feat(oid): Li22b replication — collection, POD, LSE, OID, synthesis scripts

Phase A.1: Open-loop DB collection (50 commands, LHS, new CelerisLab)
Phase A.2: Snapshot POD on Li22b DB (ROI-masked, energy analysis)
Phase A.3: LSE [sensors, b] -> POD coefficients
Phase B: Delta-q OID + cross-mapping + joint-input OID
Phase C: Three-framework synthesis (SR, Li22b, OID)

Partial data collected (in progress). Reference fields done.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank14f 2026-06-28 21:00:18 +08:00
parent 3e9cffda8e
commit 52229ea0f0
7 changed files with 739 additions and 0 deletions

View File

@ -0,0 +1,27 @@
{
"n_commands": 8,
"n_snapshots": 1600,
"dof": 240000,
"roi": [
800,
1400,
200,
400
],
"r99": 4,
"e10": 0.999856945066893,
"e2": 0.8630403049012719,
"e1": 0.532196928160917,
"top10": [
0.532196928160917,
0.3308433767403549,
0.11744563908241336,
0.00979119184601673,
0.0034631638505219966,
0.002136456704568166,
0.0018127711252703537,
0.0011799071249095198,
0.000795472213865046,
0.0001920382180559607
]
}

View File

@ -0,0 +1,119 @@
# OID_analysis/li22b/collect_openloop_db.py
"""Generate Li22b open-loop database: 50 steady control commands on fluidic pinball.
Uses new CelerisLab Simulation API, 2000x600 uniform-inlet config (no disturbance cylinder).
Saves ux/uy fields, 9 sensors (3x3 grid), and forces per command.
Usage:
conda run -n pycuda_3_10 python3 src/OID_analysis/li22b/collect_openloop_db.py --device 2
"""
import os, sys, json, time, argparse
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 CelerisLab import Simulation
U0 = 0.01; L0 = 20.0; R_CYL = 10.0
NX, NY = 2000, 600
CY = (NY - 1) / 2.0
CFG_PATH = os.path.join(_REPO, "configs", "config_lbm_karman_2000x600.json")
OUT_BASE = os.path.join(os.path.dirname(__file__), "..", "data_li22b")
WARMUP_STEPS = 4000
RECORD_STEPS = 200
DEVICE = 2
PINBALL_FRONT_X = 1000.0
PINBALL_REAR_X = 1026.0
PINBALL_Y_SPAN = 15.0
SENSOR_XS = [PINBALL_REAR_X + dx for dx in [100.0, 130.0, 160.0]]
SENSOR_YS = [CY + 25.0, CY, CY - 25.0]
def latin_hypercube(n, d=3, low=-2.0, high=2.0, seed=42):
rng = np.random.RandomState(seed)
samples = np.zeros((n, d))
for j in range(d):
perm = rng.permutation(n)
samples[:, j] = low + (high - low) * (perm + rng.rand(n)) / n
return samples
def run_one(cmd_id, b):
out_dir = os.path.join(OUT_BASE, f"{cmd_id:03d}")
fields_fp = os.path.join(out_dir, "fields.npz")
if os.path.isfile(fields_fp):
print(f" [{cmd_id:03d}] SKIP (exists)")
return
os.makedirs(out_dir, exist_ok=True)
sim = Simulation(CFG_PATH, device_id=DEVICE)
sim.add_body("circle", center=(PINBALL_FRONT_X, CY, 0.0), radius=R_CYL)
sim.add_body("circle", center=(PINBALL_REAR_X, CY + PINBALL_Y_SPAN, 0.0), radius=R_CYL)
sim.add_body("circle", center=(PINBALL_REAR_X, CY - PINBALL_Y_SPAN, 0.0), radius=R_CYL)
for sx in SENSOR_XS:
for sy in SENSOR_YS:
sim.add_body("sensor", center=(sx, sy, 0.0), radius=5.0)
sim.initialize()
for i in range(3):
sim.set_body(i, omega=float(b[i]) * U0 / R_CYL)
sim.run(WARMUP_STEPS)
ux_list, uy_list = [], []
sens_list, force_list = [], []
for step in range(RECORD_STEPS):
sim.run(1)
macro = sim.get_macroscopic()
ux_list.append(macro["ux"].copy())
uy_list.append(macro["uy"].copy())
ss, fs = [], []
for si in range(9):
sv = sim.read_sensor(si, normalize=False)
ss.extend([float(sv[0]), float(sv[1])])
sens_list.append(ss)
for fi in range(3):
fv = sim.read_force(fi)
fs.extend([float(fv[0]), float(fv[1])])
force_list.append(fs)
if (step + 1) % 100 == 0:
print(f" {step+1}/{RECORD_STEPS}")
sim.close()
np.savez_compressed(fields_fp,
ux=np.array(ux_list, dtype=np.float32),
uy=np.array(uy_list, dtype=np.float32))
np.savez_compressed(os.path.join(out_dir, "sensors.npz"),
sensors=np.array(sens_list, dtype=np.float32))
np.savez_compressed(os.path.join(out_dir, "forces.npz"),
forces=np.array(force_list, dtype=np.float32))
with open(os.path.join(out_dir, "config.json"), "w") as f:
json.dump({"b": b.tolist(), "nu": 0.004, "U0": U0,
"Re_D": 50, "Re_code": 100}, f, indent=2)
print(f" [{cmd_id:03d}] b={b[0]:+.3f},{b[1]:+.3f},{b[2]:+.3f} saved")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--device", type=int, default=2)
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--num", type=int, default=10)
ap.add_argument("--start", type=int, default=0)
args = ap.parse_args()
global DEVICE; DEVICE = args.device
b_all = latin_hypercube(args.num, seed=args.seed)
print(f"{args.num} commands, device={DEVICE}, seed={args.seed}")
print(f"Output: {OUT_BASE}")
t0 = time.time()
for i in range(args.start, args.num):
run_one(i, b_all[i])
print(f" [ETA: {(time.time()-t0)*(args.num-i-1)/((i-args.start+1)*60):.0f} min]")
print(f"Done in {(time.time()-t0)/60:.1f} min")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,82 @@
# OID_analysis/li22b/collect_reference.py
"""Collect reference fields: q_in (empty channel) and q_blk (uncontrolled pinball).
Usage:
conda run -n pycuda_3_10 python3 src/OID_analysis/li22b/collect_reference.py --device 2
"""
import os, sys, json, time, argparse
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 CelerisLab import Simulation
U0 = 0.01; R_CYL = 10.0
NX, NY = 2000, 600
CY = (NY - 1) / 2.0
CFG_PATH = os.path.join(_REPO, "configs", "config_lbm_karman_2000x600.json")
OUT_BASE = os.path.join(os.path.dirname(__file__), "..", "data_li22b")
STEPS = 200
def collect_empty_channel(device):
"""q_in: no cylinders, just uniform flow."""
out_dir = os.path.join(OUT_BASE, "q_in")
if os.path.isfile(os.path.join(out_dir, "fields.npz")):
print("q_in: SKIP (exists)")
return
os.makedirs(out_dir, exist_ok=True)
sim = Simulation(CFG_PATH, device_id=device)
sim.initialize()
sim.run(4000)
ux_list, uy_list = [], []
for _ in range(STEPS):
sim.run(1)
macro = sim.get_macroscopic()
ux_list.append(macro["ux"].copy())
uy_list.append(macro["uy"].copy())
sim.close()
np.savez_compressed(os.path.join(out_dir, "fields.npz"),
ux=np.array(ux_list, dtype=np.float32),
uy=np.array(uy_list, dtype=np.float32))
print(f"q_in: saved {STEPS} snaps")
def collect_uncontrolled_pinball(device):
"""q_blk: pinball with b=[0,0,0]."""
out_dir = os.path.join(OUT_BASE, "q_blk")
if os.path.isfile(os.path.join(out_dir, "fields.npz")):
print("q_blk: SKIP (exists)")
return
os.makedirs(out_dir, exist_ok=True)
sim = Simulation(CFG_PATH, device_id=device)
sim.add_body("circle", center=(1000.0, CY, 0.0), radius=R_CYL)
sim.add_body("circle", center=(1026.0, CY + 15.0, 0.0), radius=R_CYL)
sim.add_body("circle", center=(1026.0, CY - 15.0, 0.0), radius=R_CYL)
sim.initialize()
sim.run(4000)
ux_list, uy_list = [], []
for _ in range(STEPS):
sim.run(1)
macro = sim.get_macroscopic()
ux_list.append(macro["ux"].copy())
uy_list.append(macro["uy"].copy())
sim.close()
np.savez_compressed(os.path.join(out_dir, "fields.npz"),
ux=np.array(ux_list, dtype=np.float32),
uy=np.array(uy_list, dtype=np.float32))
print(f"q_blk: saved {STEPS} snaps")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--device", type=int, default=2)
args = ap.parse_args()
collect_empty_channel(args.device)
collect_uncontrolled_pinball(args.device)
print("Done.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,99 @@
# OID_analysis/li22b/phase_a2_pod.py
"""Phase A.2: Snapshot POD on Li22b open-loop database.
Replicates Li22b 4.1:
- Load all commands, compute ensemble mean over b,t
- POD via method-of-snapshots on ROI (x=[800:1400], y=[200:400])
- Truncate to 99% energy
- Visualize mode shapes + physical interpretation
Usage (after data collected):
PYTHONPATH="src:$PYTHONPATH" python3 src/OID_analysis/li22b/phase_a2_pod.py
"""
import os, sys, json, glob
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.utils.analysis import compute_pod
DATA_BASE = os.path.join(os.path.dirname(__file__), "..", "data_li22b")
DERIVED = os.path.join(DATA_BASE, "derived", "pod")
os.makedirs(DERIVED, exist_ok=True)
NX, NY = 2000, 600
# ROI: pinball wake region
ROI_X0, ROI_X1 = 800, 1400
ROI_Y0, ROI_Y1 = 200, 400
NX_ROI = ROI_X1 - ROI_X0; NY_ROI = ROI_Y1 - ROI_Y0
def load_all_fields():
"""Load all ux, uy snapshots from all commands, plus b vectors."""
cmd_dirs = sorted(glob.glob(os.path.join(DATA_BASE, "[0-9][0-9][0-9]")))
all_ux, all_uy, all_b = [], [], []
for d in cmd_dirs:
fp = os.path.join(d, "fields.npz")
if not os.path.isfile(fp): continue
try:
data = np.load(fp)
except Exception as e:
print(f" SKIP {os.path.basename(d)}: {e}")
continue
ux = data["ux"][:, ROI_Y0:ROI_Y1, ROI_X0:ROI_X1]
uy = data["uy"][:, ROI_Y0:ROI_Y1, ROI_X0:ROI_X1]
all_ux.append(ux); all_uy.append(uy)
with open(os.path.join(d, "config.json")) as f:
cfg = json.load(f)
all_b.append(cfg["b"])
print(f" Loaded {os.path.basename(d)}: {ux.shape[0]} snaps, b={cfg['b']}")
return (np.concatenate(all_ux, axis=0), np.concatenate(all_uy, axis=0),
np.array(all_b), len(cmd_dirs))
def build_snapshot_matrix(ux, uy):
N = ux.shape[0]
DOF = NX_ROI * NY_ROI * 2
Q = np.zeros((N, DOF), dtype=np.float64)
for t in range(N):
Q[t] = np.concatenate([ux[t].ravel(), uy[t].ravel()])
return Q
def main():
ux_all, uy_all, b_all, n_cmds = load_all_fields()
N = ux_all.shape[0]
print(f"\nTotal: {n_cmds} commands, {N} snapshots, ROI={NX_ROI}x{NY_ROI}")
# POD
Q = build_snapshot_matrix(ux_all, uy_all)
print(f"Snapshot matrix: {Q.shape} ({Q.nbytes/1e9:.2f} GB)")
pod = compute_pod(Q, rank=min(100, N-1))
S = pod["S"]; energy = pod["energy"]; cum = pod["cum_energy"]
print(f"POD: S[0]={S[0]:.2e}, cum10={cum[9]:.4f}, cum2={cum[1]:.4f}")
# Truncation to 99%
r99 = int(np.searchsorted(cum, 0.99)) + 1
print(f"99% energy: {r99} modes (Li22b: 78 modes)")
# Save
np.savez_compressed(os.path.join(DERIVED, "pod_modes.npz"),
modes=pod["modes"], mean=pod["mean"])
np.savez(os.path.join(DERIVED, "pod_coefs.npy"),
coefs=pod["coefs"], S=S, energy=energy, cum_energy=cum)
json.dump({"n_commands": n_cmds, "n_snapshots": N,
"dof": Q.shape[1], "roi": [ROI_X0,ROI_X1,ROI_Y0,ROI_Y1],
"r99": r99, "e10": float(cum[9]), "e2": float(cum[1]),
"e1": float(energy[0]), "top10": energy[:10].tolist()},
open(os.path.join(DERIVED, "energy.json"), "w"), indent=2)
# Comparison table
print(f"\n=== Energy Comparison ===")
print(f"Li22b: e1+2=44.9%, e1-10=78.9%, r99=78")
print(f"Ours: e1+2={cum[1]*100:.1f}%, e1-10={cum[9]*100:.1f}%, r99={r99}")
print(f"\nResults saved to {DERIVED}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,134 @@
# OID_analysis/li22b/phase_a3_lse.py
"""Phase A.3: LSE — [sensors, b] → POD coefficients.
Li22b Eq 2.7-2.8: T_ij solves a_i = sum_j T_ij * q_j
where q = [sensors (18 channels), b (3)] = 21 inputs.
Usage:
PYTHONPATH="src:$PYTHONPATH" python3 src/OID_analysis/li22b/phase_a3_lse.py
"""
import os, sys, json, glob
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)
DATA_BASE = os.path.join(os.path.dirname(__file__), "..", "data_li22b")
DERIVED = os.path.join(DATA_BASE, "derived")
os.makedirs(os.path.join(DERIVED, "lse"), exist_ok=True)
def load_all():
cmd_dirs = sorted(glob.glob(os.path.join(DATA_BASE, "[0-9][0-9][0-9]")))
all_sens, all_forces, all_b_snap, all_configs = [], [], [], []
for d in cmd_dirs:
sfp = os.path.join(d, "sensors.npz")
ffp = os.path.join(d, "forces.npz")
if not os.path.isfile(sfp): continue
sens = np.load(sfp)["sensors"] # (200, 18)
force = np.load(ffp)["forces"] # (200, 6)
all_sens.append(sens); all_forces.append(force)
with open(os.path.join(d, "config.json")) as f:
cfg = json.load(f)
all_configs.append(cfg)
# Repeat b for each time step
b_rep = np.tile(cfg["b"], (sens.shape[0], 1)) # (200, 3)
all_b_snap.append(b_rep)
S = np.concatenate(all_sens, axis=0)
B = np.concatenate(all_b_snap, axis=0)
N = S.shape[0]
print(f"Loaded {N} snapshots from {len(cmd_dirs)} commands")
return S, B, all_configs, len(cmd_dirs)
def main():
S, B, configs, n_cmds = load_all()
N = S.shape[0]
# Load POD coefs (handles .npy or .npy.npz naming)
coef_base = os.path.join(DERIVED, "pod", "pod_coefs.npy")
for ext in [".npy", ".npy.npz", ".npz"]:
fp = coef_base + ext
if os.path.isfile(fp):
pod = np.load(fp)
break
else:
raise FileNotFoundError(f"No pod_coefs found")
A = pod["coefs"] # (N, r)
r = A.shape[1]
print(f"POD coefs: {A.shape}, rank={r}")
# Build input matrix Q = [S, B] (21-dim)
# Standardize sensors per-channel
S_std = np.zeros_like(S)
S_mean, S_std_scale = [], []
for j in range(18):
m = np.mean(S[:, j]); s = np.std(S[:, j]) or 1.0
S_std[:, j] = (S[:, j] - m) / s
S_mean.append(m); S_std_scale.append(s)
Q = np.hstack([S_std, B]) # (N, 21)
# Train/test split by commands (80/20 per Li22b)
rng = np.random.RandomState(42)
cmd_indices = np.arange(n_cmds)
rng.shuffle(cmd_indices)
n_train = int(0.8 * n_cmds)
# Map back to snapshot indices
snaps_per_cmd = N // n_cmds
train_mask = np.zeros(N, dtype=bool)
for ci in cmd_indices[:n_train]:
train_mask[ci*snaps_per_cmd:(ci+1)*snaps_per_cmd] = True
test_mask = ~train_mask
# Solve LSE: T = (Q^T Q)^{-1} Q^T A
Q_train = Q[train_mask]; A_train = A[train_mask]
QTQ = Q_train.T @ Q_train
T = np.linalg.solve(QTQ + 1e-6 * np.eye(21), Q_train.T @ A_train) # (21, r)
print(f"T matrix shape: {T.shape}")
# Predict test set
Q_test = Q[test_mask]; A_test = A[test_mask]
A_pred = Q_test @ T
# Error metrics (Li22b Eq 3.3, 3.5)
# Per-command error ε_a(b)
eps_a = []
for ci in cmd_indices[n_train:]:
i0 = ci * snaps_per_cmd; i1 = (ci + 1) * snaps_per_cmd
mask = np.zeros(N, dtype=bool); mask[i0:i1] = True
mask_test = mask[test_mask]
if np.sum(mask_test) == 0: continue
a_true = A_test[mask_test]; a_pred = A_pred[mask_test]
num = np.mean(np.sum((a_true - a_pred)**2, axis=1))
den = np.mean(np.sum(a_true**2, axis=1))
eps_a.append(float(np.sqrt(num / den)) if den > 1e-30 else 0.0)
E = float(np.sqrt(np.mean(np.array(eps_a)**2)))
print(f"\n=== LSE Results ===")
print(f"Train commands: {n_train}, Test: {n_cmds - n_train}")
print(f"Mean ε_a: {np.mean(eps_a):.4f} ± {np.std(eps_a):.4f}")
print(f"Overall E: {E:.4f} (Li22b ~0.15-0.25 for periodic)")
print(f"Best ε_a: {np.min(eps_a):.4f}, Worst: {np.max(eps_a):.4f}")
# Per-mode estimation error
per_mode_mse = np.mean((A_test - A_pred)**2, axis=0)
per_mode_energy = np.mean(A_test**2, axis=0)
per_mode_err = np.sqrt(per_mode_mse / (per_mode_energy + 1e-30))
print(f"\nTop 10 mode errors: {per_mode_err[:10].round(4).tolist()}")
# Save
out_dir = os.path.join(DERIVED, "lse")
np.savez(os.path.join(out_dir, "lse_T.npz"), T=T,
S_mean=S_mean, S_std=S_std_scale)
json.dump({"n_train": n_train, "n_test": n_cmds - n_train,
"E": E, "mean_eps_a": float(np.mean(eps_a)),
"std_eps_a": float(np.std(eps_a)),
"eps_a_all": eps_a,
"per_mode_err": per_mode_err[:20].tolist()},
open(os.path.join(out_dir, "lse_results.json"), "w"), indent=2)
print(f"\nSaved to {out_dir}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,139 @@
# OID_analysis/li22b/phase_b.py
"""Phase B: OID on Li22b DB + Cross-mapping + Joint-input OID.
B.1: Correction-field OID: Delta_q_ctl(b) = q_ctl(b) - q_blk,
Force-OID with Y = force per command.
B.2: Li22b POD modes (phase A.2) vs OID modes cross-mapping via subspace overlap.
B.3: Joint-input OID: Y = [forces, sensors] concatenated.
Usage:
PYTHONPATH="src:$PYTHONPATH" python3 src/OID_analysis/li22b/phase_b.py
"""
import os, sys, json, glob
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.utils.analysis import compute_pod, compute_force_oid, standardize, reconstruct_oid_modes
DATA_BASE = os.path.join(os.path.dirname(__file__), "..", "data_li22b")
DERIVED = os.path.join(DATA_BASE, "derived")
ROI_X0, ROI_X1 = 800, 1400
ROI_Y0, ROI_Y1 = 200, 400
NY_ROI = ROI_Y1 - ROI_Y0; NX_ROI = ROI_X1 - ROI_X0
def load_qblk():
fp = os.path.join(DATA_BASE, "q_blk", "fields.npz")
d = np.load(fp)
return d["ux"][:, ROI_Y0:ROI_Y1, ROI_X0:ROI_X1], d["uy"][:, ROI_Y0:ROI_Y1, ROI_X0:ROI_X1]
def load_cmd(cmd_dir):
d = np.load(os.path.join(cmd_dir, "fields.npz"))
sens = np.load(os.path.join(cmd_dir, "sensors.npz"))["sensors"]
force = np.load(os.path.join(cmd_dir, "forces.npz"))["forces"]
with open(os.path.join(cmd_dir, "config.json")) as f:
b = json.load(f)["b"]
return (d["ux"][:, ROI_Y0:ROI_Y1, ROI_X0:ROI_X1],
d["uy"][:, ROI_Y0:ROI_Y1, ROI_X0:ROI_X1], sens, force, b)
def snapshot_matrix(ux, uy):
N = ux.shape[0]; DOF = NY_ROI * NX_ROI * 2
Q = np.zeros((N, DOF), dtype=np.float64)
for t in range(N):
Q[t] = np.concatenate([ux[t].ravel(), uy[t].ravel()])
return Q
def main():
out_dir = os.path.join(DERIVED, "oid_li22b")
os.makedirs(out_dir, exist_ok=True)
ux_blk, uy_blk = load_qblk()
print(f"q_blk: {ux_blk.shape}")
cmd_dirs = sorted(glob.glob(os.path.join(DATA_BASE, "[0-9][0-9][0-9]")))
valid_dirs = [d for d in cmd_dirs if os.path.isfile(os.path.join(d, "forces.npz"))]
print(f"Commands: {len(valid_dirs)}")
# --- B.1: Correction-field OID per command ---
all_delta = []; all_force = []; all_sens = []; all_b = []
for d in valid_dirs:
ux_ctl, uy_ctl, sens, force, b = load_cmd(d)
N = min(ux_ctl.shape[0], ux_blk.shape[0])
dux = ux_ctl[:N] - ux_blk[:N]; duy = uy_ctl[:N] - uy_blk[:N]
all_delta.append((dux, duy))
# Aggregate force: mean over time
all_force.append(np.mean(force[:N], axis=0))
all_sens.append(np.mean(sens[:N], axis=0))
all_b.append(b)
# Concatenate all Delta q_ctl
ux_all = np.concatenate([d[0] for d in all_delta], axis=0)
uy_all = np.concatenate([d[1] for d in all_delta], axis=0)
Q = snapshot_matrix(ux_all, uy_all)
print(f"Delta-q_ctl snapshot: {Q.shape}")
# POD on Delta-q_ctl
pod_li22b = compute_pod(Q, rank=min(20, Q.shape[0]-1))
print(f"Delta-q POD: cum5={pod_li22b['cum_energy'][4]:.4f}")
# Force-OID: Y = force per command, expanded to all snapshots
snaps_per_cmd = ux_all.shape[0] // len(valid_dirs)
Y_force = np.repeat(np.array(all_force), snaps_per_cmd, axis=0)[:ux_all.shape[0]]
Yf_std, _, _ = standardize(Y_force.astype(np.float64))
A_std, _, _ = standardize(pod_li22b["coefs"].astype(np.float64))
oid_force = compute_force_oid(A_std, Yf_std)
print(f"Force-OID on Li22b DB: S[0]={oid_force['S'][0]:.4f}")
# --- B.2: Cross-mapping Li22b POD ↔ OID modes ---
# Load Li22b full POD modes (from phase A.2)
li22b_pod_fp = os.path.join(DERIVED, "pod", "pod_modes.npz")
if os.path.isfile(li22b_pod_fp):
li22b_modes = np.load(li22b_pod_fp)["modes"]
r_min = min(li22b_modes.shape[1], pod_li22b["modes"].shape[1], 10)
# Subspace overlap: O(Phi_Li22b, Phi_OID)
overlap_matrix = np.zeros((r_min, r_min))
for i in range(r_min):
for j in range(r_min):
overlap_matrix[i,j] = abs(np.dot(li22b_modes[:,i], pod_li22b["modes"][:,j]))
print(f"\nLi22b POD x OID subspace overlap (first {r_min} modes):")
print(" " + " ".join([f"OID{j}" for j in range(r_min)]))
for i in range(r_min):
row = " ".join([f"{overlap_matrix[i,j]:.3f}" for j in range(r_min)])
print(f"Li{i:>2d} {row}")
np.savez(os.path.join(out_dir, "crossmap.npz"), overlap=overlap_matrix)
# Top matches
for i in range(min(3, r_min)):
top_j = int(np.argmax(overlap_matrix[i]))
print(f" Li22b mode {i} best matches OID mode {top_j} (overlap={overlap_matrix[i,top_j]:.3f})")
# --- B.3: Joint-input OID ---
Y_sens = np.repeat(np.array(all_sens), snaps_per_cmd, axis=0)[:ux_all.shape[0]]
Ys_std, _, _ = standardize(Y_sens.astype(np.float64))
Y_joint = np.hstack([Yf_std, Ys_std])
oid_joint = compute_force_oid(A_std, Y_joint)
print(f"\nJoint-OID: S[0]={oid_joint['S'][0]:.4f}, cum2={oid_joint['cum_corr'][1]:.4f}")
# Compare: does joint combine both channels?
print("\n=== Comparison ===")
print(f"Force-OID S[0]: {oid_force['S'][0]:.4f}")
print(f"Joint-OID S[0]: {oid_joint['S'][0]:.4f}")
# Save
np.savez(os.path.join(out_dir, "oid_force.npz"),
U=oid_force["U"], S=oid_force["S"], z=oid_force["z"])
np.savez(os.path.join(out_dir, "oid_joint.npz"),
U=oid_joint["U"], S=oid_joint["S"], z=oid_joint["z"])
np.savez(os.path.join(out_dir, "pod_delta_q.npz"),
modes=pod_li22b["modes"], mean=pod_li22b["mean"],
energy=pod_li22b["energy"])
print(f"\nSaved to {out_dir}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,139 @@
# OID_analysis/li22b/phase_c_synthesis.py
"""Phase C: Three-framework synthesis — SR, Li22b, OID.
C.1: Unified POD (Li22b DB + PPO DB global POD)
C.2: Cross-framework comparison table + interpretation
Usage:
PYTHONPATH="src:$PYTHONPATH" python3 src/OID_analysis/li22b/phase_c_synthesis.py
"""
import os, sys, json, glob, 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)
DATA_BASE = os.path.join(os.path.dirname(__file__), "..", "data_li22b")
DERIVED = os.path.join(DATA_BASE, "derived")
OID_DERIVED = os.path.join(os.path.dirname(__file__), "..", "data", "derived")
def load_li22b_lse():
fp = os.path.join(DERIVED, "lse", "lse_results.json")
if not os.path.isfile(fp): return None
return json.load(open(fp))
def load_oid_results():
"""Load existing OID results from old PPO data."""
fp = os.path.join(OID_DERIVED, "master", "master_table.json")
if not os.path.isfile(fp): return None
return json.load(open(fp))
def load_li22b_oid():
fp = os.path.join(DERIVED, "oid_li22b", "crossmap.npz")
if not os.path.isfile(fp): return None
return np.load(fp)["overlap"]
def load_li22b_pod_energy():
fp = os.path.join(DERIVED, "pod", "energy.json")
if not os.path.isfile(fp): return None
return json.load(open(fp))
def main():
lse = load_li22b_lse()
oid_res = load_oid_results()
crossmap = load_li22b_oid()
pod_en = load_li22b_pod_energy()
print("=" * 70)
print("Three-Framework Synthesis: SR + Li22b + OID")
print("=" * 70)
print("\n--- 1. Li22b LSE ---")
if lse:
print(f" Overall error E: {lse['E']:.4f}")
print(f" Mean per-command eps_a: {lse['mean_eps_a']:.4f} +/- {lse['std_eps_a']:.4f}")
else:
print(" (not yet computed — run phase_a3_lse.py)")
print("\n--- 2. Li22b POD Energy ---")
if pod_en:
print(f" Commands: {pod_en['n_commands']}, Snapshots: {pod_en['n_snapshots']}")
print(f" E2={pod_en['e2']*100:.1f}%, E10={pod_en['e10']*100:.1f}%")
print(f" 99% truncation: {pod_en['r99']} modes")
print(f" Li22b reference: E2=44.9%, E10=78.9%, r99=78")
print("\n--- 3. Li22b OID ---")
if crossmap is not None:
r = crossmap.shape[0]
# Top cross-mappings
print(f" Mode cross-mapping ({r}x{r}):")
for i in range(min(5, r)):
top_j = int(np.argmax(crossmap[i]))
print(f" Li22b mode {i} → OID mode {top_j} (overlap={crossmap[i,top_j]:.3f})")
print("\n--- 4. PPO OID Results (from previous work) ---")
if oid_res:
print(f" (loaded from {OID_DERIVED}/master/master_table.json)")
else:
print(" (not found)")
print("\n--- 5. Synthesis ---")
interpretations = []
# Is Li22b POD energy distribution consistent with PPO POD?
if pod_en and pod_en['e10'] > 0.7:
interpretations.append(
"Li22b POD confirms low-dimensionality of pinball flows (E10~{:.0f}%). "
"Supports OID's default r=10 POD truncation.".format(pod_en['e10']*100))
# Does LSE error explain why SR works?
if lse and lse['E'] < 0.5:
interpretations.append(
"LSE achieves moderate estimation accuracy (E={:.3f}), suggesting "
"a linear component EXISTS in [s,b]→field mapping. This explains why "
"SR can find clean formulas: SR's obs→act chain operates on the same "
"sensor channels, and act is lower-dimensional than full field.".format(lse['E']))
# Cross-mapping interpretation
if crossmap is not None:
# Check if any Li22b mode strongly overlaps with OID modes
max_overlap = float(np.max(crossmap))
interpretations.append(
"Max Li22b-OID mode overlap = {:.3f}. ".format(max_overlap) +
("The two POD bases share significant structure — steady-control "
"and dynamic-control correction fields lie in overlapping subspaces."
if max_overlap > 0.7 else
"Steady and dynamic control engage notably different POD structures, "
"confirming that control temporal dynamics fundamentally change the "
"correction space."))
# Key difference: Li22b uses steady open-loop, we use dynamic PPO
interpretations.append(
"CRITICAL DISTINCTION: Li22b estimates FULL fields from [s,b] under STEADY "
"controls. Our OID diagnoses CORRECTION structures in Δq_ctl under DYNAMIC "
"PPO. The bridge between them quantifies how much of the dynamic control "
"maneuver can be captured by the steady-control POD basis.")
for it in interpretations:
print(f"{it}")
# Save synthesis
out = {
"li22b_lse_E": lse['E'] if lse else None,
"li22b_pod_e10": pod_en['e10'] if pod_en else None,
"li22b_pod_r99": pod_en['r99'] if pod_en else None,
"max_crossmap_overlap": float(np.max(crossmap)) if crossmap is not None else None,
"interpretations": interpretations,
}
with open(os.path.join(DERIVED, "synthesis.json"), "w") as f:
json.dump(out, f, indent=2)
print(f"\nSaved synthesis to {DERIVED}/synthesis.json")
if __name__ == "__main__":
main()