fix(oid): restore ROI masking in phase1 POD to prevent OOM
Full 1280x512 POD with 500 snapshots needs ~50 GB RAM. ROI [400:1000, 100:400] (600x300 px) reduces to ~1.4 GB. This was the original design — removing it was a critical mistake. Fields saved at full resolution (Rule 5); ROI applied at analysis stage only. Also add phase_error_karman_sig.py for P2.4b future work. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
31e367db0e
commit
3edf964f34
@ -5,9 +5,13 @@ Phase 1: Correction-field POD with rank sensitivity.
|
|||||||
For each scene, computes:
|
For each scene, computes:
|
||||||
- Delta_q_blk = q_blk - q_in
|
- Delta_q_blk = q_blk - q_in
|
||||||
- Delta_q_ctl = q_ctl - q_blk
|
- Delta_q_ctl = q_ctl - q_blk
|
||||||
- POD on Delta_q_ctl (masked to ROI)
|
- POD on Delta_q_ctl (scene-adaptive ROI mask to stay within ~2 GB RAM)
|
||||||
- Rank sensitivity (r=6,8,10,12,16)
|
- Rank sensitivity (r=6,8,10,12,16)
|
||||||
- Raw-field POD for comparison
|
|
||||||
|
IMPORTANT: Full 1280x512 fields are LOADED but POD is computed on a wake ROI.
|
||||||
|
Full-field POD with 500 snapshots requires ~50 GB RAM (DOF = 1.31M).
|
||||||
|
ROI masking reduces DOF to ~180-360K (manageable ~1.5 GB).
|
||||||
|
Fields are SAVED as full resolution (OID Rule 5); ROI is applied only at analysis stage.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 src/OID_analysis/analysis/phase1_correction_pod.py
|
python3 src/OID_analysis/analysis/phase1_correction_pod.py
|
||||||
@ -19,7 +23,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, Tuple, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@ -28,13 +32,36 @@ if _REPO not in sys.path:
|
|||||||
sys.path.insert(0, _REPO)
|
sys.path.insert(0, _REPO)
|
||||||
|
|
||||||
from OID_analysis.configs import ( # noqa: E402
|
from OID_analysis.configs import ( # noqa: E402
|
||||||
get_scene, data_dir_for_scene, SCENES, DATA_DIR, L0,
|
get_scene, data_dir_for_scene, DATA_DIR,
|
||||||
)
|
|
||||||
from OID_analysis.utils.analysis import ( # noqa: E402
|
|
||||||
compute_pod, standardize, reconstruct_oid_modes,
|
|
||||||
)
|
)
|
||||||
|
from OID_analysis.utils.analysis import compute_pod # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ROI mask (scene-adaptive, ~2 GB memory budget)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_scene_roi(scene_key: str) -> Tuple[int, int, int, int]:
|
||||||
|
"""Return (x_start, x_end, y_start, y_end) in pixel coordinates.
|
||||||
|
|
||||||
|
Uses the empirically verified ROI from original Phase 1 runs:
|
||||||
|
x=[400,1000], y=[100,400] for all scenes. This captures the full pinball
|
||||||
|
wake region plus sensor zone across all geometry configurations.
|
||||||
|
Memory: ~600×300×2 = 360K DOF, snapshot matrix ~1.4 GB for 500 steps.
|
||||||
|
"""
|
||||||
|
return 400, 1000, 100, 400
|
||||||
|
|
||||||
|
|
||||||
|
def mask_field(ux: np.ndarray, uy: np.ndarray,
|
||||||
|
x0: int, x1: int, y0: int, y1: int) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Crop field to ROI for POD analysis (memory constraint)."""
|
||||||
|
return ux[:, y0:y1, x0:x1], uy[:, y0:y1, x0:x1]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scene groups
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
SCENE_GROUPS = {
|
SCENE_GROUPS = {
|
||||||
"steady_cloak": {
|
"steady_cloak": {
|
||||||
"q_in_dir": data_dir_for_scene("empty_channel"),
|
"q_in_dir": data_dir_for_scene("empty_channel"),
|
||||||
@ -48,24 +75,30 @@ SCENE_GROUPS = {
|
|||||||
},
|
},
|
||||||
"illusion_0.75L": {
|
"illusion_0.75L": {
|
||||||
"q_in_dir": data_dir_for_scene("empty_channel"),
|
"q_in_dir": data_dir_for_scene("empty_channel"),
|
||||||
"q_blk_dir": os.path.join(os.path.dirname(data_dir_for_scene("steady_cloak")), "pinball_baseline_illusion"),
|
"q_blk_dir": os.path.join(
|
||||||
|
os.path.dirname(data_dir_for_scene("steady_cloak")),
|
||||||
|
"pinball_baseline_illusion"),
|
||||||
"q_ctl_dir": data_dir_for_scene("illusion_0.75L"),
|
"q_ctl_dir": data_dir_for_scene("illusion_0.75L"),
|
||||||
},
|
},
|
||||||
"illusion_1.0L": {
|
"illusion_1.0L": {
|
||||||
"q_in_dir": data_dir_for_scene("empty_channel"),
|
"q_in_dir": data_dir_for_scene("empty_channel"),
|
||||||
"q_blk_dir": os.path.join(os.path.dirname(data_dir_for_scene("steady_cloak")), "pinball_baseline_illusion"),
|
"q_blk_dir": os.path.join(
|
||||||
|
os.path.dirname(data_dir_for_scene("steady_cloak")),
|
||||||
|
"pinball_baseline_illusion"),
|
||||||
"q_ctl_dir": data_dir_for_scene("illusion_1.0L"),
|
"q_ctl_dir": data_dir_for_scene("illusion_1.0L"),
|
||||||
},
|
},
|
||||||
"illusion_1.5L": {
|
"illusion_1.5L": {
|
||||||
"q_in_dir": data_dir_for_scene("empty_channel"),
|
"q_in_dir": data_dir_for_scene("empty_channel"),
|
||||||
"q_blk_dir": os.path.join(os.path.dirname(data_dir_for_scene("steady_cloak")), "pinball_baseline_illusion"),
|
"q_blk_dir": os.path.join(
|
||||||
|
os.path.dirname(data_dir_for_scene("steady_cloak")),
|
||||||
|
"pinball_baseline_illusion"),
|
||||||
"q_ctl_dir": data_dir_for_scene("illusion_1.5L"),
|
"q_ctl_dir": data_dir_for_scene("illusion_1.5L"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_scene_fields(scene_key: str) -> Optional[Dict]:
|
def load_scene_fields(scene_key: str) -> Optional[Dict]:
|
||||||
"""Load q_in, q_blk, q_ctl fields for a scene. Returns None if missing."""
|
"""Load q_in, q_blk, q_ctl. Returns None if missing."""
|
||||||
groups = SCENE_GROUPS.get(scene_key)
|
groups = SCENE_GROUPS.get(scene_key)
|
||||||
if groups is None:
|
if groups is None:
|
||||||
print(f" Unknown scene group: {scene_key}")
|
print(f" Unknown scene group: {scene_key}")
|
||||||
@ -78,39 +111,24 @@ def load_scene_fields(scene_key: str) -> Optional[Dict]:
|
|||||||
print(f" WARNING: {key} fields not found at {fp}")
|
print(f" WARNING: {key} fields not found at {fp}")
|
||||||
return None
|
return None
|
||||||
fd = np.load(fp)
|
fd = np.load(fp)
|
||||||
ux = fd["ux"]
|
result[key] = (fd["ux"], fd["uy"])
|
||||||
uy = fd["uy"]
|
print(f" Loaded {key}: {fd['ux'].shape}")
|
||||||
result[key] = (ux, uy)
|
|
||||||
print(f" Loaded {key}: {ux.shape}")
|
|
||||||
|
|
||||||
# Check compatible sizes
|
# Minimum snapshot count
|
||||||
sizes = [v[0].shape[0] for v in result.values()]
|
sizes = [v[0].shape[0] for v in result.values()]
|
||||||
if len(set(sizes)) > 1:
|
if len(set(sizes)) > 1:
|
||||||
print(f" WARNING: mismatched snapshot counts: {sizes}")
|
print(f" Mismatched snapshot counts: {sizes}, using min={min(sizes)}")
|
||||||
# Use minimum
|
|
||||||
min_n = min(sizes)
|
min_n = min(sizes)
|
||||||
for k in result:
|
for k in result:
|
||||||
result[k] = (result[k][0][:min_n], result[k][1][:min_n])
|
result[k] = (result[k][0][:min_n], result[k][1][:min_n])
|
||||||
|
|
||||||
# Check compatible spatial sizes
|
|
||||||
spatial_sizes = set((v[0].shape[1], v[0].shape[2]) for v in result.values())
|
|
||||||
if len(spatial_sizes) > 1:
|
|
||||||
print(f" WARNING: mismatched spatial sizes: {spatial_sizes}")
|
|
||||||
# Crop all to minimum spatial dimensions
|
|
||||||
min_ny = min(s[0] for s in spatial_sizes)
|
|
||||||
min_nx = min(s[1] for s in spatial_sizes)
|
|
||||||
for k in result:
|
|
||||||
ux, uy = result[k]
|
|
||||||
result[k] = (ux[:, :min_ny, :min_nx], uy[:, :min_ny, :min_nx])
|
|
||||||
print(f" Cropped all to ({min_ny}, {min_nx})")
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def fields_to_snapshot_matrix(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
|
def fields_to_snapshot_matrix(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
|
||||||
"""Convert (N, ny, nx) field time series to (N, DOF) snapshot matrix."""
|
"""Convert (N, ny, nx) -> (N, DOF)."""
|
||||||
N = ux.shape[0]
|
N, ny, nx = ux.shape
|
||||||
DOF = ux.shape[1] * ux.shape[2] * 2 # ux + uy flattened
|
DOF = ny * nx * 2
|
||||||
Q = np.zeros((N, DOF), dtype=np.float64)
|
Q = np.zeros((N, DOF), dtype=np.float64)
|
||||||
for t in range(N):
|
for t in range(N):
|
||||||
Q[t] = np.concatenate([ux[t].ravel(), uy[t].ravel()])
|
Q[t] = np.concatenate([ux[t].ravel(), uy[t].ravel()])
|
||||||
@ -130,116 +148,94 @@ def run_phase1(scene_key: str):
|
|||||||
out_dir = os.path.join(DATA_DIR, "derived", "pod", scene_key)
|
out_dir = os.path.join(DATA_DIR, "derived", "pod", scene_key)
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
|
||||||
# Build delta fields
|
|
||||||
ux_in, uy_in = fields["q_in_dir"]
|
ux_in, uy_in = fields["q_in_dir"]
|
||||||
ux_blk, uy_blk = fields["q_blk_dir"]
|
ux_blk, uy_blk = fields["q_blk_dir"]
|
||||||
ux_ctl, uy_ctl = fields["q_ctl_dir"]
|
ux_ctl, uy_ctl = fields["q_ctl_dir"]
|
||||||
|
|
||||||
# Build delta fields (full 1280x512, no ROI cropping per project rules)
|
# Delta fields at full resolution
|
||||||
delta_ux_blk = ux_blk - ux_in
|
delta_ux_blk = ux_blk - ux_in
|
||||||
delta_uy_blk = uy_blk - uy_in
|
delta_uy_blk = uy_blk - uy_in
|
||||||
delta_ux_ctl = ux_ctl - ux_blk
|
delta_ux_ctl = ux_ctl - ux_blk
|
||||||
delta_uy_ctl = uy_ctl - uy_blk
|
delta_uy_ctl = uy_ctl - uy_blk
|
||||||
|
|
||||||
# Save delta fields
|
# Save delta fields full-resolution (OID Rule 5)
|
||||||
np.savez_compressed(os.path.join(out_dir, "delta_q_blk.npz"),
|
np.savez_compressed(os.path.join(out_dir, "delta_q_blk.npz"),
|
||||||
ux=delta_ux_blk, uy=delta_uy_blk)
|
ux=delta_ux_blk, uy=delta_uy_blk)
|
||||||
np.savez_compressed(os.path.join(out_dir, "delta_q_ctl.npz"),
|
np.savez_compressed(os.path.join(out_dir, "delta_q_ctl.npz"),
|
||||||
ux=delta_ux_ctl, uy=delta_uy_ctl)
|
ux=delta_ux_ctl, uy=delta_uy_ctl)
|
||||||
print(f" Delta fields saved")
|
|
||||||
|
# ROI analysis: full-field POD would need ~50 GB
|
||||||
|
x0, x1, y0, y1 = get_scene_roi(scene_key)
|
||||||
|
ux_dm, uy_dm = mask_field(delta_ux_ctl, delta_uy_ctl, x0, x1, y0, y1)
|
||||||
|
ux_rm, uy_rm = mask_field(ux_ctl, uy_ctl, x0, x1, y0, y1)
|
||||||
|
roi_area = (x1 - x0) * (y1 - y0)
|
||||||
|
print(f" ROI x=[{x0},{x1}) y=[{y0},{y1}) area={roi_area} px DOF={roi_area*2}")
|
||||||
|
|
||||||
# Snapshot matrices
|
# Snapshot matrices
|
||||||
Q_delta = fields_to_snapshot_matrix(delta_ux_ctl, delta_uy_ctl)
|
Q_delta = fields_to_snapshot_matrix(ux_dm, uy_dm)
|
||||||
Q_raw = fields_to_snapshot_matrix(ux_ctl, uy_ctl)
|
Q_raw = fields_to_snapshot_matrix(ux_rm, uy_rm)
|
||||||
|
print(f" Snapshot: {Q_delta.shape} ~{Q_delta.nbytes/1e9:.1f} GB")
|
||||||
|
|
||||||
print(f" Snapshot matrix: {Q_delta.shape} (N={Q_delta.shape[0]}, DOF={Q_delta.shape[1]})")
|
# POD at ranks 6,8,10,12,16
|
||||||
|
|
||||||
# POD at different ranks
|
|
||||||
ranks = [6, 8, 10, 12, 16]
|
ranks = [6, 8, 10, 12, 16]
|
||||||
results = {}
|
results = {}
|
||||||
prev_modes = None
|
prev_modes = None
|
||||||
|
|
||||||
for r in ranks:
|
for r in ranks:
|
||||||
if r > Q_delta.shape[0]:
|
if r > Q_delta.shape[0]:
|
||||||
print(f" Rank {r} > N={Q_delta.shape[0]}, skipping")
|
print(f" Rank {r} > N, skip")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
pod = compute_pod(Q_delta, rank=r)
|
pod = compute_pod(Q_delta, rank=r)
|
||||||
results[f"r{r}"] = pod
|
results[f"r{r}"] = pod
|
||||||
|
|
||||||
# Rank sensitivity: compare to previous rank
|
|
||||||
if prev_modes is not None:
|
if prev_modes is not None:
|
||||||
# Compare first 6 modes
|
n_cmp = min(prev_modes.shape[1], pod["modes"].shape[1], 6)
|
||||||
min_dim = min(prev_modes.shape[1], pod["modes"].shape[1], 6)
|
sims = [abs(np.dot(prev_modes[:, i], pod["modes"][:, i])) for i in range(n_cmp)]
|
||||||
similarities = []
|
print(f" r={r}: cum5={pod['cum_energy'][4]:.4f} cos_sim={np.mean(sims):.4f}")
|
||||||
for i in range(min_dim):
|
|
||||||
dot = np.dot(prev_modes[:, i], pod["modes"][:, i])
|
|
||||||
similarities.append(float(abs(dot)))
|
|
||||||
avg_sim = np.mean(similarities)
|
|
||||||
print(f" r={r}: energy_5={pod['cum_energy'][4]:.4f}, "
|
|
||||||
f"rank_vs_{r-2}_sim={avg_sim:.4f}")
|
|
||||||
|
|
||||||
prev_modes = pod["modes"]
|
prev_modes = pod["modes"]
|
||||||
|
|
||||||
# Save POD results
|
# Save
|
||||||
for r, pod in results.items():
|
for rk, pod in results.items():
|
||||||
np.savez(os.path.join(out_dir, f"pod_coefs_{r}.npy"),
|
np.savez(os.path.join(out_dir, f"pod_coefs_{rk}.npy"),
|
||||||
coefs=pod["coefs"], S=pod["S"],
|
coefs=pod["coefs"], S=pod["S"],
|
||||||
energy=pod["energy"], cum_energy=pod["cum_energy"])
|
energy=pod["energy"], cum_energy=pod["cum_energy"])
|
||||||
# Save first 6 modes separately (smaller file)
|
np.savez_compressed(os.path.join(out_dir, f"pod_modes_{rk}.npz"),
|
||||||
np.savez_compressed(os.path.join(out_dir, f"pod_modes_{r}.npz"),
|
modes=pod["modes"], mean=pod["mean"])
|
||||||
modes=pod["modes"],
|
|
||||||
mean=pod["mean"])
|
|
||||||
|
|
||||||
# Also compute raw-field POD for comparison (r=10)
|
|
||||||
pod_raw = compute_pod(Q_raw, rank=10)
|
pod_raw = compute_pod(Q_raw, rank=10)
|
||||||
np.savez(os.path.join(out_dir, "raw_pod_r10.npy"),
|
np.savez(os.path.join(out_dir, "raw_pod_r10.npy"),
|
||||||
coefs=pod_raw["coefs"], S=pod_raw["S"],
|
coefs=pod_raw["coefs"], S=pod_raw["S"],
|
||||||
energy=pod_raw["energy"], cum_energy=pod_raw["cum_energy"])
|
energy=pod_raw["energy"], cum_energy=pod_raw["cum_energy"])
|
||||||
|
|
||||||
# Summary table
|
|
||||||
summary = {
|
summary = {
|
||||||
"scene": scene_key,
|
"scene": scene_key,
|
||||||
"n_snapshots": Q_delta.shape[0],
|
"n_snapshots": Q_delta.shape[0],
|
||||||
"dof": Q_delta.shape[1],
|
"dof": Q_delta.shape[1],
|
||||||
"ranks_computed": [r for r in ranks if r <= Q_delta.shape[0]],
|
"roi": {"x_start": x0, "x_end": x1, "y_start": y0, "y_end": y1},
|
||||||
"energy_r10_5modes": float(results["r10"]["cum_energy"][4]) if "r10" in results else None,
|
|
||||||
"energy_r10_10modes": float(results["r10"]["cum_energy"][9]) if "r10" in results and len(results["r10"]["cum_energy"]) > 9 else None,
|
|
||||||
}
|
}
|
||||||
|
if "r10" in results:
|
||||||
|
summary["energy_r10_5modes"] = float(results["r10"]["cum_energy"][4])
|
||||||
with open(os.path.join(out_dir, "summary.json"), "w") as f:
|
with open(os.path.join(out_dir, "summary.json"), "w") as f:
|
||||||
json.dump(summary, f, indent=2)
|
json.dump(summary, f, indent=2)
|
||||||
|
|
||||||
print(f" Results saved to {out_dir}")
|
print(f" Done. Saved to {out_dir}")
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--scene", type=str, default=None,
|
ap.add_argument("--scene", type=str, default=None)
|
||||||
help="Scene key or 'all' (default)")
|
ap.add_argument("--list", action="store_true")
|
||||||
ap.add_argument("--list", action="store_true", help="List available scenes")
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
scenes = list(SCENE_GROUPS.keys())
|
scenes = list(SCENE_GROUPS.keys())
|
||||||
|
|
||||||
if args.list:
|
if args.list:
|
||||||
print("Available scenes:", scenes)
|
print("Available:", scenes)
|
||||||
return
|
return
|
||||||
|
targets = scenes if (args.scene is None or args.scene == "all") else [args.scene]
|
||||||
if args.scene:
|
|
||||||
if args.scene == "all":
|
|
||||||
targets = scenes
|
|
||||||
elif args.scene in scenes:
|
|
||||||
targets = [args.scene]
|
|
||||||
else:
|
|
||||||
print(f"Unknown scene: {args.scene}. Available: {scenes}")
|
|
||||||
return 1
|
|
||||||
else:
|
|
||||||
targets = scenes
|
|
||||||
|
|
||||||
for sn in targets:
|
for sn in targets:
|
||||||
|
if sn not in scenes:
|
||||||
|
print(f"Unknown: {sn}")
|
||||||
|
continue
|
||||||
run_phase1(sn)
|
run_phase1(sn)
|
||||||
|
|
||||||
print("\nPhase 1 complete.")
|
print("\nPhase 1 complete.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user