第一轮分析工作暂存
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
# analysis_crossre/scripts/diagnose_equivariance.py
|
||||
"""Phase A2-A3: diagnose PPO control-law equivariance under G operator.
|
||||
|
||||
Usage::
|
||||
|
||||
conda run -n pycuda_3_10 python diagnose_equivariance.py --re 100 --device 0
|
||||
|
||||
conda run -n pycuda_3_10 python diagnose_equivariance.py --re all --device 0
|
||||
|
||||
Output per Re: ``output/analysis_crossre/diagnostic/equivariance_re{re}.json``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
_PROJ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _PROJ not in sys.path:
|
||||
sys.path.insert(0, _PROJ)
|
||||
from LegacyCelerisLab import FlowField # noqa: E402
|
||||
from LegacyCelerisLab import utils as legacy_utils # noqa: E402
|
||||
|
||||
from utils import (
|
||||
action_to_physical,
|
||||
compute_dimensionless,
|
||||
apply_G_x,
|
||||
apply_G_alpha,
|
||||
load_ppo_model,
|
||||
nu_from_re,
|
||||
load_legacy_configs,
|
||||
build_karman_cloak_env,
|
||||
add_pinball,
|
||||
build_observation,
|
||||
scale_action,
|
||||
)
|
||||
from cfg import (
|
||||
CONFIG_DIR,
|
||||
OUTPUT_DIR,
|
||||
MODEL_DIR,
|
||||
SAMPLE_INTERVAL,
|
||||
FIFO_LEN,
|
||||
CONV_LEN,
|
||||
S_DIM,
|
||||
A_DIM,
|
||||
ACTION_SCALE,
|
||||
ACTION_BIAS,
|
||||
U0,
|
||||
RE_CASES_TRAIN,
|
||||
RE_LABEL_MAP,
|
||||
)
|
||||
|
||||
DATA_TYPE = np.float32
|
||||
|
||||
|
||||
def diagnose_one_re(re_code: int, ppo_device: int, cfd_device: int, output_root: str) -> dict:
|
||||
"""Run equivariance diagnosis for one Re case."""
|
||||
os.makedirs(output_root, exist_ok=True)
|
||||
|
||||
nu = nu_from_re(re_code, u0=U0)
|
||||
mu = 2.0 / re_code
|
||||
label = RE_LABEL_MAP.get(re_code, f"Re{re_code}")
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Diagnosing: {label} nu={nu:.6f} mu={mu:.6f}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Build full environment (dist + sensors + pinball)
|
||||
cuda_cfg, field_cfg = load_legacy_configs(CONFIG_DIR)
|
||||
field_cfg = field_cfg._replace(viscosity=float(nu))
|
||||
ff = FlowField(field_cfg, cuda_cfg, device_id=cfd_device)
|
||||
|
||||
# Stabilize and get to controlled state
|
||||
target_states, _ = build_karman_cloak_env(
|
||||
ff, u0=U0, l0=20.0, sample_interval=SAMPLE_INTERVAL,
|
||||
fifo_len=FIFO_LEN, data_type=DATA_TYPE,
|
||||
)
|
||||
norm = add_pinball(
|
||||
ff, l0=20.0, u0=U0, sample_interval=SAMPLE_INTERVAL,
|
||||
fifo_len=FIFO_LEN, data_type=DATA_TYPE,
|
||||
action_bias=ACTION_BIAS,
|
||||
)
|
||||
|
||||
# Load PPO model
|
||||
model_path = None
|
||||
for rc, mn in RE_CASES_TRAIN:
|
||||
if rc == re_code:
|
||||
model_path = os.path.join(MODEL_DIR, "old", f"{mn}.zip")
|
||||
break
|
||||
if model_path is None or not os.path.isfile(model_path):
|
||||
return {"re_code": re_code, "error": f"No model for Re{re_code}"}
|
||||
|
||||
model = load_ppo_model(model_path, device=f"cuda:{ppo_device}")
|
||||
model.set_random_seed(0)
|
||||
|
||||
# Collect rollout data with PPO
|
||||
ff.restore_ddf()
|
||||
ff.apply_ddf()
|
||||
|
||||
# Bias FIFO
|
||||
bias_action = scale_action(
|
||||
np.zeros(3, dtype=np.float32),
|
||||
scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0, n_total_bodies=7,
|
||||
)
|
||||
from collections import deque
|
||||
fifo = deque(maxlen=FIFO_LEN)
|
||||
for _ in range(FIFO_LEN):
|
||||
ff.context.push()
|
||||
try:
|
||||
ff.run(SAMPLE_INTERVAL, bias_action)
|
||||
finally:
|
||||
ff.context.pop()
|
||||
fifo.append(ff.obs.copy()[2:14])
|
||||
|
||||
n_steps = 150
|
||||
obs_hist = np.zeros((n_steps, 12), dtype=np.float64)
|
||||
alpha_hist = np.zeros((n_steps, 3), dtype=np.float64)
|
||||
obs = np.zeros(S_DIM, dtype=np.float32)
|
||||
|
||||
for step in range(n_steps):
|
||||
action, _ = model.predict(obs, deterministic=True)
|
||||
action = action.astype(np.float32).flatten()
|
||||
|
||||
# Convert to physical
|
||||
action_arr = scale_action(
|
||||
action, scale=ACTION_SCALE, bias=ACTION_BIAS,
|
||||
u0=U0, n_total_bodies=7,
|
||||
)
|
||||
ff.context.push()
|
||||
try:
|
||||
ff.run(SAMPLE_INTERVAL, action_arr)
|
||||
finally:
|
||||
ff.context.pop()
|
||||
|
||||
obs_slice = ff.obs.copy()[2:14]
|
||||
fifo.append(obs_slice)
|
||||
alpha = action_to_physical(
|
||||
action.reshape(1, 3), scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0,
|
||||
).flatten()
|
||||
|
||||
obs_hist[step] = obs_slice
|
||||
alpha_hist[step] = alpha
|
||||
obs = build_observation(obs_slice, norm)
|
||||
|
||||
del ff
|
||||
|
||||
# ---- Equivariance diagnosis ----
|
||||
dim = compute_dimensionless(obs_hist[:, 0:6], obs_hist[:, 6:12], u0=U0, d=20.0)
|
||||
|
||||
# Compute memory terms
|
||||
a_prev = np.zeros_like(alpha_hist)
|
||||
a_prev2 = np.zeros_like(alpha_hist)
|
||||
a_prev[1:] = alpha_hist[:-1]
|
||||
a_prev2[2:] = alpha_hist[:-2]
|
||||
|
||||
# Diagnostic 1: front bias check (mean of alpha_F)
|
||||
mean_alpha_F = float(np.mean(alpha_hist[:, 0]))
|
||||
std_alpha_F = float(np.std(alpha_hist[:, 0]))
|
||||
front_bias_score = abs(mean_alpha_F) / (std_alpha_F + 1e-12)
|
||||
|
||||
# Diagnostic 2: check front equivariance
|
||||
# For each point, compute PPO(Gx) by feeding G-transformed obs through model
|
||||
eq_front_errors = []
|
||||
eq_exchange_b_errors = []
|
||||
eq_exchange_t_errors = []
|
||||
eq_front_noise_floor = []
|
||||
|
||||
for t in range(2, n_steps):
|
||||
# Get original obs and Gx
|
||||
Gx = apply_G_x(
|
||||
dim["u_hat_B"][t:t+1], dim["u_hat_C"][t:t+1], dim["u_hat_T"][t:t+1],
|
||||
dim["v_hat_B"][t:t+1], dim["v_hat_C"][t:t+1], dim["v_hat_T"][t:t+1],
|
||||
dim["Cd_F"][t:t+1], dim["Cd_T"][t:t+1], dim["Cd_B"][t:t+1],
|
||||
dim["Cl_F"][t:t+1], dim["Cl_T"][t:t+1], dim["Cl_B"][t:t+1],
|
||||
a_prev[t:t+1, 0], a_prev[t:t+1, 2], a_prev[t:t+1, 1],
|
||||
a_prev2[t:t+1, 0] - a_prev[t:t+1, 0],
|
||||
a_prev2[t:t+1, 2] - a_prev[t:t+1, 2],
|
||||
a_prev2[t:t+1, 1] - a_prev[t:t+1, 1],
|
||||
)
|
||||
|
||||
# Build Gx observation for PPO: we need the normalized obs
|
||||
# The Gx in raw sensor/force space requires inverting the dimensionless transform
|
||||
# Actually easier: compute what PPO would predict for the G state
|
||||
# by transforming the raw obs and feeding it
|
||||
|
||||
# Build raw obs corresponding to Gx
|
||||
raw_Gx = np.zeros(12, dtype=np.float64)
|
||||
# Sensors: reorder + sign flip
|
||||
# Original raw: [s0_ux, s0_uy, s1_ux, s1_uy, s2_ux, s2_uy] = top, center, bottom
|
||||
# G: bottom->top, center->center, top->bottom
|
||||
raw_Gx[0] = obs_hist[t, 4] # s0_ux <- s2_ux (bottom -> top, streamwise no sign)
|
||||
raw_Gx[1] = -obs_hist[t, 5] # s0_uy <- -s2_uy (bottom -> top, cross sign flip)
|
||||
raw_Gx[2] = obs_hist[t, 2] # s1_ux maintains (center)
|
||||
raw_Gx[3] = -obs_hist[t, 3] # s1_uy = -s1_uy (center cross sign flip)
|
||||
raw_Gx[4] = obs_hist[t, 0] # s2_ux <- s0_ux (top -> bottom)
|
||||
raw_Gx[5] = -obs_hist[t, 1] # s2_uy <- -s0_uy (top -> bottom, cross sign flip)
|
||||
# Forces: reorder + sign
|
||||
# ordering: [front_fx, front_fy, bottom_fx, bottom_fy, top_fx, top_fy]
|
||||
# G: front_fx -> front_fx (no sign), front_fy -> -front_fy
|
||||
# bottom <-> top
|
||||
raw_Gx[6] = obs_hist[t, 6] # front_fx unchanged
|
||||
raw_Gx[7] = -obs_hist[t, 7] # front_fy sign flip
|
||||
raw_Gx[8] = obs_hist[t, 10] # bottom_fx <- top_fx
|
||||
raw_Gx[9] = -obs_hist[t, 11] # bottom_fy <- -top_fy
|
||||
raw_Gx[10] = obs_hist[t, 8] # top_fx <- bottom_fx
|
||||
raw_Gx[11] = -obs_hist[t, 9] # top_fy <- -bottom_fy
|
||||
|
||||
# Build normalized PPO observation from Gx
|
||||
obs_Gx = build_observation(raw_Gx, norm)
|
||||
|
||||
# Predict action for Gx
|
||||
action_Gx, _ = model.predict(obs_Gx, deterministic=True)
|
||||
action_Gx = action_Gx.astype(np.float32).flatten()
|
||||
alpha_Gx = action_to_physical(
|
||||
action_Gx.reshape(1, 3), scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0,
|
||||
).flatten()
|
||||
|
||||
# What equivariance says Gx should produce (with CORRECTED G)
|
||||
# G([aF, aT, aB]) = [-aF, -aB, -aT]
|
||||
alpha_Gx_expected = apply_G_alpha(alpha_hist[t])
|
||||
|
||||
# Front error: PPO(Gx)[0] should == G(PPO(x))[0] = -aF(x)
|
||||
eq_front_errors.append(abs(float(alpha_Gx[0]) - float(alpha_Gx_expected[0])))
|
||||
|
||||
# Rear error (CORRECTED): PPO(Gx)[1] should == G(PPO(x))[1] = -aT(x)
|
||||
# PPO(Gx)[2] should == G(PPO(x))[2] = -aB(x)
|
||||
# Previously this incorrectly checked alpha_B(x) == alpha_T(Gx)
|
||||
eq_exchange_b_errors.append(abs(float(alpha_Gx[1]) - float(alpha_Gx_expected[1])))
|
||||
eq_exchange_t_errors.append(abs(float(alpha_Gx[2]) - float(alpha_Gx_expected[2])))
|
||||
|
||||
# Noise floor: difference between same-state replicate predictions
|
||||
# (we approximate by checking prediction consistency)
|
||||
action2, _ = model.predict(obs_Gx, deterministic=True)
|
||||
alpha_Gx2 = action_to_physical(
|
||||
action2.reshape(1, 3), scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0,
|
||||
).flatten()
|
||||
eq_front_noise_floor.append(abs(float(alpha_Gx2[0]) - float(alpha_Gx[0])))
|
||||
|
||||
eq_front_errors = np.array(eq_front_errors)
|
||||
eq_exchange_b = np.array(eq_exchange_b_errors)
|
||||
eq_exchange_t = np.array(eq_exchange_t_errors)
|
||||
eq_noise = np.array(eq_front_noise_floor)
|
||||
|
||||
# Scale equivariance errors by action range for relative measure
|
||||
alpha_range = float(np.max(np.abs(alpha_hist[2:])))
|
||||
rel_front_err = float(np.mean(eq_front_errors) / (alpha_range + 1e-12))
|
||||
rel_exchange_b_err = float(np.mean(eq_exchange_b) / (alpha_range + 1e-12))
|
||||
rel_exchange_t_err = float(np.mean(eq_exchange_t) / (alpha_range + 1e-12))
|
||||
|
||||
# Combined rear error (max of bottom and top)
|
||||
rel_exchange_err = max(rel_exchange_b_err, rel_exchange_t_err)
|
||||
|
||||
# Diagnostic 3: cross-correlation between alpha_T and -alpha_B
|
||||
if len(alpha_hist) > 10:
|
||||
# After initial transient
|
||||
tail = n_steps // 2
|
||||
corr_TB = float(np.corrcoef(alpha_hist[tail:, 2], -alpha_hist[tail:, 1])[0, 1])
|
||||
else:
|
||||
corr_TB = float("nan")
|
||||
|
||||
result = {
|
||||
"re_code": re_code,
|
||||
"mu": mu,
|
||||
"n_samples": n_steps,
|
||||
"alpha_range": alpha_range,
|
||||
"front_bias": {
|
||||
"mean_alpha_F": mean_alpha_F,
|
||||
"std_alpha_F": std_alpha_F,
|
||||
"bias_over_std": front_bias_score,
|
||||
"bias_significant": front_bias_score > 2.0,
|
||||
},
|
||||
"equivariance_front": {
|
||||
"mean_abs_error": float(np.mean(eq_front_errors)),
|
||||
"max_abs_error": float(np.max(eq_front_errors)),
|
||||
"relative_error": rel_front_err,
|
||||
"noise_floor": float(np.mean(eq_noise)),
|
||||
"signal_to_noise": float(np.mean(eq_front_errors) / (np.mean(eq_noise) + 1e-12)),
|
||||
},
|
||||
"equivariance_rear_bottom": {
|
||||
"mean_abs_error": float(np.mean(eq_exchange_b)),
|
||||
"max_abs_error": float(np.max(eq_exchange_b)),
|
||||
"relative_error": rel_exchange_b_err,
|
||||
},
|
||||
"equivariance_rear_top": {
|
||||
"mean_abs_error": float(np.mean(eq_exchange_t)),
|
||||
"max_abs_error": float(np.max(eq_exchange_t)),
|
||||
"relative_error": rel_exchange_t_err,
|
||||
},
|
||||
"top_bottom_correlation": {
|
||||
"corr_alphaT_vs_negAlphaB": corr_TB,
|
||||
},
|
||||
"equivariance_verdict": "PASS" if (rel_front_err < 0.20 and rel_exchange_err < 0.20) else "REVIEW",
|
||||
}
|
||||
|
||||
print(f" Front bias: mean_alpha_F={mean_alpha_F:.6f} |bias|/std={front_bias_score:.3f}")
|
||||
print(f" Front equiv err: mean={np.mean(eq_front_errors):.6f} rel={rel_front_err:.3%}")
|
||||
print(f" Rear-bot err: mean={np.mean(eq_exchange_b):.6f} rel={rel_exchange_b_err:.3%}")
|
||||
print(f" Rear-top err: mean={np.mean(eq_exchange_t):.6f} rel={rel_exchange_t_err:.3%}")
|
||||
print(f" T vs -B corr: {corr_TB:.4f}")
|
||||
print(f" Verdict: {result['equivariance_verdict']}")
|
||||
|
||||
with open(os.path.join(output_root, f"equivariance_re{re_code}.json"), "w") as f:
|
||||
json.dump(result, f, indent=2)
|
||||
print(f" Saved to {output_root}/equivariance_re{re_code}.json")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Equivariance diagnosis for PPO cloak control")
|
||||
ap.add_argument("--re", type=str, default="all",
|
||||
help='Re case: 50,100,200,400, or "all"')
|
||||
ap.add_argument("--device", type=int, default=0, help="GPU device for PPO model")
|
||||
ap.add_argument("--cfd-device", type=int, default=2, help="GPU device for CFD simulation")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.re.lower() == "all":
|
||||
re_list = [rc for rc, _ in RE_CASES_TRAIN]
|
||||
else:
|
||||
re_list = [int(args.re)]
|
||||
|
||||
# Store device args for use in diagnose_one_re
|
||||
device_id = args.device
|
||||
cfd_device = args.cfd_device
|
||||
|
||||
diag_root = os.path.join(OUTPUT_DIR, "diagnostic")
|
||||
os.makedirs(diag_root, exist_ok=True)
|
||||
|
||||
all_results = []
|
||||
for re_code in re_list:
|
||||
res = diagnose_one_re(re_code, device_id, cfd_device, diag_root)
|
||||
all_results.append(res)
|
||||
|
||||
summary = {
|
||||
"summary": {
|
||||
"equivariance_verdicts": {r["re_code"]: r.get("equivariance_verdict", "ERROR")
|
||||
for r in all_results}
|
||||
},
|
||||
"details": all_results,
|
||||
}
|
||||
with open(os.path.join(diag_root, "equivariance_summary.json"), "w") as f:
|
||||
json.dump(summary, f, indent=2)
|
||||
print(f"\nSummary saved to {diag_root}/equivariance_summary.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,439 @@
|
||||
# analysis_crossre/scripts/phase2_ablation.py
|
||||
"""Ablation runner: v2 baseline -> v2.1 -> v2.2 -> v2.3 -> v2.4.
|
||||
|
||||
Each version differs by exactly one change from the previous.
|
||||
Run specific versions via --mode.
|
||||
|
||||
Usage::
|
||||
|
||||
conda run -n pycuda_3_10 python phase2_ablation.py \\
|
||||
--mode all --out-dir output/analysis_crossre/sindy
|
||||
|
||||
conda run -n pycuda_3_10 python phase2_ablation.py \\
|
||||
--mode v21 --out-dir output/analysis_crossre/sindy
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from utils import (
|
||||
action_to_physical,
|
||||
compute_dimensionless,
|
||||
compute_physical_symbols,
|
||||
fit_channel,
|
||||
print_control_law,
|
||||
)
|
||||
from cfg import (
|
||||
OUTPUT_DIR,
|
||||
RE_CASES_TRAIN,
|
||||
ACTION_SCALE,
|
||||
ACTION_BIAS,
|
||||
U0,
|
||||
)
|
||||
|
||||
THRESHOLDS = [0.0, 0.001, 0.002, 0.005, 0.01, 0.015, 0.02, 0.03, 0.05, 0.1]
|
||||
|
||||
|
||||
def load_case_data(re_code: int) -> Tuple:
|
||||
case_dir = os.path.join(OUTPUT_DIR, f"re{re_code}")
|
||||
npz_path = os.path.join(case_dir, "controlled.npz")
|
||||
if not os.path.isfile(npz_path):
|
||||
raise FileNotFoundError(f"Missing {npz_path}")
|
||||
data = np.load(npz_path)
|
||||
sensors = data["sensors"].astype(np.float64)
|
||||
forces = data["forces"].astype(np.float64)
|
||||
actions_norm = data["actions"].astype(np.float64)
|
||||
rewards = data.get("rewards", np.zeros(sensors.shape[0])).astype(np.float64)
|
||||
actions_phys = action_to_physical(
|
||||
actions_norm, scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0)
|
||||
mu = 2.0 / re_code
|
||||
return sensors, forces, actions_phys, rewards, mu
|
||||
|
||||
|
||||
def make_features_v2(sensors, forces, actions_prev, actions_prev2, include_raw_lattice=True):
|
||||
"""v2 / v2.1 features: raw lattice physical symbols."""
|
||||
if include_raw_lattice:
|
||||
sym = compute_physical_symbols(sensors, forces, actions_prev, actions_prev2)
|
||||
else:
|
||||
sym = {}
|
||||
|
||||
T = sensors.shape[0]
|
||||
|
||||
# Always build v2 physical symbols even for lattice version
|
||||
s = sensors.astype(np.float64)
|
||||
f = forces.astype(np.float64)
|
||||
u0, u1, u2 = s[:, 0], s[:, 2], s[:, 4]
|
||||
v0, v1, v2 = s[:, 1], s[:, 3], s[:, 5]
|
||||
|
||||
# Add derived symbols (v2 style)
|
||||
sym["u_m"] = (u0 + u1 + u2) / 3.0
|
||||
sym["u_a"] = (u2 - u0) / 2.0
|
||||
sym["u_c"] = u1.copy()
|
||||
sym["u_curv"] = u0 - 2.0 * u1 + u2
|
||||
sym["v_m"] = (v0 + v1 + v2) / 3.0
|
||||
sym["v_a"] = (v2 - v0) / 2.0
|
||||
sym["v_c"] = v1.copy()
|
||||
sym["v_curv"] = v0 - 2.0 * v1 + v2
|
||||
sym["sin_ua"] = np.sin(np.pi * sym["u_a"])
|
||||
sym["cos_ua"] = np.cos(np.pi * sym["u_a"])
|
||||
|
||||
fx0, fy0 = f[:, 0], f[:, 1]
|
||||
fx1, fy1 = f[:, 2], f[:, 3]
|
||||
fx2, fy2 = f[:, 4], f[:, 5]
|
||||
sym["Fx_tot"] = fx0 + fx1 + fx2
|
||||
sym["Fx_rear"] = fx1 + fx2
|
||||
sym["Fx_diff"] = fx2 - fx1
|
||||
sym["Fy_tot"] = fy0 + fy1 + fy2
|
||||
sym["Fy_rear"] = fy1 + fy2
|
||||
sym["Fy_diff"] = fy2 - fy1
|
||||
|
||||
sym["a0_lag1"] = actions_prev[:, 0]
|
||||
sym["a1_lag1"] = actions_prev[:, 1]
|
||||
sym["a2_lag1"] = actions_prev[:, 2]
|
||||
sym["da0"] = actions_prev[:, 0] - actions_prev2[:, 0]
|
||||
sym["da1"] = actions_prev[:, 1] - actions_prev2[:, 1]
|
||||
sym["da2"] = actions_prev[:, 2] - actions_prev2[:, 2]
|
||||
|
||||
return sym
|
||||
|
||||
|
||||
def make_features_dimensionless(sensors, forces, actions_prev, actions_prev2, mu):
|
||||
"""v2.2 features: fully dimensionless."""
|
||||
dim = compute_dimensionless(sensors, forces, u0=U0, d=20.0)
|
||||
T = actions_prev.shape[0]
|
||||
|
||||
# Nondim actions: alpha = omega_phys / U0
|
||||
T = actions_prev.shape[0]
|
||||
a_prev = np.zeros((T, 3), dtype=np.float64)
|
||||
a_prev2 = np.zeros((T, 3), dtype=np.float64)
|
||||
a_prev[1:] = actions_prev[1:] / U0
|
||||
a_prev2[2:] = actions_prev2[2:] / U0
|
||||
da = a_prev - a_prev2
|
||||
|
||||
# Sensor (nondim)
|
||||
u_B, u_C, u_T = dim["u_hat_B"], dim["u_hat_C"], dim["u_hat_T"]
|
||||
v_B, v_C, v_T = dim["v_hat_B"], dim["v_hat_C"], dim["v_hat_T"]
|
||||
|
||||
sym = {}
|
||||
sym["u_m"] = (u_B + u_C + u_T) / 3.0
|
||||
sym["u_a"] = (u_T - u_B) / 2.0
|
||||
sym["u_c"] = u_C.copy()
|
||||
sym["u_curv"] = u_B - 2.0 * u_C + u_T
|
||||
sym["v_m"] = (v_B + v_C + v_T) / 3.0
|
||||
sym["v_a"] = (v_T - v_B) / 2.0
|
||||
sym["v_c"] = v_C.copy()
|
||||
sym["v_curv"] = v_B - 2.0 * v_C + v_T
|
||||
sym["sin_ua"] = np.sin(np.pi * sym["u_a"])
|
||||
sym["cos_ua"] = np.cos(np.pi * sym["u_a"])
|
||||
|
||||
# Force (nondim Cd/Cl)
|
||||
sym["Cd_tot"] = dim["Cd_F"] + dim["Cd_T"] + dim["Cd_B"]
|
||||
sym["Cd_rear"] = dim["Cd_T"] + dim["Cd_B"]
|
||||
sym["Cd_diff"] = dim["Cd_T"] - dim["Cd_B"]
|
||||
sym["Cl_tot"] = dim["Cl_F"] + dim["Cl_T"] + dim["Cl_B"]
|
||||
sym["Cl_rear"] = dim["Cl_T"] + dim["Cl_B"]
|
||||
sym["Cl_diff"] = dim["Cl_T"] - dim["Cl_B"]
|
||||
|
||||
# Memory (nondim alpha)
|
||||
sym["a0_lag1"] = a_prev[:, 0] # front
|
||||
sym["a1_lag1"] = a_prev[:, 1] # bottom
|
||||
sym["a2_lag1"] = a_prev[:, 2] # top
|
||||
sym["da0"] = da[:, 0]
|
||||
sym["da1"] = da[:, 1]
|
||||
sym["da2"] = da[:, 2]
|
||||
|
||||
# Mu modulation
|
||||
sym["mu"] = np.full(T, mu, dtype=np.float64)
|
||||
sym["mu_u_a"] = sym["u_a"] * mu
|
||||
sym["mu_v_a"] = sym["v_a"] * mu
|
||||
sym["mu_Cd_tot"] = sym["Cd_tot"] * mu
|
||||
sym["mu_Cl_diff"] = sym["Cl_diff"] * mu
|
||||
|
||||
return sym
|
||||
|
||||
|
||||
def build_theta(sym, feature_keys, add_bias=True):
|
||||
"""Build feature matrix from symbol dict."""
|
||||
T = sym[feature_keys[0]].shape[0]
|
||||
cols = []
|
||||
if add_bias:
|
||||
cols.append(np.ones(T, dtype=np.float64))
|
||||
for k in feature_keys:
|
||||
cols.append(sym[k])
|
||||
return np.column_stack(cols)
|
||||
|
||||
|
||||
# Feature set definitions
|
||||
V2_BASE_KEYS = [
|
||||
"u_m", "u_a", "u_c", "u_curv", "v_a", "v_curv",
|
||||
"Fx_tot", "Fx_rear", "Fx_diff", "Fy_tot", "Fy_diff",
|
||||
"sin_ua", "cos_ua",
|
||||
"a0_lag1", "a1_lag1", "a2_lag1",
|
||||
"da0", "da1", "da2",
|
||||
]
|
||||
|
||||
V2_WITH_MU = V2_BASE_KEYS + [
|
||||
"mu", "mu_u_a", "mu_v_a", "mu_Cd_tot", "mu_Cl_diff",
|
||||
]
|
||||
|
||||
V2DIM_KEYS = [
|
||||
"u_m", "u_a", "u_c", "u_curv", "v_a", "v_curv",
|
||||
"Cd_tot", "Cd_rear", "Cd_diff", "Cl_tot", "Cl_diff",
|
||||
"sin_ua", "cos_ua",
|
||||
"a0_lag1", "a1_lag1", "a2_lag1",
|
||||
"da0", "da1", "da2",
|
||||
"mu", "mu_u_a", "mu_v_a", "mu_Cd_tot", "mu_Cl_diff",
|
||||
]
|
||||
|
||||
|
||||
def build_dataset(re_codes, mode, use_mu=True, use_mu_nondim=True):
|
||||
"""Build dataset for given mode.
|
||||
|
||||
Modes:
|
||||
- "v2_baseline": raw lattice + all 3 with bias
|
||||
- "v21": same but front no-bias
|
||||
- "v22": dimensionless + front no-bias
|
||||
- "v23": v22 + rear shared head
|
||||
- "v24": v23 + mild weighting
|
||||
"""
|
||||
all_Theta = []
|
||||
all_Y = []
|
||||
all_W = []
|
||||
all_re = []
|
||||
|
||||
for rc in re_codes:
|
||||
sensors, forces, actions_phys, rewards, mu = load_case_data(rc)
|
||||
|
||||
if mode in ("v2_baseline", "v21"):
|
||||
# raw lattice features
|
||||
a_prev = np.zeros_like(actions_phys)
|
||||
a_prev2 = np.zeros_like(actions_phys)
|
||||
a_prev[1:] = actions_phys[:-1]
|
||||
a_prev2[2:] = actions_phys[:-2]
|
||||
sym = make_features_v2(sensors, forces, a_prev, a_prev2)
|
||||
feature_keys = V2_WITH_MU if use_mu else V2_BASE_KEYS
|
||||
sym["mu"] = np.full(sensors.shape[0], mu, dtype=np.float64)
|
||||
sym["mu_u_a"] = sym["u_a"] * mu
|
||||
sym["mu_v_a"] = sym["v_a"] * mu
|
||||
if use_mu:
|
||||
# Add mu modulated forces
|
||||
sym["mu_Cd_tot"] = sym["Fx_tot"] * mu
|
||||
sym["mu_Cl_diff"] = sym["Fy_diff"] * mu
|
||||
Y = actions_phys.copy()
|
||||
else:
|
||||
# dimensionless features
|
||||
a_prev_d = np.zeros_like(actions_phys)
|
||||
a_prev2_d = np.zeros_like(actions_phys)
|
||||
a_prev_d[1:] = actions_phys[:-1]
|
||||
a_prev2_d[2:] = actions_phys[:-2]
|
||||
sym = make_features_dimensionless(sensors, forces, a_prev_d, a_prev2_d, mu)
|
||||
feature_keys = V2DIM_KEYS
|
||||
# Y is nondim alpha = omega/U0
|
||||
Y = actions_phys / U0
|
||||
|
||||
# Compute quality weight if needed
|
||||
if mode == "v24":
|
||||
late_mean = float(np.mean(rewards[-80:]))
|
||||
weight = np.clip(0.3 + 0.7 * late_mean / 0.7, 0.2, 1.0)
|
||||
W = np.full(sensors.shape[0], weight, dtype=np.float64)
|
||||
else:
|
||||
W = np.ones(sensors.shape[0], dtype=np.float64)
|
||||
|
||||
# Store for stacking (will trim warmup later)
|
||||
all_Theta.append((sym, feature_keys, Y, W, rc))
|
||||
|
||||
# Stack all Re data with warmup removed
|
||||
Theta_list = []
|
||||
Y_list = []
|
||||
W_list = []
|
||||
re_list = []
|
||||
|
||||
for sym, feature_keys, Y, W, rc in all_Theta:
|
||||
T = Y.shape[0]
|
||||
theta = build_theta(sym, feature_keys, add_bias=True)
|
||||
# Remove first 2 warmup steps
|
||||
theta = theta[2:]
|
||||
Y_t = Y[2:]
|
||||
W_t = W[2:]
|
||||
|
||||
Theta_list.append(theta)
|
||||
Y_list.append(Y_t)
|
||||
W_list.append(W_t)
|
||||
re_list.append(np.full(theta.shape[0], rc, dtype=np.int64))
|
||||
|
||||
Theta_stacked = np.vstack(Theta_list)
|
||||
Y_stacked = np.vstack(Y_list)
|
||||
W_stacked = np.concatenate(W_list)
|
||||
Re_stacked = np.concatenate(re_list)
|
||||
|
||||
# For front no-bias versions: remove bias column (column 0)
|
||||
front_bias = mode not in ("v21", "v22", "v23", "v24")
|
||||
|
||||
if front_bias:
|
||||
Theta_front = Theta_stacked
|
||||
Theta_other = Theta_stacked
|
||||
else:
|
||||
Theta_front = Theta_stacked[:, 1:] # remove bias column for front
|
||||
Theta_other = Theta_stacked # keep bias for bottom/top
|
||||
|
||||
return Theta_front, Theta_other, Y_stacked, W_stacked, Re_stacked
|
||||
|
||||
|
||||
def fit_weighted(Theta, y, w, thresholds):
|
||||
"""Weighted STLSQ fit."""
|
||||
import pysindy as ps
|
||||
std = np.sqrt(np.average((Theta - np.average(Theta, axis=0, weights=w))**2,
|
||||
axis=0, weights=w))
|
||||
std = np.where(std < 1e-8, 1.0, std)
|
||||
Theta_s = Theta / std
|
||||
best = None
|
||||
rows = []
|
||||
for th in thresholds:
|
||||
opt = ps.STLSQ(threshold=th, alpha=1e-4, max_iter=25)
|
||||
opt.fit(Theta_s, y, sample_weight=w)
|
||||
coef = np.asarray(opt.coef_, dtype=np.float64).flatten() / std
|
||||
y_pred = Theta @ coef
|
||||
y_mean = np.average(y, weights=w)
|
||||
ssr = np.sum(w * (y - y_pred)**2)
|
||||
sst = np.sum(w * (y - y_mean)**2) + 1e-12
|
||||
r2 = 1.0 - ssr / sst
|
||||
mae = float(np.average(np.abs(y - y_pred), weights=w))
|
||||
nz = int(np.sum(np.abs(coef) > 1e-8))
|
||||
entry = {"threshold": float(th), "nz": nz, "r2": r2, "mae": mae, "coef": coef}
|
||||
rows.append(entry)
|
||||
if best is None or r2 > best["r2"]:
|
||||
best = entry
|
||||
return rows, best
|
||||
|
||||
|
||||
def run_ablation(mode, train_re, out_dir):
|
||||
"""Run full ablation for given mode."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Mode: {mode}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
ThetaF, ThetaO, Y, W, Re = build_dataset(train_re, mode)
|
||||
|
||||
# Determine which cylinders use which feature matrix
|
||||
if mode == "v23":
|
||||
# rear shared-head: only fit front and top. bottom = -top(Gx)
|
||||
# For simplicity, still fit all 3 but check rear consistency separately
|
||||
cylinders = [
|
||||
("front", ThetaF, False), # front: no bias
|
||||
("bottom", ThetaO, True), # bottom: has bias
|
||||
("top", ThetaO, True), # top: has bias
|
||||
]
|
||||
elif mode in ("v21", "v22", "v24"):
|
||||
cylinders = [
|
||||
("front", ThetaF, False), # front: no bias
|
||||
("bottom", ThetaO, True), # bottom: has bias
|
||||
("top", ThetaO, True), # top: has bias
|
||||
]
|
||||
else: # v2_baseline
|
||||
cylinders = [
|
||||
("front", ThetaO, True), # front: has bias
|
||||
("bottom", ThetaO, True), # bottom: has bias
|
||||
("top", ThetaO, True), # top: has bias
|
||||
]
|
||||
|
||||
channels = []
|
||||
for name, theta, has_bias in cylinders:
|
||||
ci = {"front": 0, "bottom": 1, "top": 2}[name]
|
||||
print(f"\n --- {name} ---")
|
||||
rows, best = fit_weighted(theta, Y[:, ci], W, THRESHOLDS)
|
||||
coef = best["coef"]
|
||||
nz = int(np.sum(np.abs(coef) > 1e-8))
|
||||
print(f" {name}: R2={best['r2']:.6f} MAE={best['mae']:.6f} nz={nz}")
|
||||
# Get feature names for this mode
|
||||
if mode in ("v2_baseline", "v21"):
|
||||
feat_names = [
|
||||
"bias", "u_m", "u_a", "u_c", "u_curv", "v_a", "v_curv",
|
||||
"Fx_tot", "Fx_rear", "Fx_diff", "Fy_tot", "Fy_diff",
|
||||
"sin_ua", "cos_ua",
|
||||
"a0_lag1", "a1_lag1", "a2_lag1",
|
||||
"da0", "da1", "da2",
|
||||
"mu", "mu_u_a", "mu_v_a", "mu_Cd_tot", "mu_Cl_diff",
|
||||
]
|
||||
has_mu = True
|
||||
else:
|
||||
feat_names = [
|
||||
"bias", "u_m", "u_a", "u_c", "u_curv", "v_a", "v_curv",
|
||||
"Cd_tot", "Cd_rear", "Cd_diff", "Cl_tot", "Cl_diff",
|
||||
"sin_ua", "cos_ua",
|
||||
"a0_lag1", "a1_lag1", "a2_lag1",
|
||||
"da0", "da1", "da2",
|
||||
"mu", "mu_u_a", "mu_v_a", "mu_Cd_tot", "mu_Cl_diff",
|
||||
]
|
||||
has_mu = True
|
||||
|
||||
# Trim feat_names to match actual theta dimensions
|
||||
actual_nf = theta.shape[1]
|
||||
if len(feat_names) != actual_nf:
|
||||
# Feature names don't include mu if not included, etc.
|
||||
# Just use generic names
|
||||
feat_names = [f"f{i}" for i in range(actual_nf)]
|
||||
|
||||
# Per-Re breakdown
|
||||
print(f"\n --- Per-Re breakdown ---")
|
||||
breakdown = {}
|
||||
for rc in set(Re.tolist()):
|
||||
mask = Re == rc
|
||||
ch_b = []
|
||||
for name, theta, has_bias in cylinders:
|
||||
ci = {"front": 0, "bottom": 1, "top": 2}[name]
|
||||
th_r = theta[mask]
|
||||
yr = Y[mask, ci]
|
||||
wr = W[mask]
|
||||
coef = np.array([ch["best_coef"][ci] for ch in channels], dtype=np.float64).flatten()
|
||||
# Actually get the right coefficient for this cylinder
|
||||
coef_c = np.array(channels[ci]["best_coef"], dtype=np.float64)
|
||||
y_pred = th_r @ coef_c
|
||||
y_mean = np.average(yr, weights=wr)
|
||||
ssr = np.sum(wr * (yr - y_pred)**2)
|
||||
sst = np.sum(wr * (yr - y_mean)**2) + 1e-12
|
||||
r2 = 1.0 - ssr / sst
|
||||
mae = float(np.average(np.abs(yr - y_pred), weights=wr))
|
||||
ch_b.append({"cylinder": name, "r2": float(r2), "mae": mae})
|
||||
breakdown[f"re{int(rc)}"] = ch_b
|
||||
r2s = ", ".join([f"{b['cylinder']}={b['r2']:.4f}" for b in ch_b])
|
||||
print(f" Re{int(rc)}: {r2s}")
|
||||
|
||||
return {
|
||||
"mode": mode,
|
||||
"train_re": train_re,
|
||||
"channels": channels,
|
||||
"per_re_breakdown": breakdown,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Ablation runner v2->v2.4")
|
||||
ap.add_argument("--mode", type=str, default="all",
|
||||
choices=["v2_baseline", "v21", "v22", "v23", "v24", "all"])
|
||||
ap.add_argument("--out-dir", type=str, default=os.path.join(OUTPUT_DIR, "sindy"))
|
||||
ap.add_argument("--train-re", type=str, default="50,100,200")
|
||||
args = ap.parse_args()
|
||||
|
||||
train_re = [int(r) for r in args.train_re.split(",")]
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
|
||||
modes = ["v2_baseline", "v21", "v22", "v23", "v24"] if args.mode == "all" else [args.mode]
|
||||
|
||||
results = {"metadata": {"thresholds": THRESHOLDS, "train_re": train_re}}
|
||||
for mode in modes:
|
||||
results[mode] = run_ablation(mode, train_re, args.out_dir)
|
||||
|
||||
out_path = os.path.join(args.out_dir, "ablation_results.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"\nSaved: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,320 @@
|
||||
# analysis_crossre/scripts/phase2_control_fit.py
|
||||
"""Phase 2 v3: dimensionless + front-no-bias + quality-weighted SINDy fitting.
|
||||
|
||||
Usage::
|
||||
|
||||
conda run -n pycuda_3_10 python phase2_control_fit.py \\
|
||||
--cross-re --out-dir output/analysis_crossre/sindy
|
||||
|
||||
conda run -n pycuda_3_10 python phase2_control_fit.py \\
|
||||
--leave-one-out --out-dir output/analysis_crossre/sindy
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from utils import (
|
||||
action_to_physical,
|
||||
compute_dimensionless,
|
||||
compute_v3_symbols,
|
||||
fit_channel,
|
||||
print_control_law,
|
||||
)
|
||||
from cfg import (
|
||||
OUTPUT_DIR,
|
||||
RE_CASES_TRAIN,
|
||||
ACTION_SCALE,
|
||||
ACTION_BIAS,
|
||||
U0,
|
||||
)
|
||||
|
||||
THRESHOLDS = [0.0, 0.001, 0.002, 0.005, 0.01, 0.015, 0.02, 0.03, 0.05, 0.1]
|
||||
|
||||
|
||||
def load_case_data(re_code: int) -> Tuple:
|
||||
"""Load controlled NPZ for a single Re.
|
||||
|
||||
Returns (sensors, forces, actions_phys, rewards, mu).
|
||||
"""
|
||||
case_dir = os.path.join(OUTPUT_DIR, f"re{re_code}")
|
||||
npz_path = os.path.join(case_dir, "controlled.npz")
|
||||
if not os.path.isfile(npz_path):
|
||||
raise FileNotFoundError(f"Missing {npz_path}")
|
||||
|
||||
data = np.load(npz_path)
|
||||
sensors = data["sensors"].astype(np.float64)
|
||||
forces = data["forces"].astype(np.float64)
|
||||
actions_norm = data["actions"].astype(np.float64)
|
||||
rewards = data.get("rewards", np.zeros(sensors.shape[0])).astype(np.float64)
|
||||
|
||||
actions_phys = action_to_physical(
|
||||
actions_norm, scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0)
|
||||
mu = 2.0 / re_code # 1 / Re_D
|
||||
return sensors, forces, actions_phys, rewards, mu
|
||||
|
||||
|
||||
def compute_trajectory_weights(rewards: np.ndarray, late_window: int = 80) -> float:
|
||||
"""Compute a single quality weight for this trajectory.
|
||||
|
||||
Uses the mean reward over the last ``late_window`` steps.
|
||||
Maps to weight via quantile-based scheme.
|
||||
"""
|
||||
n = len(rewards)
|
||||
if n < late_window:
|
||||
late_mean = float(np.mean(rewards))
|
||||
else:
|
||||
late_mean = float(np.mean(rewards[-late_window:]))
|
||||
|
||||
# Map reward to weight via sigmoid-like scheme:
|
||||
# reward 0.0 -> weight 0.1, reward 0.3 -> 0.3, reward 0.5 -> 0.6, reward 0.7 -> 0.9
|
||||
weight = 1.0 / (1.0 + np.exp(-8.0 * (late_mean - 0.4)))
|
||||
return float(np.clip(weight, 0.05, 1.0))
|
||||
|
||||
|
||||
def build_dataset_v3(
|
||||
re_code: int,
|
||||
include_mu: bool = True,
|
||||
) -> Tuple:
|
||||
"""Build v3 data: dimensionless features, front-no-bias, quality-weighted.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Theta_front : (T, nf_f) for front model (no bias)
|
||||
Theta_other : (T, nf_o) for top/bottom model (with bias)
|
||||
Y : (T, 3) physical omegas
|
||||
W : (T,) quality weight per sample
|
||||
names : feature names (without "bias")
|
||||
"""
|
||||
sensors, forces, actions_phys, rewards, mu = load_case_data(re_code)
|
||||
|
||||
# Dimensionless
|
||||
dim = compute_dimensionless(sensors, forces, u0=U0, d=20.0)
|
||||
|
||||
# Memory terms
|
||||
a_prev = np.zeros_like(actions_phys)
|
||||
a_prev2 = np.zeros_like(actions_phys)
|
||||
a_prev[1:] = actions_phys[:-1]
|
||||
a_prev2[2:] = actions_phys[:-2]
|
||||
|
||||
# Build v3 features
|
||||
Theta_f, Theta_top, names = compute_v3_symbols(
|
||||
dim, a_prev, a_prev2, mu=mu, include_mu=include_mu)
|
||||
|
||||
# Quality weight per sample: inherit trajectory weight
|
||||
traj_weight = compute_trajectory_weights(rewards)
|
||||
W = np.full(Theta_f.shape[0], traj_weight, dtype=np.float64)
|
||||
|
||||
# Remove warmup (need 2 steps of memory)
|
||||
Theta_f = Theta_f[2:]
|
||||
Theta_top = Theta_top[2:]
|
||||
Y = actions_phys[2:]
|
||||
W = W[2:]
|
||||
|
||||
print(f" Re{re_code}: {Theta_f.shape[0]} samples, {Theta_f.shape[1]} feats, "
|
||||
f"mu={mu:.6f}, traj_weight={traj_weight:.4f}")
|
||||
return Theta_f, Theta_top, Y, W, names, mu
|
||||
|
||||
|
||||
def fit_channel_weighted(
|
||||
Theta: np.ndarray,
|
||||
y: np.ndarray,
|
||||
w: np.ndarray,
|
||||
thresholds: list,
|
||||
alpha: float = 1e-4,
|
||||
max_iter: int = 25,
|
||||
) -> tuple:
|
||||
"""Weighted STLSQ fit."""
|
||||
import pysindy as ps
|
||||
|
||||
# Weighted normalisation
|
||||
std = np.sqrt(np.average((Theta - np.average(Theta, axis=0, weights=w)) ** 2,
|
||||
axis=0, weights=w))
|
||||
std = np.where(std < 1e-8, 1.0, std)
|
||||
Theta_s = Theta / std
|
||||
|
||||
best = None
|
||||
rows = []
|
||||
for th in thresholds:
|
||||
opt = ps.STLSQ(threshold=th, alpha=alpha, max_iter=max_iter)
|
||||
opt.fit(Theta_s, y, sample_weight=w)
|
||||
coef = np.asarray(opt.coef_, dtype=np.float64).flatten() / std
|
||||
y_pred = Theta @ coef
|
||||
# Weighted R2
|
||||
y_mean = np.average(y, weights=w)
|
||||
ssr = np.sum(w * (y - y_pred) ** 2)
|
||||
sst = np.sum(w * (y - y_mean) ** 2) + 1e-12
|
||||
r2 = 1.0 - ssr / sst
|
||||
mae = float(np.average(np.abs(y - y_pred), weights=w))
|
||||
nz = int(np.sum(np.abs(coef) > 1e-8))
|
||||
entry = {"threshold": float(th), "nz": nz, "r2": r2, "mae": mae, "coef": coef}
|
||||
rows.append(entry)
|
||||
if best is None or r2 > best["r2"]:
|
||||
best = entry
|
||||
return rows, best
|
||||
|
||||
|
||||
def build_cross_re_v3(
|
||||
train_re_codes: List[int],
|
||||
include_mu: bool = True,
|
||||
) -> Tuple:
|
||||
"""Stack multiple Re datasets with v3 features.
|
||||
|
||||
Front model uses Theta_front (no bias).
|
||||
Top/Bottom models use Theta_other (with bias).
|
||||
|
||||
Returns three stacked datasets.
|
||||
"""
|
||||
all_ThetaF, all_ThetaO, all_Y, all_W, all_re = [], [], [], [], []
|
||||
names = None
|
||||
|
||||
for rc in train_re_codes:
|
||||
tf, to, y, w, fn, mu = build_dataset_v3(rc, include_mu=include_mu)
|
||||
all_ThetaF.append(tf)
|
||||
all_ThetaO.append(to)
|
||||
all_Y.append(y)
|
||||
all_W.append(w)
|
||||
all_re.append(np.full(tf.shape[0], rc, dtype=np.int64))
|
||||
if names is None:
|
||||
names = fn
|
||||
|
||||
ThetaF = np.vstack(all_ThetaF)
|
||||
ThetaO = np.vstack(all_ThetaO)
|
||||
Y = np.vstack(all_Y)
|
||||
W = np.concatenate(all_W)
|
||||
Re = np.concatenate(all_re)
|
||||
|
||||
print(f"\n Cross-Re: {ThetaF.shape[0]} samples, "
|
||||
f"front={ThetaF.shape[1]} feats (no bias), "
|
||||
f"other={ThetaO.shape[1]} feats (w/ bias)")
|
||||
return ThetaF, ThetaO, Y, W, names, Re
|
||||
|
||||
|
||||
def run_cross_re_fit(
|
||||
train_re: List[int],
|
||||
tag: str = "",
|
||||
include_mu: bool = True,
|
||||
) -> dict:
|
||||
"""Fit all 3 cylinders independently.
|
||||
|
||||
Front: no bias.
|
||||
Top/Bottom: with bias.
|
||||
"""
|
||||
ThetaF, ThetaO, Y, W, names, re_labels = build_cross_re_v3(train_re, include_mu)
|
||||
|
||||
cylinders = [
|
||||
{"name": "front", "theta": ThetaF, "label": "front (no bias)"},
|
||||
{"name": "bottom", "theta": ThetaO, "label": "bottom (w/ bias)"},
|
||||
{"name": "top", "theta": ThetaO, "label": "top (w/ bias)"},
|
||||
]
|
||||
|
||||
channels = []
|
||||
for ci, cyl in enumerate(cylinders):
|
||||
print(f"\n --- {tag} {cyl['label']} ---")
|
||||
rows, best = fit_channel_weighted(cyl["theta"], Y[:, ci], W, THRESHOLDS)
|
||||
coef = best["coef"]
|
||||
print_control_law(names, coef, channel_label=f"{cyl['name']}")
|
||||
print(f" R2={best['r2']:.6f} MAE={best['mae']:.6f}")
|
||||
channels.append({
|
||||
"cylinder": cyl["name"],
|
||||
"has_bias": cyl["name"] != "front",
|
||||
"n_features": cyl["theta"].shape[1],
|
||||
"best": {k: float(v) if isinstance(v, (np.floating, float)) else v
|
||||
for k, v in best.items() if k != "coef"},
|
||||
"best_coef": [float(c) for c in coef],
|
||||
"grid": [{k: float(v) for k, v in row.items() if k != "coef"}
|
||||
for row in rows],
|
||||
"feature_names": names,
|
||||
})
|
||||
|
||||
# Per-Re breakdown
|
||||
print(f"\n --- {tag} per-Re breakdown ---")
|
||||
breakdown = {}
|
||||
for re_code in set(re_labels.tolist()):
|
||||
mask = re_labels == re_code
|
||||
Yr, Wr = Y[mask], W[mask]
|
||||
ch_b = []
|
||||
for ci, cyl in enumerate(cylinders):
|
||||
th = cyl["theta"][mask]
|
||||
coef = np.array(channels[ci]["best_coef"], dtype=np.float64)
|
||||
y_pred = th @ coef
|
||||
y_t = Yr[:, ci]
|
||||
y_mean = np.average(y_t, weights=Wr)
|
||||
ssr = np.sum(Wr * (y_t - y_pred) ** 2)
|
||||
sst = np.sum(Wr * (y_t - y_mean) ** 2) + 1e-12
|
||||
r2 = 1.0 - ssr / sst
|
||||
mae = float(np.average(np.abs(y_t - y_pred), weights=Wr))
|
||||
ch_b.append({"cylinder": cyl["name"], "r2": float(r2), "mae": mae})
|
||||
breakdown[f"re{int(re_code)}"] = ch_b
|
||||
r2s = ", ".join([f"{b['cylinder']}={b['r2']:.4f}" for b in ch_b])
|
||||
print(f" Re{int(re_code)}: {r2s}")
|
||||
|
||||
return {
|
||||
"tag": tag,
|
||||
"train_re": train_re,
|
||||
"n_samples": int(ThetaF.shape[0]),
|
||||
"n_features_front": int(ThetaF.shape[1]),
|
||||
"n_features_other": int(ThetaO.shape[1]),
|
||||
"channels": channels,
|
||||
"per_re_breakdown": breakdown,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Phase 2 v3: dimensionless + constrained fitting")
|
||||
ap.add_argument("--cross-re", action="store_true")
|
||||
ap.add_argument("--leave-one-out", action="store_true")
|
||||
ap.add_argument("--out-dir", type=str, default=os.path.join(OUTPUT_DIR, "sindy"))
|
||||
ap.add_argument("--train-re", type=str, default="50,100,200")
|
||||
ap.add_argument("--no-mu", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not (args.cross_re or args.leave_one_out):
|
||||
print("ERROR: specify --cross-re and/or --leave-one-out")
|
||||
return 1
|
||||
|
||||
train_re = [int(r) for r in args.train_re.split(",")]
|
||||
include_mu = not args.no_mu
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
|
||||
results = {
|
||||
"metadata": {
|
||||
"method": "v3_dimensionless_front_nobias_weighted",
|
||||
"thresholds": THRESHOLDS,
|
||||
"include_mu": include_mu,
|
||||
}
|
||||
}
|
||||
|
||||
if args.cross_re:
|
||||
print("\n" + "=" * 60)
|
||||
print("v3 Cross-Re unified (dimensionless + front no-bias + weighted)")
|
||||
print("=" * 60)
|
||||
results["cross_re"] = run_cross_re_fit(
|
||||
train_re, tag="v3-cross", include_mu=include_mu)
|
||||
|
||||
if args.leave_one_out:
|
||||
print("\n" + "=" * 60)
|
||||
print("v3 Leave-one-out cross-validation")
|
||||
print("=" * 60)
|
||||
loo_results = {}
|
||||
for held_out in train_re:
|
||||
train_set = [r for r in train_re if r != held_out]
|
||||
print(f"\n--- LOO: train={train_set}, test={held_out} ---")
|
||||
loo = run_cross_re_fit(
|
||||
train_set, tag=f"v3-loo-{held_out}", include_mu=include_mu)
|
||||
loo_results[f"holdout_{held_out}"] = loo
|
||||
results["leave_one_out"] = loo_results
|
||||
|
||||
out_path = os.path.join(args.out_dir, "sindy_results_v3.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"\nSaved: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Phase 3: closed-loop for ablation modes.
|
||||
|
||||
Usage::
|
||||
|
||||
conda run -n pycuda_3_10 python phase3_ablation_val.py \\
|
||||
--ablation-json output/analysis_crossre/sindy/ablation_results.json \\
|
||||
--mode v21 --validate-re 70 --device 2
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
_PROJ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _PROJ not in sys.path:
|
||||
sys.path.insert(0, _PROJ)
|
||||
from LegacyCelerisLab import FlowField
|
||||
|
||||
from utils import (
|
||||
nu_from_re, load_legacy_configs, build_karman_cloak_env, add_pinball,
|
||||
build_observation, scale_action, action_to_physical,
|
||||
compute_dimensionless, compute_physical_symbols,
|
||||
save_vorticity_png, vorticity_from_ddf, compute_similarity,
|
||||
)
|
||||
from cfg import (
|
||||
CONFIG_DIR, OUTPUT_DIR, SAMPLE_INTERVAL, FIFO_LEN, CONV_LEN,
|
||||
S_DIM, ACTION_SCALE, ACTION_BIAS, U0,
|
||||
)
|
||||
|
||||
DATA_TYPE = np.float32
|
||||
|
||||
|
||||
def load_ablation_coef(ablation_path, mode, channels_to_load=("front", "bottom", "top")):
|
||||
"""Load coefficients for a specific ablation mode."""
|
||||
with open(ablation_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
mode_data = data[mode]
|
||||
coefs = {}
|
||||
for ch in mode_data["channels"]:
|
||||
name = ch["cylinder"]
|
||||
if name in channels_to_load:
|
||||
coefs[name] = {
|
||||
"coef": np.array(ch["best_coef"], dtype=np.float64),
|
||||
"has_bias": ch["has_bias"],
|
||||
}
|
||||
return coefs
|
||||
|
||||
|
||||
def predict_ablation(obs_slice, actions_prev, actions_prev2, coefs, mu, mode, u0=0.01):
|
||||
"""Predict action using ablation mode coefficients.
|
||||
|
||||
obs_slice: (12,) raw lattice [sensor(6), force(6)]
|
||||
"""
|
||||
sensors = obs_slice[0:6].astype(np.float64).reshape(1, 6)
|
||||
forces = obs_slice[6:12].astype(np.float64).reshape(1, 6)
|
||||
a_prev = actions_prev.astype(np.float64).reshape(1, 3)
|
||||
a_prev2 = actions_prev2.astype(np.float64).reshape(1, 3)
|
||||
|
||||
is_dim = "v22" in mode or "v23" in mode or "v24" in mode or mode in ("v2_dimless",)
|
||||
|
||||
if is_dim:
|
||||
dim = compute_dimensionless(sensors, forces, u0=u0, d=20.0)
|
||||
# Build dimensionless features
|
||||
sym = _build_dimensionless_features(dim, a_prev[0], a_prev2[0], mu)
|
||||
Y_scale = 1.0 / u0 # predict nondim alpha = omega / U0
|
||||
else:
|
||||
# Lattice features (v2/v21)
|
||||
a_prev_f = np.zeros((1, 3), dtype=np.float64)
|
||||
a_prev2_f = np.zeros((1, 3), dtype=np.float64)
|
||||
a_prev_f[0] = a_prev
|
||||
a_prev2_f[0] = a_prev2
|
||||
sym = _build_lattice_features(sensors, forces, a_prev_f, a_prev2_f, mu)
|
||||
Y_scale = 1.0 # predict raw omega
|
||||
|
||||
# Build feature vector
|
||||
feat_keys = [k for k in sym.keys() if k not in ("mu", "mu_u_a", "mu_v_a", "mu_Cd_tot", "mu_Cl_diff")]
|
||||
feat_keys_mu = ["mu", "mu_u_a", "mu_v_a", "mu_Cd_tot", "mu_Cl_diff"]
|
||||
|
||||
feats = []
|
||||
for k in feat_keys:
|
||||
feats.append(float(sym[k][0]) if isinstance(sym[k], np.ndarray) else float(sym[k]))
|
||||
if mu > 0:
|
||||
for k in feat_keys_mu:
|
||||
feats.append(float(sym[k][0]) if isinstance(sym[k], np.ndarray) else float(sym[k]))
|
||||
|
||||
omega = np.zeros(3, dtype=np.float64)
|
||||
for ci, name in enumerate(["front", "bottom", "top"]):
|
||||
c = coefs.get(name)
|
||||
if c is None:
|
||||
continue
|
||||
coef_arr = c["coef"]
|
||||
has_bias = c["has_bias"]
|
||||
|
||||
if has_bias:
|
||||
feat_vec = np.array([1.0] + feats) if len(coef_arr) == len(feats) + 1 else np.array(feats)
|
||||
else:
|
||||
feat_vec = np.array(feats) if len(coef_arr) == len(feats) else np.array([1.0] + feats)
|
||||
|
||||
if len(feat_vec) != len(coef_arr):
|
||||
feat_vec = np.array(feats) # fallback
|
||||
|
||||
pred = float(feat_vec @ coef_arr) * Y_scale
|
||||
omega[ci] = pred
|
||||
|
||||
return omega
|
||||
|
||||
|
||||
def _build_lattice_features(sensors, forces, a_prev, a_prev2, mu):
|
||||
"""Build v2-style lattice features. All args 2D (1, N)."""
|
||||
# Ensure 2D
|
||||
if sensors.ndim == 1:
|
||||
sensors = sensors.reshape(1, -1)
|
||||
if forces.ndim == 1:
|
||||
forces = forces.reshape(1, -1)
|
||||
if a_prev.ndim == 1:
|
||||
a_prev = a_prev.reshape(1, -1)
|
||||
if a_prev2.ndim == 1:
|
||||
a_prev2 = a_prev2.reshape(1, -1)
|
||||
sym = compute_physical_symbols(sensors, forces, a_prev, a_prev2)
|
||||
sym["mu"] = np.array([mu])
|
||||
sym["mu_u_a"] = sym["u_a"] * mu
|
||||
sym["mu_v_a"] = sym["v_a"] * mu
|
||||
sym["mu_Cd_tot"] = sym["Fx_tot"] * mu
|
||||
sym["mu_Cl_diff"] = sym["Fy_diff"] * mu
|
||||
return sym
|
||||
|
||||
|
||||
def _build_dimensionless_features(dim, a_prev, a_prev2, mu):
|
||||
"""Build dimensionless features. a_prev/a_prev2 are 1D (3,) arrays."""
|
||||
if a_prev.ndim > 1:
|
||||
a_prev = a_prev.flatten()
|
||||
if a_prev2.ndim > 1:
|
||||
a_prev2 = a_prev2.flatten()
|
||||
"""Build dimensionless features."""
|
||||
T = 1
|
||||
u_B, u_C, u_T = dim["u_hat_B"][0], dim["u_hat_C"][0], dim["u_hat_T"][0]
|
||||
v_B, v_C, v_T = dim["v_hat_B"][0], dim["v_hat_C"][0], dim["v_hat_T"][0]
|
||||
|
||||
sym = {}
|
||||
sym["u_m"] = np.array([(u_B + u_C + u_T) / 3.0])
|
||||
sym["u_a"] = np.array([(u_T - u_B) / 2.0])
|
||||
sym["u_c"] = np.array([u_C])
|
||||
sym["u_curv"] = np.array([u_B - 2.0*u_C + u_T])
|
||||
sym["v_a"] = np.array([(v_T - v_B) / 2.0])
|
||||
sym["v_curv"] = np.array([v_B - 2.0*v_C + v_T])
|
||||
sym["sin_ua"] = np.sin(np.pi * sym["u_a"])
|
||||
sym["cos_ua"] = np.cos(np.pi * sym["u_a"])
|
||||
|
||||
sym["Cd_tot"] = np.array([dim["Cd_F"][0] + dim["Cd_T"][0] + dim["Cd_B"][0]])
|
||||
sym["Cd_rear"] = np.array([dim["Cd_T"][0] + dim["Cd_B"][0]])
|
||||
sym["Cd_diff"] = np.array([dim["Cd_T"][0] - dim["Cd_B"][0]])
|
||||
sym["Cl_tot"] = np.array([dim["Cl_F"][0] + dim["Cl_T"][0] + dim["Cl_B"][0]])
|
||||
sym["Cl_diff"] = np.array([dim["Cl_T"][0] - dim["Cl_B"][0]])
|
||||
|
||||
# Nondim actions
|
||||
a_prev_n = a_prev / U0
|
||||
a_prev2_n = a_prev2 / U0
|
||||
sym["a0_lag1"] = np.array([a_prev_n[0]])
|
||||
sym["a1_lag1"] = np.array([a_prev_n[1]])
|
||||
sym["a2_lag1"] = np.array([a_prev_n[2]])
|
||||
sym["da0"] = np.array([a_prev_n[0] - a_prev2_n[0]])
|
||||
sym["da1"] = np.array([a_prev_n[1] - a_prev2_n[1]])
|
||||
sym["da2"] = np.array([a_prev_n[2] - a_prev2_n[2]])
|
||||
|
||||
sym["mu"] = np.array([mu])
|
||||
sym["mu_u_a"] = sym["u_a"] * mu
|
||||
sym["mu_v_a"] = sym["v_a"] * mu
|
||||
sym["mu_Cd_tot"] = sym["Cd_tot"] * mu
|
||||
sym["mu_Cl_diff"] = sym["Cl_diff"] * mu
|
||||
|
||||
return sym
|
||||
|
||||
|
||||
def run_closed_loop(re_code, coefs, mode, device_id, output_root, n_steps=100):
|
||||
"""Run closed-loop for one ablation mode."""
|
||||
os.makedirs(output_root, exist_ok=True)
|
||||
nu = nu_from_re(re_code, u0=U0)
|
||||
mu = 2.0 / re_code
|
||||
|
||||
cuda_cfg, field_cfg = load_legacy_configs(CONFIG_DIR)
|
||||
field_cfg = field_cfg._replace(viscosity=float(nu))
|
||||
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
|
||||
|
||||
target_states, _ = build_karman_cloak_env(
|
||||
ff, u0=U0, l0=20.0, sample_interval=SAMPLE_INTERVAL,
|
||||
fifo_len=FIFO_LEN, data_type=DATA_TYPE)
|
||||
norm = add_pinball(
|
||||
ff, l0=20.0, u0=U0, sample_interval=SAMPLE_INTERVAL,
|
||||
fifo_len=FIFO_LEN, data_type=DATA_TYPE, action_bias=ACTION_BIAS)
|
||||
|
||||
np.savez(os.path.join(output_root, "target.npz"), target_states=target_states)
|
||||
|
||||
# Controlled rollout
|
||||
ff.restore_ddf()
|
||||
ff.apply_ddf()
|
||||
fifo = deque(maxlen=FIFO_LEN)
|
||||
bias_action = scale_action(
|
||||
np.zeros(3, dtype=np.float32), scale=ACTION_SCALE,
|
||||
bias=ACTION_BIAS, u0=U0, n_total_bodies=7)
|
||||
for _ in range(FIFO_LEN):
|
||||
ff.run(SAMPLE_INTERVAL, bias_action)
|
||||
fifo.append(ff.obs.copy()[2:14])
|
||||
|
||||
sens_sc = []
|
||||
actions_prev = action_to_physical(
|
||||
np.zeros((1,3), dtype=np.float32), scale=ACTION_SCALE,
|
||||
bias=ACTION_BIAS, u0=U0).flatten()
|
||||
actions_prev2 = actions_prev.copy()
|
||||
|
||||
for step in range(n_steps):
|
||||
obs_slice = fifo[-1] if len(fifo) > 0 else np.zeros(12, dtype=np.float32)
|
||||
omega_pred = predict_ablation(obs_slice, actions_prev, actions_prev2, coefs, mu, mode, u0=U0)
|
||||
|
||||
norm_action = (omega_pred / U0 - np.array(ACTION_BIAS, dtype=np.float64)) / ACTION_SCALE
|
||||
norm_action = np.clip(norm_action, -1.0, 1.0).astype(np.float32)
|
||||
action_arr = scale_action(
|
||||
norm_action, scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0, n_total_bodies=7)
|
||||
ff.run(SAMPLE_INTERVAL, action_arr)
|
||||
|
||||
obs_slice_new = ff.obs.copy()[2:14]
|
||||
fifo.append(obs_slice_new)
|
||||
sens_sc.append(obs_slice_new[0:6])
|
||||
actions_prev2 = actions_prev.copy()
|
||||
actions_prev = omega_pred.copy()
|
||||
|
||||
sens_arr = np.array(sens_sc, dtype=np.float32)
|
||||
sim = compute_similarity(target_states, sens_arr, CONV_LEN)
|
||||
|
||||
omega_vort = vorticity_from_ddf(ff, u0=U0)
|
||||
save_vorticity_png(os.path.join(output_root, f"vorticity_{mode}.png"),
|
||||
omega_vort, title=f"Re{re_code} {mode}")
|
||||
|
||||
del ff
|
||||
result = {"re_code": re_code, "mode": mode, "similarity": sim}
|
||||
with open(os.path.join(output_root, "result.json"), "w") as f:
|
||||
json.dump(result, f, indent=2)
|
||||
print(f" Re{re_code} {mode}: similarity={sim:.4f}")
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--ablation-json", type=str, required=True)
|
||||
ap.add_argument("--mode", type=str, default="v21")
|
||||
ap.add_argument("--validate-re", type=str, default="70")
|
||||
ap.add_argument("--device", type=int, default=2)
|
||||
ap.add_argument("--steps", type=int, default=100)
|
||||
ap.add_argument("--out-dir", type=str, default=os.path.join(OUTPUT_DIR, "sindy_val"))
|
||||
args = ap.parse_args()
|
||||
|
||||
validate_re = [int(r) for r in args.validate_re.split(",")]
|
||||
coefs = load_ablation_coef(args.ablation_json, args.mode)
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
|
||||
for rc in validate_re:
|
||||
out_sub = os.path.join(args.out_dir, f"re{rc}")
|
||||
run_closed_loop(rc, coefs, args.mode, args.device, out_sub, n_steps=args.steps)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,306 @@
|
||||
# analysis_crossre/scripts/phase3_validate.py
|
||||
"""Phase 3: closed-loop validation using cross-Re SINDy control law.
|
||||
|
||||
Usage::
|
||||
|
||||
conda run -n pycuda_3_10 python phase3_validate.py \\
|
||||
--device 2 --out-dir output/analysis_crossre/sindy_val
|
||||
|
||||
conda run -n pycuda_3_10 python phase3_validate.py \\
|
||||
--validate-re 35,70,150 --device 2
|
||||
|
||||
conda run -n pycuda_3_10 python phase3_validate.py \\
|
||||
--baseline-only --validate-re 35 --device 2
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
_PROJ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _PROJ not in sys.path:
|
||||
sys.path.insert(0, _PROJ)
|
||||
from LegacyCelerisLab import FlowField # noqa: E402
|
||||
|
||||
from utils import (
|
||||
nu_from_re,
|
||||
load_legacy_configs,
|
||||
build_karman_cloak_env,
|
||||
add_pinball,
|
||||
build_observation,
|
||||
scale_action,
|
||||
action_to_physical,
|
||||
compute_dimensionless,
|
||||
compute_v3_symbols,
|
||||
save_vorticity_png,
|
||||
vorticity_from_ddf,
|
||||
compute_similarity,
|
||||
)
|
||||
from cfg import (
|
||||
CONFIG_DIR,
|
||||
OUTPUT_DIR,
|
||||
MODEL_DIR,
|
||||
SAMPLE_INTERVAL,
|
||||
FIFO_LEN,
|
||||
CONV_LEN,
|
||||
S_DIM,
|
||||
A_DIM,
|
||||
ACTION_SCALE,
|
||||
ACTION_BIAS,
|
||||
U0,
|
||||
RE_CASES_TRAIN,
|
||||
RE_CASES_VALIDATION,
|
||||
RE_LABEL_MAP,
|
||||
)
|
||||
|
||||
DATA_TYPE = np.float32
|
||||
|
||||
|
||||
def load_cross_re_coef(sindy_results_path: str, threshold: float) -> dict:
|
||||
"""Load v3 cross-Re coefficients.
|
||||
|
||||
Returns dict ``{cylinder_name: {"coef": np.ndarray, "feat_names": list, "has_bias": bool}}``
|
||||
"""
|
||||
with open(sindy_results_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
cross = data["cross_re"]
|
||||
coefs = {}
|
||||
for ch_entry in cross["channels"]:
|
||||
name = ch_entry["cylinder"]
|
||||
feat_names = ch_entry["feature_names"]
|
||||
coef_full = np.array(ch_entry["best_coef"], dtype=np.float64)
|
||||
has_bias = ch_entry["has_bias"]
|
||||
|
||||
scale = np.max(np.abs(coef_full))
|
||||
if scale > 0 and threshold > 0:
|
||||
mask = np.abs(coef_full) / scale >= threshold
|
||||
else:
|
||||
mask = np.ones_like(coef_full, dtype=bool)
|
||||
coef = coef_full * mask
|
||||
|
||||
nz = int(np.sum(mask))
|
||||
print(f" {name}: total={len(coef_full)} nz={nz} threshold={threshold} "
|
||||
f"R2={ch_entry['best']['r2']:.4f}")
|
||||
|
||||
coefs[name] = {"coef": coef, "feat_names": feat_names,
|
||||
"has_bias": has_bias, "nz": nz, "r2": ch_entry["best"]["r2"]}
|
||||
return coefs
|
||||
|
||||
|
||||
def predict_omega_v3(
|
||||
obs_slice: np.ndarray,
|
||||
actions_prev: np.ndarray,
|
||||
actions_prev2: np.ndarray,
|
||||
coefs: dict,
|
||||
mu: float,
|
||||
u0: float = 0.01,
|
||||
) -> np.ndarray:
|
||||
"""Predict physical omega using v3 dimensionless features.
|
||||
|
||||
Front: no bias term.
|
||||
Bottom/Top: with bias term.
|
||||
All 3 independently (no exchange symmetry constraint).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
obs_slice : (12,) raw [sensor(6), force(6)] in lattice units
|
||||
actions_prev : (3,) omega(t-1)
|
||||
actions_prev2 : (3,) omega(t-2)
|
||||
"""
|
||||
sensors = obs_slice[0:6].astype(np.float64).reshape(1, 6)
|
||||
forces = obs_slice[6:12].astype(np.float64).reshape(1, 6)
|
||||
a_prev = actions_prev.astype(np.float64).reshape(1, 3)
|
||||
a_prev2 = actions_prev2.astype(np.float64).reshape(1, 3)
|
||||
|
||||
# Dimensionless
|
||||
dim = compute_dimensionless(sensors, forces, u0=u0, d=20.0)
|
||||
|
||||
# Build v3 features
|
||||
Theta_f, Theta_top, names = compute_v3_symbols(
|
||||
dim, a_prev, a_prev2, mu=mu, include_mu=(mu > 0))
|
||||
|
||||
# Predict
|
||||
omega = np.zeros(3, dtype=np.float64)
|
||||
omega[0] = float(Theta_f[0] @ coefs["front"]["coef"]) # front (no bias)
|
||||
omega[1] = float(Theta_top[0] @ coefs["bottom"]["coef"]) # bottom
|
||||
omega[2] = float(Theta_top[0] @ coefs["top"]["coef"]) # top
|
||||
|
||||
return omega
|
||||
|
||||
|
||||
def run_sindy_controlled(
|
||||
re_code: int,
|
||||
coefs: dict,
|
||||
device_id: int,
|
||||
output_root: str,
|
||||
*,
|
||||
n_steps: int = 150,
|
||||
) -> dict:
|
||||
"""Run closed-loop validation with SINDy control law."""
|
||||
os.makedirs(output_root, exist_ok=True)
|
||||
|
||||
nu = nu_from_re(re_code, u0=U0)
|
||||
mu = 2.0 / re_code # 1 / Re_D
|
||||
label = RE_LABEL_MAP.get(re_code, f"Re{re_code}")
|
||||
print(f"\n{'='*60}")
|
||||
print(f"SINDy Validation: {label} nu={nu:.6f} mu={mu:.6f}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Build environment (same as Phase 1)
|
||||
cuda_cfg, field_cfg = load_legacy_configs(CONFIG_DIR)
|
||||
field_cfg = field_cfg._replace(viscosity=float(nu))
|
||||
|
||||
# Phase 1: dist + sensors + target
|
||||
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
|
||||
target_states, _ = build_karman_cloak_env(
|
||||
ff, u0=U0, l0=20.0, sample_interval=SAMPLE_INTERVAL,
|
||||
fifo_len=FIFO_LEN, data_type=DATA_TYPE,
|
||||
)
|
||||
|
||||
# Phase 2: pinball + norm
|
||||
norm = add_pinball(
|
||||
ff, l0=20.0, u0=U0, sample_interval=SAMPLE_INTERVAL,
|
||||
fifo_len=FIFO_LEN, data_type=DATA_TYPE,
|
||||
action_bias=ACTION_BIAS,
|
||||
)
|
||||
np.savez(os.path.join(output_root, "target.npz"), target_states=target_states)
|
||||
|
||||
# --- Uncontrolled rollout ---
|
||||
print(" uncontrolled rollout ...")
|
||||
ff.restore_ddf()
|
||||
ff.apply_ddf()
|
||||
sens_unc, forc_unc = [], []
|
||||
for _ in range(n_steps):
|
||||
ff.run(SAMPLE_INTERVAL, np.zeros(7, dtype=DATA_TYPE))
|
||||
obs_slice = ff.obs.copy()[2:14]
|
||||
sens_unc.append(obs_slice[0:6])
|
||||
forc_unc.append(obs_slice[6:12])
|
||||
np.savez(os.path.join(output_root, "uncontrolled.npz"),
|
||||
sensors=np.array(sens_unc, dtype=np.float32),
|
||||
forces=np.array(forc_unc, dtype=np.float32))
|
||||
|
||||
# Uncontrolled vorticity
|
||||
omega_unc = vorticity_from_ddf(ff, u0=U0)
|
||||
save_vorticity_png(os.path.join(output_root, "vorticity_uncontrolled.png"),
|
||||
omega_unc, title=f"{label} uncontrolled")
|
||||
|
||||
# --- SINDy controlled rollout ---
|
||||
print(f" SINDy controlled rollout ({n_steps} steps) ...")
|
||||
ff.restore_ddf()
|
||||
ff.apply_ddf()
|
||||
|
||||
# Bias FIFO
|
||||
fifo = deque(maxlen=FIFO_LEN)
|
||||
bias_action = scale_action(
|
||||
np.zeros(3, dtype=np.float32),
|
||||
scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0, n_total_bodies=7,
|
||||
)
|
||||
for _ in range(FIFO_LEN):
|
||||
ff.run(SAMPLE_INTERVAL, bias_action)
|
||||
fifo.append(ff.obs.copy()[2:14])
|
||||
|
||||
sens_sc, forc_sc, omega_sc = [], [], []
|
||||
omega_bias = action_to_physical(
|
||||
np.zeros((1, 3), dtype=np.float32),
|
||||
scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0,
|
||||
).flatten()
|
||||
actions_prev = omega_bias.copy()
|
||||
actions_prev2 = omega_bias.copy()
|
||||
|
||||
for step in range(n_steps):
|
||||
obs_slice = fifo[-1] if len(fifo) > 0 else np.zeros(12, dtype=np.float32)
|
||||
|
||||
omega_pred = predict_omega_v3(obs_slice, actions_prev, actions_prev2, coefs, mu, u0=U0)
|
||||
omega_sc.append(omega_pred.copy())
|
||||
|
||||
# Convert action to legacy array and apply
|
||||
norm_action = (omega_pred / U0 - np.array(ACTION_BIAS, dtype=np.float64)) / ACTION_SCALE
|
||||
norm_action = np.clip(norm_action, -1.0, 1.0).astype(np.float32)
|
||||
action_arr = scale_action(
|
||||
norm_action,
|
||||
scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0, n_total_bodies=7,
|
||||
)
|
||||
ff.run(SAMPLE_INTERVAL, action_arr)
|
||||
|
||||
obs_slice_new = ff.obs.copy()[2:14]
|
||||
fifo.append(obs_slice_new)
|
||||
sens_sc.append(obs_slice_new[0:6])
|
||||
forc_sc.append(obs_slice_new[6:12])
|
||||
actions_prev = omega_pred
|
||||
|
||||
sens_sc_arr = np.array(sens_sc, dtype=np.float32)
|
||||
forc_sc_arr = np.array(forc_sc, dtype=np.float32)
|
||||
omega_sc_arr = np.array(omega_sc, dtype=np.float32)
|
||||
np.savez(os.path.join(output_root, "sindy_controlled.npz"),
|
||||
sensors=sens_sc_arr, forces=forc_sc_arr,
|
||||
omegas=omega_sc_arr)
|
||||
|
||||
# Vorticity
|
||||
omega_vort = vorticity_from_ddf(ff, u0=U0)
|
||||
save_vorticity_png(os.path.join(output_root, "vorticity_sindy_controlled.png"),
|
||||
omega_vort, title=f"{label} SINDy-controlled")
|
||||
|
||||
# Similarity
|
||||
sim = compute_similarity(target_states, sens_sc_arr, CONV_LEN)
|
||||
print(f" SINDy similarity: {sim:.4f}")
|
||||
|
||||
del ff
|
||||
result = {"re_code": re_code, "mu": mu,
|
||||
"sindy_similarity": sim,
|
||||
"n_steps": n_steps}
|
||||
with open(os.path.join(output_root, "result.json"), "w") as f:
|
||||
json.dump(result, f, indent=2)
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Phase 3: cross-Re SINDy validation")
|
||||
ap.add_argument("--sindy-results", type=str,
|
||||
default=os.path.join(OUTPUT_DIR, "sindy", "sindy_results_v3.json"),
|
||||
help="Path to Phase 2 SINDy results JSON (v3 dimensionless)")
|
||||
ap.add_argument("--validate-re", type=str, default="35,70,150",
|
||||
help="Comma-separated validation Re codes")
|
||||
ap.add_argument("--device", type=int, default=0, help="GPU device ID")
|
||||
ap.add_argument("--steps", type=int, default=150,
|
||||
help="Number of inference steps")
|
||||
ap.add_argument("--threshold", type=float, default=0.002,
|
||||
help="SINDy sparsity threshold (default: 0.002)")
|
||||
ap.add_argument("--out-dir", type=str,
|
||||
default=os.path.join(OUTPUT_DIR, "sindy_val"),
|
||||
help="Output root for validation results")
|
||||
args = ap.parse_args()
|
||||
|
||||
validate_re = [int(r) for r in args.validate_re.split(",")]
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
|
||||
# Load cross-Re coefficients
|
||||
print(f"\nLoading cross-Re coefficients from {args.sindy_results}")
|
||||
coefs = load_cross_re_coef(args.sindy_results, args.threshold)
|
||||
for name in ["front", "bottom", "top"]:
|
||||
print(f" {name}: nz={coefs[name]['nz']}, R2={coefs[name]['r2']:.4f}, "
|
||||
f"threshold={args.threshold}")
|
||||
|
||||
t_start = time.time()
|
||||
|
||||
# Run for each validation Re
|
||||
for re_code in validate_re:
|
||||
out_dir = os.path.join(args.out_dir, f"re{re_code}")
|
||||
result = run_sindy_controlled(
|
||||
re_code, coefs, args.device, out_dir, n_steps=args.steps,
|
||||
)
|
||||
print(f" Done: Re{re_code} -> {out_dir}")
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
print(f"\nTotal time: {elapsed:.1f}s")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,197 @@
|
||||
# analysis_crossre/scripts/validate_v22.py
|
||||
"""Validate v22: v2 coefficients + front bias zeroed.
|
||||
Direct standalone script to avoid JSON format issues.
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python validate_v22.py --re 70 --device 2
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
_PROJ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _PROJ not in sys.path:
|
||||
sys.path.insert(0, _PROJ)
|
||||
from LegacyCelerisLab import FlowField
|
||||
from LegacyCelerisLab import utils as legacy_utils
|
||||
|
||||
from utils import (
|
||||
nu_from_re, action_to_physical, scale_action, build_karman_cloak_env,
|
||||
add_pinball, build_observation, compute_physical_symbols,
|
||||
save_vorticity_png, vorticity_from_ddf, compute_similarity,
|
||||
load_legacy_configs,
|
||||
)
|
||||
from cfg import (
|
||||
OUTPUT_DIR, SAMPLE_INTERVAL, FIFO_LEN, CONV_LEN, S_DIM,
|
||||
ACTION_SCALE, ACTION_BIAS, U0, CONFIG_DIR,
|
||||
)
|
||||
|
||||
DATA_TYPE = np.float32
|
||||
|
||||
# v2 feature keys (matching sindy_results_v2.json layout exactly)
|
||||
V2_FEAT_KEYS = [
|
||||
"u_m", "u_a", "u_c", "v_a",
|
||||
"Fx_tot", "Fx_rear", "Fy_tot", "Fy_diff",
|
||||
"sin_ua", "cos_ua",
|
||||
"a0_lag1", "a1_lag1", "a2_lag1",
|
||||
"da0", "da1", "da2",
|
||||
]
|
||||
V2_MU_KEYS = ["mu", "mu_u_a", "mu_v_a", "mu_Fx_tot", "mu_Fy_diff", "mu_Fy_tot"]
|
||||
V2_N_FEAT_NOBIAS = len(V2_FEAT_KEYS) + len(V2_MU_KEYS) # 22
|
||||
V2_N_FEAT_BIAS = 1 + V2_N_FEAT_NOBIAS # 23
|
||||
|
||||
|
||||
def build_feature_vec(obs_slice, actions_prev, actions_prev2, mu, add_bias):
|
||||
"""Build a single feature vector matching v2 feature layout."""
|
||||
sensors = obs_slice[0:6].astype(np.float64).reshape(1, 6)
|
||||
forces = obs_slice[6:12].astype(np.float64).reshape(1, 6)
|
||||
ap = actions_prev.astype(np.float64).reshape(1, 3)
|
||||
ap2 = actions_prev2.astype(np.float64).reshape(1, 3)
|
||||
|
||||
sym = compute_physical_symbols(sensors, forces, ap, ap2)
|
||||
# Add mu terms
|
||||
sym["mu"] = np.array([mu])
|
||||
sym["mu_u_a"] = sym["u_a"] * mu
|
||||
sym["mu_v_a"] = sym["v_a"] * mu
|
||||
sym["mu_Fx_tot"] = sym["Fx_tot"] * mu
|
||||
sym["mu_Fy_diff"] = sym["Fy_diff"] * mu
|
||||
sym["mu_Fy_tot"] = sym["Fy_tot"] * mu
|
||||
|
||||
vals = []
|
||||
if add_bias:
|
||||
vals.append(1.0)
|
||||
for k in V2_FEAT_KEYS:
|
||||
vals.append(float(sym[k][0]))
|
||||
for k in V2_MU_KEYS:
|
||||
vals.append(float(sym[k][0]))
|
||||
return np.array(vals, dtype=np.float64)
|
||||
|
||||
|
||||
def load_v2_coefs(v2_path):
|
||||
"""Load v2 coefficients, zero front bias."""
|
||||
with open(v2_path) as f:
|
||||
data = json.load(f)
|
||||
cross = data["cross_re"]
|
||||
coefs_list = cross["channels"] # 3 channels: 0=front, 1=bottom, 2=top
|
||||
|
||||
# Zero front bias
|
||||
coefs_list[0]["best_coef"][0] = 0.0
|
||||
|
||||
names = ["front", "bottom", "top"]
|
||||
result = {}
|
||||
for i, name in enumerate(names):
|
||||
coef_list = coefs_list[i]["best_coef"]
|
||||
# Check if first is bias (it is for v2)
|
||||
has_bias = True
|
||||
if name == "front":
|
||||
has_bias = True # v2 has bias for all, we just zeroed it
|
||||
result[name] = {
|
||||
"coef": np.array(coef_list, dtype=np.float64),
|
||||
"has_bias": True, # v2 has bias for all channels
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def predict(obs_slice, a_prev, a_prev2, coefs, mu):
|
||||
"""Predict physical omega using v2 coefficients."""
|
||||
omega = np.zeros(3, dtype=np.float64)
|
||||
for i, name in enumerate(["front", "bottom", "top"]):
|
||||
c = coefs[name]
|
||||
feat = build_feature_vec(obs_slice, a_prev, a_prev2, mu, add_bias=c["has_bias"])
|
||||
omega[i] = float(feat @ c["coef"])
|
||||
return omega
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--re", type=int, default=70)
|
||||
ap.add_argument("--device", type=int, default=2)
|
||||
ap.add_argument("--steps", type=int, default=100)
|
||||
ap.add_argument("--out-dir", type=str, default=os.path.join(OUTPUT_DIR, "sindy_val"))
|
||||
ap.add_argument("--v2-results", type=str,
|
||||
default=os.path.join(OUTPUT_DIR, "sindy", "sindy_results_v2.json"))
|
||||
args = ap.parse_args()
|
||||
|
||||
re_code = args.re
|
||||
mu = 2.0 / re_code
|
||||
output_root = os.path.join(args.out_dir, f"re{re_code}")
|
||||
os.makedirs(output_root, exist_ok=True)
|
||||
|
||||
print(f"\n=== v22 validation: Re{re_code} (mu={mu:.6f}) ===")
|
||||
|
||||
# Load v2 coefs (front bias zeroed)
|
||||
coefs = load_v2_coefs(args.v2_results)
|
||||
for name in ["front", "bottom", "top"]:
|
||||
print(f" {name}: {len(coefs[name]['coef'])} coefs, "
|
||||
f"bias={coefs[name]['coef'][0]:.6f}")
|
||||
|
||||
# Build environment (same as phase1)
|
||||
cuda_cfg, field_cfg = load_legacy_configs(CONFIG_DIR)
|
||||
field_cfg = field_cfg._replace(viscosity=float(nu_from_re(re_code, u0=U0)))
|
||||
ff = FlowField(field_cfg, cuda_cfg, device_id=args.device)
|
||||
|
||||
target_states, _ = build_karman_cloak_env(
|
||||
ff, u0=U0, l0=20.0, sample_interval=SAMPLE_INTERVAL,
|
||||
fifo_len=FIFO_LEN, data_type=DATA_TYPE)
|
||||
norm = add_pinball(
|
||||
ff, l0=20.0, u0=U0, sample_interval=SAMPLE_INTERVAL,
|
||||
fifo_len=FIFO_LEN, data_type=DATA_TYPE, action_bias=ACTION_BIAS)
|
||||
|
||||
# Controlled rollout
|
||||
ff.restore_ddf()
|
||||
ff.apply_ddf()
|
||||
fifo = deque(maxlen=FIFO_LEN)
|
||||
bias_action = scale_action(np.zeros(3, dtype=np.float32),
|
||||
scale=ACTION_SCALE, bias=ACTION_BIAS,
|
||||
u0=U0, n_total_bodies=7)
|
||||
for _ in range(FIFO_LEN):
|
||||
ff.run(SAMPLE_INTERVAL, bias_action)
|
||||
fifo.append(ff.obs.copy()[2:14])
|
||||
|
||||
sens_sc = []
|
||||
a_prev = action_to_physical(np.zeros((1,3), dtype=np.float32),
|
||||
scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0).flatten()
|
||||
a_prev2 = a_prev.copy()
|
||||
|
||||
for step in range(args.steps):
|
||||
obs_slice = fifo[-1] if len(fifo) > 0 else np.zeros(12, dtype=np.float32)
|
||||
omega = predict(obs_slice, a_prev, a_prev2, coefs, mu)
|
||||
|
||||
# Apply action (convert to normalized for legacy run())
|
||||
norm_action = (omega / U0 - np.array(ACTION_BIAS, dtype=np.float64)) / ACTION_SCALE
|
||||
norm_action = np.clip(norm_action, -1.0, 1.0).astype(np.float32)
|
||||
action_arr = scale_action(norm_action, scale=ACTION_SCALE,
|
||||
bias=ACTION_BIAS, u0=U0, n_total_bodies=7)
|
||||
ff.run(SAMPLE_INTERVAL, action_arr)
|
||||
|
||||
obs_slice_new = ff.obs.copy()[2:14]
|
||||
fifo.append(obs_slice_new)
|
||||
sens_sc.append(obs_slice_new[0:6])
|
||||
a_prev2 = a_prev.copy()
|
||||
a_prev = omega.copy()
|
||||
|
||||
sens_arr = np.array(sens_sc, dtype=np.float32)
|
||||
sim = compute_similarity(target_states, sens_arr, CONV_LEN)
|
||||
print(f" v22 similarity: {sim:.4f}")
|
||||
|
||||
# Vorticity
|
||||
omega_vort = vorticity_from_ddf(ff, u0=U0)
|
||||
save_vorticity_png(os.path.join(output_root, "vorticity_v22.png"),
|
||||
omega_vort, title=f"Re{re_code} v22 (front no-bias)")
|
||||
|
||||
# Save result
|
||||
result = {"re_code": re_code, "mode": "v22", "similarity": sim}
|
||||
with open(os.path.join(output_root, "result_v22.json"), "w") as f:
|
||||
json.dump(result, f, indent=2)
|
||||
|
||||
del ff
|
||||
print(f" Done -> {output_root}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,237 @@
|
||||
# analysis_crossre/scripts/validate_v23.py
|
||||
"""Validate v23: front no-bias + rear shared-head.
|
||||
|
||||
Front: v2 coeffs with bias=0.
|
||||
Top: v2 coeffs unchanged.
|
||||
Bottom: -top(Gx), using G-transformed raw observations.
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python validate_v23.py --re 70 --device 2
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
_PROJ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _PROJ not in sys.path:
|
||||
sys.path.insert(0, _PROJ)
|
||||
from LegacyCelerisLab import FlowField
|
||||
|
||||
from utils import (
|
||||
nu_from_re, action_to_physical, scale_action, build_karman_cloak_env,
|
||||
add_pinball, build_observation, compute_physical_symbols,
|
||||
save_vorticity_png, vorticity_from_ddf, compute_similarity,
|
||||
load_legacy_configs,
|
||||
)
|
||||
from cfg import (
|
||||
OUTPUT_DIR, SAMPLE_INTERVAL, FIFO_LEN, CONV_LEN, S_DIM,
|
||||
ACTION_SCALE, ACTION_BIAS, U0, CONFIG_DIR,
|
||||
)
|
||||
|
||||
DATA_TYPE = np.float32
|
||||
|
||||
# v2 feature keys matching sindy_results_v2.json
|
||||
V2_FEAT_KEYS = [
|
||||
"u_m", "u_a", "u_c", "v_a",
|
||||
"Fx_tot", "Fx_rear", "Fy_tot", "Fy_diff",
|
||||
"sin_ua", "cos_ua",
|
||||
"a0_lag1", "a1_lag1", "a2_lag1",
|
||||
"da0", "da1", "da2",
|
||||
]
|
||||
V2_MU_KEYS = ["mu", "mu_u_a", "mu_v_a", "mu_Fx_tot", "mu_Fy_diff", "mu_Fy_tot"]
|
||||
|
||||
|
||||
def apply_G_raw(obs_slice, a_prev, a_prev2):
|
||||
"""Apply mirror operator G to raw observations and actions.
|
||||
|
||||
obs_slice: (12,) [s0_ux,s0_uy, s1_ux,s1_uy, s2_ux,s2_uy, front_fx,front_fy, bot_fx,bot_fy, top_fx,top_fy]
|
||||
a_prev: (3,) [aF, aB, aT]
|
||||
a_prev2: (3,) [aF_prev2, aB_prev2, aT_prev2]
|
||||
|
||||
Returns (G_obs, G_a_prev, G_a_prev2)
|
||||
"""
|
||||
# Sensors: top<->bottom swap, cross components negate
|
||||
G_obs = np.zeros(12, dtype=np.float64)
|
||||
G_obs[0] = obs_slice[4] # s0_ux <- s2_ux (streamwise: no sign)
|
||||
G_obs[1] = -obs_slice[5] # s0_uy <- -s2_uy (cross: negate)
|
||||
G_obs[2] = obs_slice[2] # s1_ux unchanged
|
||||
G_obs[3] = -obs_slice[3] # s1_uy negate
|
||||
G_obs[4] = obs_slice[0] # s2_ux <- s0_ux
|
||||
G_obs[5] = -obs_slice[1] # s2_uy <- -s0_uy
|
||||
|
||||
# Forces: front unchanged (but lift sign flips), bottom<->top with sign flips
|
||||
G_obs[6] = obs_slice[6] # front_fx unchanged
|
||||
G_obs[7] = -obs_slice[7] # front_fy negate
|
||||
G_obs[8] = obs_slice[10] # bot_fx <- top_fx
|
||||
G_obs[9] = -obs_slice[11] # bot_fy <- -top_fy
|
||||
G_obs[10] = obs_slice[8] # top_fx <- bot_fx
|
||||
G_obs[11] = -obs_slice[9] # top_fy <- -bot_fy
|
||||
|
||||
# Actions: all negate, B<->T swap
|
||||
G_a_prev = np.array([-a_prev[0], -a_prev[2], -a_prev[1]], dtype=np.float64)
|
||||
G_a_prev2 = np.array([-a_prev2[0], -a_prev2[2], -a_prev2[1]], dtype=np.float64)
|
||||
|
||||
return G_obs, G_a_prev, G_a_prev2
|
||||
|
||||
|
||||
def build_v2_feature_vec(obs_slice, actions_prev, actions_prev2, mu, add_bias):
|
||||
"""Build feature vector matching v2 layout."""
|
||||
sensors = obs_slice[0:6].astype(np.float64).reshape(1, 6)
|
||||
forces = obs_slice[6:12].astype(np.float64).reshape(1, 6)
|
||||
ap = actions_prev.astype(np.float64).reshape(1, 3)
|
||||
ap2 = actions_prev2.astype(np.float64).reshape(1, 3)
|
||||
|
||||
sym = compute_physical_symbols(sensors, forces, ap, ap2)
|
||||
sym["mu"] = np.array([mu])
|
||||
sym["mu_u_a"] = sym["u_a"] * mu
|
||||
sym["mu_v_a"] = sym["v_a"] * mu
|
||||
sym["mu_Fx_tot"] = sym["Fx_tot"] * mu
|
||||
sym["mu_Fy_diff"] = sym["Fy_diff"] * mu
|
||||
sym["mu_Fy_tot"] = sym["Fy_tot"] * mu
|
||||
|
||||
vals = []
|
||||
if add_bias:
|
||||
vals.append(1.0)
|
||||
for k in V2_FEAT_KEYS:
|
||||
vals.append(float(sym[k][0]))
|
||||
for k in V2_MU_KEYS:
|
||||
vals.append(float(sym[k][0]))
|
||||
return np.array(vals, dtype=np.float64)
|
||||
|
||||
|
||||
def load_v2_coefs(v2_path):
|
||||
"""Load v2 coefficients. Zero front bias. Return front + top only."""
|
||||
with open(v2_path) as f:
|
||||
data = json.load(f)
|
||||
cross = data["cross_re"]
|
||||
coefs_list = cross["channels"]
|
||||
|
||||
# Zero front bias
|
||||
coefs_list[0]["best_coef"][0] = 0.0
|
||||
|
||||
return {
|
||||
"front": {
|
||||
"coef": np.array(coefs_list[0]["best_coef"], dtype=np.float64),
|
||||
"has_bias": True,
|
||||
},
|
||||
"top": {
|
||||
"coef": np.array(coefs_list[2]["best_coef"], dtype=np.float64),
|
||||
"has_bias": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def predict_v23(obs_slice, a_prev, a_prev2, coefs, mu):
|
||||
"""Predict physical omega using v23 shared-head.
|
||||
|
||||
Front: v2 coefs (bias zeroed).
|
||||
Top: v2 coefs unchanged.
|
||||
Bottom: -top(Gx).
|
||||
"""
|
||||
# Front prediction
|
||||
feat = build_v2_feature_vec(obs_slice, a_prev, a_prev2, mu, add_bias=coefs["front"]["has_bias"])
|
||||
front = float(feat @ coefs["front"]["coef"])
|
||||
|
||||
# Top prediction (from original state)
|
||||
feat_top = build_v2_feature_vec(obs_slice, a_prev, a_prev2, mu, add_bias=coefs["top"]["has_bias"])
|
||||
top = float(feat_top @ coefs["top"]["coef"])
|
||||
|
||||
# Bottom = -top(Gx)
|
||||
G_obs, G_a_prev, G_a_prev2 = apply_G_raw(obs_slice, a_prev, a_prev2)
|
||||
feat_G = build_v2_feature_vec(G_obs, G_a_prev, G_a_prev2, mu, add_bias=coefs["top"]["has_bias"])
|
||||
top_at_Gx = float(feat_G @ coefs["top"]["coef"])
|
||||
bottom = -top_at_Gx
|
||||
|
||||
return np.array([front, bottom, top], dtype=np.float64)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--re", type=int, default=70)
|
||||
ap.add_argument("--device", type=int, default=2)
|
||||
ap.add_argument("--steps", type=int, default=100)
|
||||
ap.add_argument("--out-dir", type=str, default=os.path.join(OUTPUT_DIR, "sindy_val"))
|
||||
ap.add_argument("--v2-results", type=str,
|
||||
default=os.path.join(OUTPUT_DIR, "sindy", "sindy_results_v2.json"))
|
||||
args = ap.parse_args()
|
||||
|
||||
re_code = args.re
|
||||
mu = 2.0 / re_code
|
||||
output_root = os.path.join(args.out_dir, f"re{re_code}")
|
||||
os.makedirs(output_root, exist_ok=True)
|
||||
|
||||
print(f"\n=== v23 validation: Re{re_code} (mu={mu:.6f}) ===")
|
||||
|
||||
coefs = load_v2_coefs(args.v2_results)
|
||||
for name in ["front", "top"]:
|
||||
print(f" {name}: {len(coefs[name]['coef'])} coefs")
|
||||
|
||||
# Build environment
|
||||
cuda_cfg, field_cfg = load_legacy_configs(CONFIG_DIR)
|
||||
field_cfg = field_cfg._replace(viscosity=float(nu_from_re(re_code, u0=U0)))
|
||||
ff = FlowField(field_cfg, cuda_cfg, device_id=args.device)
|
||||
|
||||
target_states, _ = build_karman_cloak_env(
|
||||
ff, u0=U0, l0=20.0, sample_interval=SAMPLE_INTERVAL,
|
||||
fifo_len=FIFO_LEN, data_type=DATA_TYPE)
|
||||
norm = add_pinball(
|
||||
ff, l0=20.0, u0=U0, sample_interval=SAMPLE_INTERVAL,
|
||||
fifo_len=FIFO_LEN, data_type=DATA_TYPE, action_bias=ACTION_BIAS)
|
||||
|
||||
# Controlled rollout
|
||||
ff.restore_ddf()
|
||||
ff.apply_ddf()
|
||||
fifo = deque(maxlen=FIFO_LEN)
|
||||
bias_action = scale_action(np.zeros(3, dtype=np.float32),
|
||||
scale=ACTION_SCALE, bias=ACTION_BIAS,
|
||||
u0=U0, n_total_bodies=7)
|
||||
for _ in range(FIFO_LEN):
|
||||
ff.run(SAMPLE_INTERVAL, bias_action)
|
||||
fifo.append(ff.obs.copy()[2:14])
|
||||
|
||||
sens_sc = []
|
||||
a_prev = action_to_physical(np.zeros((1,3), dtype=np.float32),
|
||||
scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0).flatten()
|
||||
a_prev2 = a_prev.copy()
|
||||
|
||||
for step in range(args.steps):
|
||||
obs_slice = fifo[-1] if len(fifo) > 0 else np.zeros(12, dtype=np.float32)
|
||||
omega = predict_v23(obs_slice, a_prev, a_prev2, coefs, mu)
|
||||
|
||||
# Apply action
|
||||
norm_action = (omega / U0 - np.array(ACTION_BIAS, dtype=np.float64)) / ACTION_SCALE
|
||||
norm_action = np.clip(norm_action, -1.0, 1.0).astype(np.float32)
|
||||
action_arr = scale_action(norm_action, scale=ACTION_SCALE,
|
||||
bias=ACTION_BIAS, u0=U0, n_total_bodies=7)
|
||||
ff.run(SAMPLE_INTERVAL, action_arr)
|
||||
|
||||
obs_slice_new = ff.obs.copy()[2:14]
|
||||
fifo.append(obs_slice_new)
|
||||
sens_sc.append(obs_slice_new[0:6])
|
||||
a_prev2 = a_prev.copy()
|
||||
a_prev = omega.copy()
|
||||
|
||||
sens_arr = np.array(sens_sc, dtype=np.float32)
|
||||
sim = compute_similarity(target_states, sens_arr, CONV_LEN)
|
||||
print(f" v23 similarity: {sim:.4f}")
|
||||
|
||||
# Vorticity
|
||||
omega_vort = vorticity_from_ddf(ff, u0=U0)
|
||||
save_vorticity_png(os.path.join(output_root, "vorticity_v23.png"),
|
||||
omega_vort, title=f"Re{re_code} v23 (shared-head)")
|
||||
|
||||
result = {"re_code": re_code, "mode": "v23", "similarity": sim}
|
||||
with open(os.path.join(output_root, "result_v23.json"), "w") as f:
|
||||
json.dump(result, f, indent=2)
|
||||
|
||||
del ff
|
||||
print(f" Done -> {output_root}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user