第一轮分析工作暂存
This commit is contained in:
@@ -0,0 +1,755 @@
|
||||
"""
|
||||
DANTE v6 (full-adjustable, no SINDy pretraining)
|
||||
|
||||
Core decisions for this version:
|
||||
1) Disable SINDy prior structure usage entirely.
|
||||
2) Use full simplified basis pool and optimize all controller coefficients.
|
||||
3) Keep minimal loop: no resume/checkpoint restore, live DB save + reward-only TB.
|
||||
4) Continuous rollout objective with protective reset only on done/truncated.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import numpy as np
|
||||
|
||||
os.environ["MKL_THREADING_LAYER"] = "GNU"
|
||||
os.environ["OMP_NUM_THREADS"] = "16"
|
||||
os.environ["MKL_NUM_THREADS"] = "16"
|
||||
|
||||
try:
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
except Exception:
|
||||
SummaryWriter = None
|
||||
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
|
||||
os.chdir(CURRENT_DIR)
|
||||
sys.path.insert(0, os.path.join(ROOT, "DANTE"))
|
||||
|
||||
from dante.obj_functions import ObjectiveFunction
|
||||
from dante.tree_exploration import TreeExploration
|
||||
|
||||
from dante_v6_surrogate_torch import FlowControlSurrogateV6
|
||||
from dante_pinball.env.gym_env_dante_total_force import CustomEnv
|
||||
|
||||
|
||||
N_OBS = 2
|
||||
N_ACT = 3
|
||||
BIAS_SCALE = 1.0
|
||||
COEF_SCALE = 2.0
|
||||
|
||||
# Full simplified basis pool: all terms are trainable in v6.
|
||||
FULL_BASIS_TERMS = [
|
||||
"obs0", "obs1", "dobs0", "dobs1",
|
||||
"sin_obs0", "sin_obs1", "cos_obs0", "cos_obs1",
|
||||
"tanh_obs0", "tanh_obs1",
|
||||
"act0_l1", "act1_l1", "act2_l1",
|
||||
]
|
||||
|
||||
BASIS_PROFILES = {
|
||||
# Compact profile (<20 dims) with derivative + nonlinear terms.
|
||||
"compact_deriv_nl": ["obs0", "obs1", "dobs0", "dobs1", "tanh_obs1"],
|
||||
# Constrained search top performer (<20 dims) with nonlinear + history.
|
||||
"compact_nl_hist": ["obs1", "sin_obs0", "cos_obs0", "act1_l1"],
|
||||
}
|
||||
|
||||
|
||||
# Fixed run configuration (kept in-file for reproducibility and stricter control)
|
||||
V6_CONFIG = {
|
||||
"name": "d1a3o12_250421_forces02_dante_v6",
|
||||
"device_id": 1,
|
||||
"surrogate_gpu_id": 1,
|
||||
"eval_steps": 300,
|
||||
"tail_steps": 100,
|
||||
"startup_steps": 0,
|
||||
"max_recover_resets": 1,
|
||||
"reset_each_candidate": True,
|
||||
"obs_fail_bound": 2.0,
|
||||
"obs_clip_bound": 3.0,
|
||||
"basis_profile": "compact_deriv_nl",
|
||||
# Use d-dependent initialization, then clamp into [min_num_initial, max_num_initial].
|
||||
"num_initial_per_dim": 10,
|
||||
"min_num_initial": 100,
|
||||
"max_num_initial": 240,
|
||||
"samples_per_acq": 18,
|
||||
"max_init_attempts_factor": 2.0,
|
||||
"surrogate_mode": "ensemble", # mlp | cnn | ensemble
|
||||
# Match PPO script budget: 100 learn iterations * 2048 rollout steps
|
||||
"target_cfd_steps": 204800*2,
|
||||
# Keep consistent with surrogate study runs in this repo.
|
||||
"surrogate_epochs": 400,
|
||||
}
|
||||
|
||||
|
||||
class NullWriter:
|
||||
def add_scalar(self, *_args, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class LinearBasisController:
|
||||
def __init__(self, basis_terms: List[str]):
|
||||
self.basis_terms = list(basis_terms)
|
||||
self.num_basis = len(self.basis_terms)
|
||||
self.total_params = N_ACT * (1 + self.num_basis)
|
||||
|
||||
self.params = np.zeros(self.total_params, dtype=np.float64)
|
||||
self.obs_l1 = np.zeros(2, dtype=np.float64)
|
||||
self.prev_action = np.zeros(3, dtype=np.float64)
|
||||
|
||||
def reset_state(self, obs0: np.ndarray) -> None:
|
||||
obs0 = np.asarray(obs0, dtype=np.float64).reshape(-1)
|
||||
if obs0.size < 2:
|
||||
if obs0.size == 1:
|
||||
obs0 = np.array([obs0[0], obs0[0]], dtype=np.float64)
|
||||
else:
|
||||
obs0 = np.zeros(2, dtype=np.float64)
|
||||
self.obs_l1 = obs0[:2].copy()
|
||||
self.prev_action = np.zeros(3, dtype=np.float64)
|
||||
|
||||
def set_params(self, x: np.ndarray) -> None:
|
||||
x = np.asarray(x, dtype=np.float64).reshape(-1)
|
||||
if x.size != self.total_params:
|
||||
raise ValueError(f"controller params mismatch: {x.size} != {self.total_params}")
|
||||
self.params = np.clip(x, -1.0, 1.0)
|
||||
|
||||
def _feature_dict(self, obs: np.ndarray) -> Dict[str, float]:
|
||||
o = np.asarray(obs, dtype=np.float64).reshape(-1)
|
||||
if o.size < 2:
|
||||
if o.size == 1:
|
||||
o = np.array([o[0], o[0]], dtype=np.float64)
|
||||
else:
|
||||
o = np.zeros(2, dtype=np.float64)
|
||||
|
||||
o0, o1 = float(o[0]), float(o[1])
|
||||
o0_l1, o1_l1 = float(self.obs_l1[0]), float(self.obs_l1[1])
|
||||
a0, a1, a2 = float(self.prev_action[0]), float(self.prev_action[1]), float(self.prev_action[2])
|
||||
|
||||
return {
|
||||
"obs0": o0,
|
||||
"obs1": o1,
|
||||
"dobs0": o0 - o0_l1,
|
||||
"dobs1": o1 - o1_l1,
|
||||
"sin_obs0": float(np.sin(np.pi * o0)),
|
||||
"sin_obs1": float(np.sin(np.pi * o1)),
|
||||
"cos_obs0": float(np.cos(np.pi * o0)),
|
||||
"cos_obs1": float(np.cos(np.pi * o1)),
|
||||
"tanh_obs0": float(np.tanh(o0)),
|
||||
"tanh_obs1": float(np.tanh(o1)),
|
||||
"act0_l1": a0,
|
||||
"act1_l1": a1,
|
||||
"act2_l1": a2,
|
||||
}
|
||||
|
||||
def predict(self, obs: np.ndarray) -> np.ndarray:
|
||||
feat = self._feature_dict(obs)
|
||||
out = np.zeros(3, dtype=np.float64)
|
||||
stride = 1 + self.num_basis
|
||||
|
||||
for ch in range(3):
|
||||
off = ch * stride
|
||||
qb = np.tanh(1.25 * self.params[off])
|
||||
y = BIAS_SCALE * qb
|
||||
for k, term in enumerate(self.basis_terms):
|
||||
qk = np.tanh(1.25 * self.params[off + 1 + k])
|
||||
y += (COEF_SCALE * qk) * feat.get(term, 0.0)
|
||||
out[ch] = y
|
||||
|
||||
out = np.clip(out, -1.0, 1.0)
|
||||
|
||||
obs2 = np.asarray(obs, dtype=np.float64).reshape(-1)
|
||||
if obs2.size < 2:
|
||||
if obs2.size == 1:
|
||||
obs2 = np.array([obs2[0], obs2[0]], dtype=np.float64)
|
||||
else:
|
||||
obs2 = np.zeros(2, dtype=np.float64)
|
||||
|
||||
self.obs_l1 = obs2[:2].copy()
|
||||
self.prev_action = out.copy()
|
||||
return out.astype(np.float32)
|
||||
|
||||
|
||||
class FlowControlObjectiveV6(ObjectiveFunction):
|
||||
def __init__(
|
||||
self,
|
||||
basis_terms: List[str],
|
||||
eval_steps: int = 200,
|
||||
tail_steps: int = 100,
|
||||
startup_steps: int = 200,
|
||||
max_recover_resets: int = 1,
|
||||
turn: float = 0.05,
|
||||
reset_each_candidate: bool = True,
|
||||
obs_fail_bound: float = 2.0,
|
||||
obs_clip_bound: float = 3.0,
|
||||
):
|
||||
self.eval_steps = int(eval_steps)
|
||||
self.tail_steps = int(tail_steps)
|
||||
self.startup_steps = int(startup_steps)
|
||||
self.max_recover_resets = int(max_recover_resets)
|
||||
self.turn = float(turn)
|
||||
self.reset_each_candidate = bool(reset_each_candidate)
|
||||
self.obs_fail_bound = float(obs_fail_bound)
|
||||
self.obs_clip_bound = float(obs_clip_bound)
|
||||
|
||||
self.basis_terms = list(basis_terms)
|
||||
self.controller = LinearBasisController(self.basis_terms)
|
||||
self.dims = int(self.controller.total_params)
|
||||
|
||||
self.lb = -1.0 * np.ones(self.dims)
|
||||
self.ub = 1.0 * np.ones(self.dims)
|
||||
|
||||
self.env = None
|
||||
self._device_id = 1
|
||||
self.obs_current = None
|
||||
self.recover_count = 0
|
||||
|
||||
def init_env(self, device_id: int = 1) -> None:
|
||||
self._device_id = int(device_id)
|
||||
if self.env is not None:
|
||||
self.env.close()
|
||||
self.env = CustomEnv(
|
||||
device_id=int(device_id),
|
||||
obs_fail_bound=float(self.obs_fail_bound),
|
||||
obs_clip_bound=float(self.obs_clip_bound),
|
||||
)
|
||||
self._hard_reset_and_stabilize()
|
||||
|
||||
def _hard_reset_and_stabilize(self) -> None:
|
||||
obs, _ = self.env.reset()
|
||||
obs = np.asarray(obs, dtype=np.float32)
|
||||
action = np.zeros(3, dtype=np.float32)
|
||||
|
||||
for _ in range(int(self.startup_steps)):
|
||||
obs, _, done, trunc, _ = self.env.step(action)
|
||||
if done or trunc:
|
||||
obs, _ = self.env.reset()
|
||||
|
||||
self.obs_current = np.asarray(obs, dtype=np.float32)
|
||||
self.controller.reset_state(self.obs_current)
|
||||
|
||||
@staticmethod
|
||||
def _tail_mean(rewards: List[float], tail_steps: int) -> float:
|
||||
rr = np.asarray(rewards, dtype=np.float64)
|
||||
if rr.size == 0:
|
||||
return 0.0
|
||||
tail = rr[-tail_steps:] if rr.size >= tail_steps else rr
|
||||
return float(np.mean(tail))
|
||||
|
||||
def evaluate_candidate(self, x: np.ndarray) -> Dict:
|
||||
x = self._preprocess(x)
|
||||
self.controller.set_params(x)
|
||||
|
||||
if self.env is None:
|
||||
self.init_env(self._device_id)
|
||||
|
||||
# DANTE candidate evaluations are isolated from each other.
|
||||
if self.reset_each_candidate:
|
||||
self._hard_reset_and_stabilize()
|
||||
|
||||
for attempt in range(int(self.max_recover_resets) + 1):
|
||||
obs = np.asarray(self.obs_current, dtype=np.float32)
|
||||
self.controller.reset_state(obs)
|
||||
|
||||
rewards: List[float] = []
|
||||
steps = 0
|
||||
done = False
|
||||
trunc = False
|
||||
last_info: Dict = {}
|
||||
|
||||
for _ in range(int(self.eval_steps)):
|
||||
action = self.controller.predict(obs)
|
||||
obs, reward, done, trunc, step_info = self.env.step(action)
|
||||
if isinstance(step_info, dict):
|
||||
last_info = step_info
|
||||
rewards.append(float(reward))
|
||||
steps += 1
|
||||
if done or trunc:
|
||||
break
|
||||
|
||||
if done or trunc and attempt < int(self.max_recover_resets):
|
||||
self.recover_count += 1
|
||||
self._hard_reset_and_stabilize()
|
||||
continue
|
||||
|
||||
self.obs_current = np.asarray(obs, dtype=np.float32)
|
||||
y = self._tail_mean(rewards, tail_steps=int(self.tail_steps))
|
||||
return {
|
||||
"reward": float(y),
|
||||
"scaled": float(y * 100.0),
|
||||
"steps": int(steps),
|
||||
"done": bool(done),
|
||||
"truncated": bool(trunc),
|
||||
"recoveries_used": int(attempt),
|
||||
"failure_code": int(last_info.get("failure_code", 0)),
|
||||
}
|
||||
|
||||
return {
|
||||
"reward": 0.0,
|
||||
"scaled": 0.0,
|
||||
"steps": 0,
|
||||
"done": True,
|
||||
"truncated": True,
|
||||
"recoveries_used": int(self.max_recover_resets),
|
||||
"failure_code": 1,
|
||||
}
|
||||
|
||||
def scaled(self, y: float) -> float:
|
||||
return float(y * 100.0)
|
||||
|
||||
def __call__(self, x: np.ndarray, apply_scaling: bool = False, track: bool = True) -> float:
|
||||
info = self.evaluate_candidate(x)
|
||||
y = float(info["reward"])
|
||||
return self.scaled(y) if apply_scaling else y
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="DANTE v6 full-adjustable (no SINDy pretraining)")
|
||||
p.add_argument(
|
||||
"--name",
|
||||
type=str,
|
||||
default=V6_CONFIG["name"],
|
||||
help="Optional run name override. Core budgets remain fixed in-file.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--basis-profile",
|
||||
type=str,
|
||||
default=V6_CONFIG["basis_profile"],
|
||||
choices=sorted(BASIS_PROFILES.keys()),
|
||||
help="Controller basis profile to optimize.",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def sample_params(dims: int, turn: float) -> np.ndarray:
|
||||
x = np.random.uniform(-1.0, 1.0, size=dims)
|
||||
x = np.round(x / turn) * turn
|
||||
return np.clip(x, -1.0, 1.0)
|
||||
|
||||
|
||||
def save_live_db(path: str, x: np.ndarray, y: np.ndarray, meta: Dict) -> None:
|
||||
np.savez(path, input_x=x, input_y=y, meta_json=np.array([json.dumps(meta, ensure_ascii=False)]))
|
||||
|
||||
|
||||
def print_progress_line(
|
||||
phase: str,
|
||||
idx: int,
|
||||
total: int,
|
||||
reward: float,
|
||||
best_reward: float,
|
||||
recover_total: int,
|
||||
elapsed_sec: float,
|
||||
global_step: int,
|
||||
total_expected: int,
|
||||
) -> None:
|
||||
print(
|
||||
f"[{phase}] {idx}/{total} | reward={reward:.4f} | best={best_reward:.4f} | "
|
||||
f"recover={recover_total} | eval={global_step}/{total_expected} | elapsed={elapsed_sec:.1f}s",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
|
||||
name = str(args.name)
|
||||
cfg = dict(V6_CONFIG)
|
||||
cfg["name"] = name
|
||||
|
||||
basis_profile = str(args.basis_profile)
|
||||
basis_terms = list(BASIS_PROFILES[basis_profile])
|
||||
|
||||
target_evals = int(cfg["target_cfd_steps"] // cfg["eval_steps"])
|
||||
|
||||
model_dir = os.path.join(ROOT, "models", "250421")
|
||||
out_dir = os.path.join(ROOT, "output")
|
||||
tb_dir = os.path.join(ROOT, "tensorboard", name)
|
||||
os.makedirs(model_dir, exist_ok=True)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
obj = FlowControlObjectiveV6(
|
||||
basis_terms=basis_terms,
|
||||
eval_steps=int(cfg["eval_steps"]),
|
||||
tail_steps=int(cfg["tail_steps"]),
|
||||
startup_steps=int(cfg["startup_steps"]),
|
||||
max_recover_resets=int(cfg["max_recover_resets"]),
|
||||
reset_each_candidate=bool(cfg["reset_each_candidate"]),
|
||||
obs_fail_bound=float(cfg["obs_fail_bound"]),
|
||||
obs_clip_bound=float(cfg["obs_clip_bound"]),
|
||||
)
|
||||
obj.init_env(device_id=int(cfg["device_id"]))
|
||||
|
||||
controller_dims = int(obj.dims)
|
||||
surrogate_gpu_id = int(cfg["surrogate_gpu_id"])
|
||||
num_initial_raw = int(max(1, round(float(cfg["num_initial_per_dim"]) * controller_dims)))
|
||||
num_initial = int(min(int(cfg["max_num_initial"]), max(int(cfg["min_num_initial"]), num_initial_raw)))
|
||||
num_acquisitions = int((target_evals - int(num_initial)) // int(cfg["samples_per_acq"]))
|
||||
if num_acquisitions <= 0:
|
||||
raise RuntimeError(
|
||||
f"computed num_acquisitions <= 0 with target_evals={target_evals}, "
|
||||
f"num_initial={num_initial}, samples_per_acq={int(cfg['samples_per_acq'])}"
|
||||
)
|
||||
max_init_attempts = int(max(num_initial, round(float(cfg["max_init_attempts_factor"]) * num_initial)))
|
||||
if max_init_attempts < num_initial:
|
||||
raise RuntimeError("max_init_attempts must be >= num_initial")
|
||||
total_expected = int(num_initial + num_acquisitions * int(cfg["samples_per_acq"]))
|
||||
|
||||
config_payload = {
|
||||
"name": name,
|
||||
"use_sindy_prior": False,
|
||||
"reason": "forced_disabled_by_v6_full_adjustable",
|
||||
"basis_profile": basis_profile,
|
||||
"basis_terms": basis_terms,
|
||||
"controller_dims": controller_dims,
|
||||
"num_initial": num_initial,
|
||||
"num_initial_raw": int(num_initial_raw),
|
||||
"num_initial_per_dim": float(cfg["num_initial_per_dim"]),
|
||||
"min_num_initial": int(cfg["min_num_initial"]),
|
||||
"max_num_initial": int(cfg["max_num_initial"]),
|
||||
"num_acquisitions": int(num_acquisitions),
|
||||
"samples_per_acq": int(cfg["samples_per_acq"]),
|
||||
"surrogate_mode": str(cfg["surrogate_mode"]),
|
||||
"surrogate_gpu_id": int(surrogate_gpu_id),
|
||||
"eval_steps": int(cfg["eval_steps"]),
|
||||
"tail_steps": int(cfg["tail_steps"]),
|
||||
"startup_steps": int(cfg["startup_steps"]),
|
||||
"max_recover_resets": int(cfg["max_recover_resets"]),
|
||||
"obs_fail_bound": float(cfg["obs_fail_bound"]),
|
||||
"obs_clip_bound": float(cfg["obs_clip_bound"]),
|
||||
"reset_each_candidate": bool(cfg["reset_each_candidate"]),
|
||||
"max_init_attempts": int(max_init_attempts),
|
||||
"max_init_attempts_factor": float(cfg["max_init_attempts_factor"]),
|
||||
"target_cfd_steps": int(cfg["target_cfd_steps"]),
|
||||
"target_total_evals": int(target_evals),
|
||||
"matched_total_evals": int(total_expected),
|
||||
"matched_cfd_steps": int(total_expected * int(cfg["eval_steps"])),
|
||||
}
|
||||
with open(os.path.join(out_dir, f"{name}_structure_decision.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(config_payload, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print("use_sindy_prior:", False)
|
||||
print("basis_profile:", basis_profile)
|
||||
print("basis_terms:", len(basis_terms))
|
||||
print("controller_dims:", controller_dims)
|
||||
print("num_initial:", num_initial)
|
||||
print("num_initial_raw:", int(num_initial_raw))
|
||||
print("num_initial_per_dim:", float(cfg["num_initial_per_dim"]))
|
||||
print("min_num_initial:", int(cfg["min_num_initial"]))
|
||||
print("max_num_initial:", int(cfg["max_num_initial"]))
|
||||
print("max_init_attempts:", max_init_attempts)
|
||||
print("max_init_attempts_factor:", float(cfg["max_init_attempts_factor"]))
|
||||
print("num_acquisitions:", int(num_acquisitions))
|
||||
print("samples_per_acq:", int(cfg["samples_per_acq"]))
|
||||
print("surrogate_mode:", str(cfg["surrogate_mode"]))
|
||||
print("surrogate_gpu_id:", surrogate_gpu_id)
|
||||
print("eval_steps:", int(cfg["eval_steps"]))
|
||||
print("tail_steps:", int(cfg["tail_steps"]))
|
||||
print("startup_steps:", int(cfg["startup_steps"]))
|
||||
print("target_cfd_steps:", int(cfg["target_cfd_steps"]))
|
||||
print("matched_cfd_steps:", int(total_expected * int(cfg["eval_steps"])))
|
||||
print("expected_total_evals:", total_expected)
|
||||
|
||||
ckpt_path = Path(os.path.join(model_dir, f"{name}_surrogate.keras"))
|
||||
surrogate = FlowControlSurrogateV6(
|
||||
input_dims=controller_dims,
|
||||
epochs=int(cfg["surrogate_epochs"]),
|
||||
patience=30,
|
||||
check_point_path=ckpt_path,
|
||||
tf_device_id=int(surrogate_gpu_id),
|
||||
)
|
||||
|
||||
live_db_path = os.path.join(out_dir, f"{name}_database_live.npz")
|
||||
best_params_path = os.path.join(model_dir, f"{name}_best.npy")
|
||||
best_meta_path = os.path.join(out_dir, f"{name}_best_meta.pkl")
|
||||
final_meta_path = os.path.join(out_dir, f"{name}_final_meta.pkl")
|
||||
dante_log_path = os.path.join(out_dir, f"{name}_dante_log.csv")
|
||||
|
||||
if SummaryWriter is None:
|
||||
writer = NullWriter()
|
||||
else:
|
||||
writer = SummaryWriter(log_dir=tb_dir)
|
||||
|
||||
with open(dante_log_path, "w", encoding="utf-8") as f:
|
||||
f.write("timestamp,phase,acq,candidate,reward,best_reward,dataset_size,recoveries_used,failure_code\n")
|
||||
|
||||
input_x = np.empty((0, controller_dims), dtype=np.float64)
|
||||
input_y = np.empty((0,), dtype=np.float64)
|
||||
history = []
|
||||
best_reward = -1.0
|
||||
best_params = np.zeros(controller_dims, dtype=np.float64)
|
||||
invalid_skips = 0
|
||||
|
||||
t0 = time.time()
|
||||
global_step = 0
|
||||
|
||||
try:
|
||||
# Phase 1: random init points (collect exactly num_initial valid samples)
|
||||
valid_init = 0
|
||||
init_attempts = 0
|
||||
while valid_init < num_initial:
|
||||
if init_attempts >= max_init_attempts:
|
||||
raise RuntimeError(
|
||||
f"init failed to collect enough valid samples: "
|
||||
f"valid={valid_init}/{num_initial}, attempts={init_attempts}/{max_init_attempts}, "
|
||||
f"invalid_skips={invalid_skips}"
|
||||
)
|
||||
|
||||
init_attempts += 1
|
||||
x = sample_params(controller_dims, obj.turn)
|
||||
info = obj.evaluate_candidate(x)
|
||||
reward = float(info["reward"])
|
||||
is_valid = int(info.get("failure_code", 0)) == 0
|
||||
|
||||
if is_valid:
|
||||
valid_init += 1
|
||||
input_x = np.vstack((input_x, x.reshape(1, -1)))
|
||||
input_y = np.append(input_y, float(info["scaled"]))
|
||||
writer.add_scalar("Reward", reward, global_step)
|
||||
else:
|
||||
invalid_skips += 1
|
||||
print(
|
||||
f"[init] invalid sample skipped: attempt={init_attempts}, "
|
||||
f"failure_code={info.get('failure_code', -1)}, "
|
||||
f"valid={valid_init}/{num_initial}, invalid_skips={invalid_skips}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if is_valid and reward > best_reward:
|
||||
best_reward = reward
|
||||
best_params = x.copy()
|
||||
np.save(best_params_path, best_params)
|
||||
with open(best_meta_path, "wb") as f:
|
||||
pickle.dump(
|
||||
{
|
||||
"best_reward": best_reward,
|
||||
"best_params": best_params,
|
||||
"basis_terms": basis_terms,
|
||||
"controller_dims": controller_dims,
|
||||
"config": config_payload,
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
save_live_db(
|
||||
live_db_path,
|
||||
input_x,
|
||||
input_y,
|
||||
{
|
||||
"name": name,
|
||||
"best_reward": best_reward,
|
||||
"dataset_size": int(len(input_y)),
|
||||
"basis_terms": basis_terms,
|
||||
"use_sindy_prior": False,
|
||||
"recover_count": int(obj.recover_count),
|
||||
"invalid_skips": int(invalid_skips),
|
||||
},
|
||||
)
|
||||
|
||||
with open(dante_log_path, "a", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"{time.time():.3f},init,0,{init_attempts},{reward:.8f},{best_reward:.8f},{len(input_y)},{info['recoveries_used']},{info.get('failure_code', 0)}\n"
|
||||
)
|
||||
|
||||
global_step += 1
|
||||
print(
|
||||
f"[init-attempt {init_attempts}/{max_init_attempts}] "
|
||||
f"valid={valid_init}/{num_initial} | reward={reward:.4f} | "
|
||||
f"best={best_reward:.4f} | invalid_skips={invalid_skips} | "
|
||||
f"recover={int(obj.recover_count)} | eval={global_step}/{total_expected}+ | "
|
||||
f"elapsed={float(time.time() - t0):.1f}s",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Phase 2: DANTE acquisitions
|
||||
for acq in range(int(num_acquisitions)):
|
||||
if len(input_y) < 2:
|
||||
print(
|
||||
f"[acq {acq + 1}] skipped: insufficient valid samples ({len(input_y)})",
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
print(
|
||||
f"[acq {acq + 1}/{int(num_acquisitions)}] fitting surrogate on {len(input_y)} samples...",
|
||||
flush=True,
|
||||
)
|
||||
surrogate_mode = str(cfg.get("surrogate_mode", "mlp")).lower()
|
||||
if surrogate_mode == "ensemble":
|
||||
candidates = []
|
||||
for arch in ["mlp", "cnn"]:
|
||||
cand_ckpt = Path(str(ckpt_path).replace(".keras", f"_{arch}.keras"))
|
||||
surr = FlowControlSurrogateV6(
|
||||
input_dims=controller_dims,
|
||||
epochs=int(cfg["surrogate_epochs"]),
|
||||
patience=30,
|
||||
check_point_path=cand_ckpt,
|
||||
tf_device_id=int(surrogate_gpu_id),
|
||||
architecture=arch,
|
||||
)
|
||||
try:
|
||||
m = surr(input_x, input_y, verbose=0)
|
||||
fm = dict(getattr(surr, "last_fit_metrics", {}) or {})
|
||||
candidates.append((arch, m, fm, cand_ckpt))
|
||||
except Exception as e:
|
||||
print(f"[acq {acq + 1}] surrogate {arch} failed: {e}", flush=True)
|
||||
if not candidates:
|
||||
raise RuntimeError("all surrogate candidates failed in ensemble mode")
|
||||
candidates.sort(key=lambda z: float(z[2].get("val_r2", -1e9)), reverse=True)
|
||||
best_arch, model, fit_metrics, best_ckpt = candidates[0]
|
||||
if best_ckpt.exists():
|
||||
shutil.copy2(best_ckpt, ckpt_path)
|
||||
fit_metrics["selected_architecture"] = best_arch
|
||||
else:
|
||||
surrogate.architecture = "cnn" if surrogate_mode == "cnn" else "mlp"
|
||||
model = surrogate(input_x, input_y, verbose=0)
|
||||
fit_metrics = dict(getattr(surrogate, "last_fit_metrics", {}) or {})
|
||||
fit_metrics["selected_architecture"] = str(surrogate.architecture)
|
||||
if fit_metrics:
|
||||
writer.add_scalar("Surrogate/val_r2", float(fit_metrics.get("val_r2", 0.0)), acq)
|
||||
writer.add_scalar("Surrogate/val_mae", float(fit_metrics.get("val_mae", 0.0)), acq)
|
||||
print(
|
||||
f"[acq {acq + 1}] surrogate metrics: "
|
||||
f"val_r2={fit_metrics.get('val_r2', float('nan')):.4f}, "
|
||||
f"val_mae={fit_metrics.get('val_mae', float('nan')):.4f}, "
|
||||
f"device={fit_metrics.get('device', 'CPU')}",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
f"[acq {acq + 1}/{int(num_acquisitions)}] surrogate fit done, rolling out {int(cfg['samples_per_acq'])} candidates...",
|
||||
flush=True,
|
||||
)
|
||||
if ckpt_path.exists():
|
||||
shutil.copy2(ckpt_path, os.path.join(model_dir, f"{name}_surrogate_live.keras"))
|
||||
|
||||
explorer = TreeExploration(
|
||||
func=obj,
|
||||
model=model,
|
||||
num_samples_per_acquisition=int(cfg["samples_per_acq"]),
|
||||
)
|
||||
candidates = explorer.rollout(input_x, input_y, iteration=acq)
|
||||
|
||||
acq_rewards = []
|
||||
for j, x in enumerate(candidates):
|
||||
info = obj.evaluate_candidate(x)
|
||||
reward = float(info["reward"])
|
||||
is_valid = int(info.get("failure_code", 0)) == 0
|
||||
if is_valid:
|
||||
acq_rewards.append(reward)
|
||||
|
||||
input_x = np.vstack((input_x, np.asarray(x, dtype=np.float64).reshape(1, -1)))
|
||||
input_y = np.append(input_y, float(info["scaled"]))
|
||||
writer.add_scalar("Reward", reward, global_step)
|
||||
|
||||
if reward > best_reward:
|
||||
best_reward = reward
|
||||
best_params = np.asarray(x, dtype=np.float64).copy()
|
||||
np.save(best_params_path, best_params)
|
||||
with open(best_meta_path, "wb") as f:
|
||||
pickle.dump(
|
||||
{
|
||||
"best_reward": best_reward,
|
||||
"best_params": best_params,
|
||||
"basis_terms": basis_terms,
|
||||
"controller_dims": controller_dims,
|
||||
"config": config_payload,
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
save_live_db(
|
||||
live_db_path,
|
||||
input_x,
|
||||
input_y,
|
||||
{
|
||||
"name": name,
|
||||
"best_reward": best_reward,
|
||||
"dataset_size": int(len(input_y)),
|
||||
"basis_terms": basis_terms,
|
||||
"use_sindy_prior": False,
|
||||
"recover_count": int(obj.recover_count),
|
||||
"acq": int(acq + 1),
|
||||
"invalid_skips": int(invalid_skips),
|
||||
},
|
||||
)
|
||||
else:
|
||||
invalid_skips += 1
|
||||
print(
|
||||
f"[acq {acq + 1}] invalid sample skipped: cand={j + 1}, failure_code={info.get('failure_code', -1)}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
with open(dante_log_path, "a", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"{time.time():.3f},acq,{acq + 1},{j + 1},{reward:.8f},{best_reward:.8f},{len(input_y)},{info['recoveries_used']},{info.get('failure_code', 0)}\n"
|
||||
)
|
||||
|
||||
global_step += 1
|
||||
print_progress_line(
|
||||
phase=f"acq{acq + 1}",
|
||||
idx=j + 1,
|
||||
total=len(candidates),
|
||||
reward=reward,
|
||||
best_reward=best_reward,
|
||||
recover_total=int(obj.recover_count),
|
||||
elapsed_sec=float(time.time() - t0),
|
||||
global_step=global_step,
|
||||
total_expected=total_expected,
|
||||
)
|
||||
|
||||
history.append(
|
||||
{
|
||||
"iteration": int(acq + 1),
|
||||
"new_mean": float(np.mean(acq_rewards) if acq_rewards else 0.0),
|
||||
"new_max": float(np.max(acq_rewards) if acq_rewards else 0.0),
|
||||
"best_reward": float(best_reward),
|
||||
"dataset_size": int(len(input_y)),
|
||||
"recover_total": int(obj.recover_count),
|
||||
}
|
||||
)
|
||||
|
||||
print(
|
||||
f"acq {acq + 1}/{num_acquisitions}: mean={history[-1]['new_mean']:.4f}, "
|
||||
f"max={history[-1]['new_max']:.4f}, best={best_reward:.4f}, recover_total={obj.recover_count}"
|
||||
)
|
||||
|
||||
with open(final_meta_path, "wb") as f:
|
||||
pickle.dump(
|
||||
{
|
||||
"name": name,
|
||||
"best_reward": best_reward,
|
||||
"best_params": best_params,
|
||||
"basis_terms": basis_terms,
|
||||
"controller_dims": controller_dims,
|
||||
"history": history,
|
||||
"elapsed_sec": float(time.time() - t0),
|
||||
"config": config_payload,
|
||||
"recover_total": int(obj.recover_count),
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
print("Training done")
|
||||
print("best_reward:", f"{best_reward:.4f}")
|
||||
print("recover_total:", obj.recover_count)
|
||||
print("invalid_skips:", invalid_skips)
|
||||
|
||||
finally:
|
||||
writer.close()
|
||||
if obj.env is not None:
|
||||
obj.env.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,911 @@
|
||||
"""
|
||||
DANTE v7 (open-loop periodic control)
|
||||
|
||||
This version switches from closed-loop basis feedback to a periodic open-loop controller:
|
||||
1) Optimize control points of one cycle directly.
|
||||
2) Use continuous phase advance to support non-integer cycle lengths.
|
||||
3) Keep v6-compatible evaluation protocol (300-step rollout, tail100 score).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
os.environ["MKL_THREADING_LAYER"] = "GNU"
|
||||
os.environ["OMP_NUM_THREADS"] = "16"
|
||||
os.environ["MKL_NUM_THREADS"] = "16"
|
||||
|
||||
try:
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
except Exception:
|
||||
SummaryWriter = None
|
||||
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
|
||||
os.chdir(CURRENT_DIR)
|
||||
sys.path.insert(0, os.path.join(ROOT, "DANTE"))
|
||||
|
||||
from dante.obj_functions import ObjectiveFunction
|
||||
from dante.tree_exploration import TreeExploration
|
||||
|
||||
from dante_v6_surrogate_torch import FlowControlSurrogateV6
|
||||
from dante_pinball.env.gym_env_dante_total_force import CustomEnv
|
||||
|
||||
|
||||
N_ACT = 3
|
||||
|
||||
V7_CONFIG = {
|
||||
"name": "d1a3o12_250421_forces02_dante_v7_openloop",
|
||||
"device_id": 0,
|
||||
"surrogate_gpu_id": 0,
|
||||
"eval_steps": 300,
|
||||
"tail_steps": 100,
|
||||
"startup_steps": 0,
|
||||
"max_recover_resets": 1,
|
||||
"reset_each_candidate": True,
|
||||
"obs_fail_bound": 2.0,
|
||||
"obs_clip_bound": 3.0,
|
||||
"control_points_per_channel": 8,
|
||||
"phase_interp": "linear",
|
||||
"period_mode": "auto", # auto | fixed | optimize
|
||||
"fixed_period": 40.0,
|
||||
"period_min": 15.0,
|
||||
"period_max": 80.0,
|
||||
"period_estimate_files": [
|
||||
"output/report_dante_v2_v5_v6/raw_oldenv_seed_11.npz",
|
||||
"output/report_dante_v2_v5_v6/raw_oldenv_seed_29.npz",
|
||||
"output/report_dante_v2_v5_v6/raw_oldenv_seed_47.npz",
|
||||
],
|
||||
"period_estimate_key": "ppo_actions",
|
||||
"num_initial_per_dim": 10,
|
||||
"min_num_initial": 100,
|
||||
"max_num_initial": 320,
|
||||
"samples_per_acq": 24,
|
||||
"max_init_attempts_factor": 2.0,
|
||||
"surrogate_mode": "ensemble", # mlp | cnn | ensemble
|
||||
"target_cfd_steps": 204800*2,
|
||||
"surrogate_epochs": 400,
|
||||
}
|
||||
|
||||
|
||||
class NullWriter:
|
||||
def add_scalar(self, *_args, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def map_unit_to_range(u: float, low: float, high: float) -> float:
|
||||
u_clip = float(np.clip(u, -1.0, 1.0))
|
||||
alpha = 0.5 * (u_clip + 1.0)
|
||||
return float(low + alpha * (high - low))
|
||||
|
||||
|
||||
def map_range_to_unit(x: float, low: float, high: float) -> float:
|
||||
if high <= low:
|
||||
return 0.0
|
||||
alpha = (float(x) - low) / (high - low)
|
||||
return float(np.clip(2.0 * alpha - 1.0, -1.0, 1.0))
|
||||
|
||||
|
||||
def dominant_period_fft(x: np.ndarray, min_period: float, max_period: float) -> Optional[float]:
|
||||
x = np.asarray(x, dtype=np.float64).reshape(-1)
|
||||
n = int(x.size)
|
||||
if n < 16:
|
||||
return None
|
||||
|
||||
xc = x - np.mean(x)
|
||||
std = float(np.std(xc))
|
||||
if std < 1e-8:
|
||||
return None
|
||||
|
||||
yf = np.fft.rfft(xc)
|
||||
power = (np.abs(yf) ** 2).reshape(-1)
|
||||
freq = np.fft.rfftfreq(n, d=1.0)
|
||||
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
period = np.where(freq > 0.0, 1.0 / freq, np.inf)
|
||||
|
||||
valid = (freq > 0.0) & (period >= float(min_period)) & (period <= float(max_period))
|
||||
if not np.any(valid):
|
||||
return None
|
||||
|
||||
idx = np.argmax(power * valid.astype(np.float64))
|
||||
f_peak = float(freq[idx])
|
||||
if f_peak <= 0.0:
|
||||
return None
|
||||
return float(1.0 / f_peak)
|
||||
|
||||
|
||||
def load_action_array_from_npz(npz_path: str, key_hint: str = "ppo_actions") -> np.ndarray:
|
||||
with np.load(npz_path) as z:
|
||||
keys = list(z.keys())
|
||||
if key_hint in z:
|
||||
arr = z[key_hint]
|
||||
return np.asarray(arr, dtype=np.float64)
|
||||
|
||||
for k in keys:
|
||||
kl = k.lower()
|
||||
if "ppo" in kl and "action" in kl:
|
||||
arr = z[k]
|
||||
return np.asarray(arr, dtype=np.float64)
|
||||
|
||||
for k in keys:
|
||||
arr = np.asarray(z[k])
|
||||
if arr.ndim == 2 and arr.shape[1] == N_ACT:
|
||||
return np.asarray(arr, dtype=np.float64)
|
||||
|
||||
raise KeyError(f"no action array found in {npz_path}")
|
||||
|
||||
|
||||
def estimate_period_from_npz_list(
|
||||
rel_paths: List[str],
|
||||
key_hint: str,
|
||||
min_period: float,
|
||||
max_period: float,
|
||||
) -> Dict[str, object]:
|
||||
periods: List[float] = []
|
||||
details: List[Dict[str, object]] = []
|
||||
|
||||
for rel in rel_paths:
|
||||
abs_path = os.path.join(ROOT, rel)
|
||||
if not os.path.exists(abs_path):
|
||||
details.append({"file": rel, "used": False, "reason": "missing"})
|
||||
continue
|
||||
|
||||
try:
|
||||
a = load_action_array_from_npz(abs_path, key_hint=key_hint)
|
||||
if a.ndim != 2 or a.shape[1] != N_ACT:
|
||||
details.append({
|
||||
"file": rel,
|
||||
"used": False,
|
||||
"reason": f"invalid_shape_{tuple(a.shape)}",
|
||||
})
|
||||
continue
|
||||
|
||||
file_periods = []
|
||||
for ch in range(N_ACT):
|
||||
p = dominant_period_fft(
|
||||
a[:, ch],
|
||||
min_period=float(min_period),
|
||||
max_period=float(max_period),
|
||||
)
|
||||
if p is not None and np.isfinite(p):
|
||||
file_periods.append(float(p))
|
||||
periods.append(float(p))
|
||||
|
||||
details.append({
|
||||
"file": rel,
|
||||
"used": len(file_periods) > 0,
|
||||
"num_channels": int(len(file_periods)),
|
||||
"channel_periods": [float(v) for v in file_periods],
|
||||
})
|
||||
except Exception as e:
|
||||
details.append({"file": rel, "used": False, "reason": str(e)})
|
||||
|
||||
if len(periods) == 0:
|
||||
return {
|
||||
"period": None,
|
||||
"source": "none",
|
||||
"details": details,
|
||||
"count": 0,
|
||||
}
|
||||
|
||||
period_med = float(np.median(np.asarray(periods, dtype=np.float64)))
|
||||
period_med = float(np.clip(period_med, min_period, max_period))
|
||||
|
||||
return {
|
||||
"period": period_med,
|
||||
"source": "fft_median",
|
||||
"details": details,
|
||||
"count": int(len(periods)),
|
||||
"all_periods": [float(v) for v in periods],
|
||||
}
|
||||
|
||||
|
||||
class PeriodicOpenLoopController:
|
||||
def __init__(
|
||||
self,
|
||||
control_points_per_channel: int,
|
||||
period_mode: str,
|
||||
base_period_steps: float,
|
||||
period_min: float,
|
||||
period_max: float,
|
||||
interp: str = "linear",
|
||||
):
|
||||
self.k = int(control_points_per_channel)
|
||||
if self.k < 3:
|
||||
raise ValueError("control_points_per_channel must be >= 3")
|
||||
|
||||
self.period_mode = str(period_mode).lower()
|
||||
if self.period_mode not in {"auto", "fixed", "optimize"}:
|
||||
raise ValueError("period_mode must be one of auto|fixed|optimize")
|
||||
|
||||
self.base_period_steps = float(base_period_steps)
|
||||
self.period_min = float(period_min)
|
||||
self.period_max = float(period_max)
|
||||
self.interp = str(interp).lower()
|
||||
if self.interp not in {"linear"}:
|
||||
raise ValueError("only linear interpolation is currently supported")
|
||||
|
||||
self.optimize_period = self.period_mode == "optimize"
|
||||
|
||||
self.ctrl_points = np.zeros((N_ACT, self.k), dtype=np.float64)
|
||||
self.phase = 0.0
|
||||
self.current_period_steps = float(np.clip(self.base_period_steps, self.period_min, self.period_max))
|
||||
|
||||
self.total_params = int(N_ACT * self.k + (1 if self.optimize_period else 0))
|
||||
|
||||
def reset_state(self) -> None:
|
||||
self.phase = 0.0
|
||||
|
||||
def _eval_channel(self, points: np.ndarray, phase: float) -> float:
|
||||
p = np.asarray(points, dtype=np.float64).reshape(-1)
|
||||
z = float(np.mod(phase, 1.0)) * self.k
|
||||
i0 = int(np.floor(z)) % self.k
|
||||
frac = float(z - np.floor(z))
|
||||
i1 = (i0 + 1) % self.k
|
||||
return float((1.0 - frac) * p[i0] + frac * p[i1])
|
||||
|
||||
def set_params(self, x: np.ndarray) -> None:
|
||||
x = np.asarray(x, dtype=np.float64).reshape(-1)
|
||||
if x.size != self.total_params:
|
||||
raise ValueError(f"controller params mismatch: {x.size} != {self.total_params}")
|
||||
|
||||
core = np.clip(x[: N_ACT * self.k], -1.0, 1.0)
|
||||
self.ctrl_points = core.reshape(N_ACT, self.k)
|
||||
|
||||
if self.optimize_period:
|
||||
p_unit = float(x[-1])
|
||||
self.current_period_steps = map_unit_to_range(p_unit, self.period_min, self.period_max)
|
||||
else:
|
||||
self.current_period_steps = float(np.clip(self.base_period_steps, self.period_min, self.period_max))
|
||||
|
||||
def predict(self, _obs: np.ndarray) -> np.ndarray:
|
||||
action = np.zeros(N_ACT, dtype=np.float64)
|
||||
for ch in range(N_ACT):
|
||||
action[ch] = self._eval_channel(self.ctrl_points[ch], self.phase)
|
||||
|
||||
action = np.clip(action, -1.0, 1.0)
|
||||
|
||||
step_phase = 1.0 / max(1e-6, float(self.current_period_steps))
|
||||
self.phase = float((self.phase + step_phase) % 1.0)
|
||||
|
||||
return action.astype(np.float32)
|
||||
|
||||
|
||||
class FlowControlObjectiveV7(ObjectiveFunction):
|
||||
def __init__(
|
||||
self,
|
||||
control_points_per_channel: int,
|
||||
period_mode: str,
|
||||
period_steps: float,
|
||||
period_min: float,
|
||||
period_max: float,
|
||||
phase_interp: str = "linear",
|
||||
eval_steps: int = 300,
|
||||
tail_steps: int = 100,
|
||||
startup_steps: int = 0,
|
||||
max_recover_resets: int = 1,
|
||||
turn: float = 0.05,
|
||||
reset_each_candidate: bool = True,
|
||||
obs_fail_bound: float = 2.0,
|
||||
obs_clip_bound: float = 3.0,
|
||||
):
|
||||
self.eval_steps = int(eval_steps)
|
||||
self.tail_steps = int(tail_steps)
|
||||
self.startup_steps = int(startup_steps)
|
||||
self.max_recover_resets = int(max_recover_resets)
|
||||
self.turn = float(turn)
|
||||
self.reset_each_candidate = bool(reset_each_candidate)
|
||||
self.obs_fail_bound = float(obs_fail_bound)
|
||||
self.obs_clip_bound = float(obs_clip_bound)
|
||||
|
||||
self.control_points_per_channel = int(control_points_per_channel)
|
||||
self.period_mode = str(period_mode)
|
||||
self.period_steps = float(period_steps)
|
||||
self.period_min = float(period_min)
|
||||
self.period_max = float(period_max)
|
||||
self.phase_interp = str(phase_interp)
|
||||
|
||||
self.controller = PeriodicOpenLoopController(
|
||||
control_points_per_channel=self.control_points_per_channel,
|
||||
period_mode=self.period_mode,
|
||||
base_period_steps=self.period_steps,
|
||||
period_min=self.period_min,
|
||||
period_max=self.period_max,
|
||||
interp=self.phase_interp,
|
||||
)
|
||||
|
||||
self.dims = int(self.controller.total_params)
|
||||
self.lb = -1.0 * np.ones(self.dims)
|
||||
self.ub = 1.0 * np.ones(self.dims)
|
||||
|
||||
self.env = None
|
||||
self._device_id = 0
|
||||
self.obs_current = None
|
||||
self.recover_count = 0
|
||||
|
||||
def init_env(self, device_id: int = 0) -> None:
|
||||
self._device_id = int(device_id)
|
||||
if self.env is not None:
|
||||
self.env.close()
|
||||
self.env = CustomEnv(
|
||||
device_id=int(device_id),
|
||||
obs_fail_bound=float(self.obs_fail_bound),
|
||||
obs_clip_bound=float(self.obs_clip_bound),
|
||||
)
|
||||
self._hard_reset_and_stabilize()
|
||||
|
||||
def _hard_reset_and_stabilize(self) -> None:
|
||||
obs, _ = self.env.reset()
|
||||
obs = np.asarray(obs, dtype=np.float32)
|
||||
action = np.zeros(N_ACT, dtype=np.float32)
|
||||
|
||||
for _ in range(int(self.startup_steps)):
|
||||
obs, _, done, trunc, _ = self.env.step(action)
|
||||
if done or trunc:
|
||||
obs, _ = self.env.reset()
|
||||
|
||||
self.obs_current = np.asarray(obs, dtype=np.float32)
|
||||
self.controller.reset_state()
|
||||
|
||||
@staticmethod
|
||||
def _tail_mean(rewards: List[float], tail_steps: int) -> float:
|
||||
rr = np.asarray(rewards, dtype=np.float64)
|
||||
if rr.size == 0:
|
||||
return 0.0
|
||||
tail = rr[-tail_steps:] if rr.size >= tail_steps else rr
|
||||
return float(np.mean(tail))
|
||||
|
||||
def evaluate_candidate(self, x: np.ndarray) -> Dict[str, object]:
|
||||
x = self._preprocess(x)
|
||||
self.controller.set_params(x)
|
||||
|
||||
if self.env is None:
|
||||
self.init_env(self._device_id)
|
||||
|
||||
if self.reset_each_candidate:
|
||||
self._hard_reset_and_stabilize()
|
||||
|
||||
for attempt in range(int(self.max_recover_resets) + 1):
|
||||
obs = np.asarray(self.obs_current, dtype=np.float32)
|
||||
self.controller.reset_state()
|
||||
|
||||
rewards: List[float] = []
|
||||
steps = 0
|
||||
done = False
|
||||
trunc = False
|
||||
last_info: Dict[str, object] = {}
|
||||
|
||||
for _ in range(int(self.eval_steps)):
|
||||
action = self.controller.predict(obs)
|
||||
obs, reward, done, trunc, step_info = self.env.step(action)
|
||||
if isinstance(step_info, dict):
|
||||
last_info = step_info
|
||||
rewards.append(float(reward))
|
||||
steps += 1
|
||||
if done or trunc:
|
||||
break
|
||||
|
||||
if done or trunc and attempt < int(self.max_recover_resets):
|
||||
self.recover_count += 1
|
||||
self._hard_reset_and_stabilize()
|
||||
continue
|
||||
|
||||
self.obs_current = np.asarray(obs, dtype=np.float32)
|
||||
y = self._tail_mean(rewards, tail_steps=int(self.tail_steps))
|
||||
return {
|
||||
"reward": float(y),
|
||||
"scaled": float(y * 100.0),
|
||||
"steps": int(steps),
|
||||
"done": bool(done),
|
||||
"truncated": bool(trunc),
|
||||
"recoveries_used": int(attempt),
|
||||
"failure_code": int(last_info.get("failure_code", 0)),
|
||||
"period_steps": float(self.controller.current_period_steps),
|
||||
}
|
||||
|
||||
return {
|
||||
"reward": 0.0,
|
||||
"scaled": 0.0,
|
||||
"steps": 0,
|
||||
"done": True,
|
||||
"truncated": True,
|
||||
"recoveries_used": int(self.max_recover_resets),
|
||||
"failure_code": 1,
|
||||
"period_steps": float(self.controller.current_period_steps),
|
||||
}
|
||||
|
||||
def scaled(self, y: float) -> float:
|
||||
return float(y * 100.0)
|
||||
|
||||
def __call__(self, x: np.ndarray, apply_scaling: bool = False, track: bool = True) -> float:
|
||||
info = self.evaluate_candidate(x)
|
||||
y = float(info["reward"])
|
||||
return self.scaled(y) if apply_scaling else y
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="DANTE v7 open-loop periodic controller")
|
||||
p.add_argument("--name", type=str, default=V7_CONFIG["name"], help="Optional run name override")
|
||||
p.add_argument(
|
||||
"--control-points",
|
||||
type=int,
|
||||
default=V7_CONFIG["control_points_per_channel"],
|
||||
help="Control points per channel for one cycle",
|
||||
)
|
||||
p.add_argument(
|
||||
"--period-mode",
|
||||
type=str,
|
||||
default=V7_CONFIG["period_mode"],
|
||||
choices=["auto", "fixed", "optimize"],
|
||||
help="Cycle period mode",
|
||||
)
|
||||
p.add_argument("--fixed-period", type=float, default=V7_CONFIG["fixed_period"], help="Fixed period in steps")
|
||||
p.add_argument("--period-min", type=float, default=V7_CONFIG["period_min"], help="Min period for clipping")
|
||||
p.add_argument("--period-max", type=float, default=V7_CONFIG["period_max"], help="Max period for clipping")
|
||||
p.add_argument(
|
||||
"--phase-interp",
|
||||
type=str,
|
||||
default=V7_CONFIG["phase_interp"],
|
||||
choices=["linear"],
|
||||
help="Interpolation mode for phase to control point",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def sample_params(dims: int, turn: float, period_mode: str, period_guess: float, pmin: float, pmax: float) -> np.ndarray:
|
||||
x = np.random.uniform(-1.0, 1.0, size=dims)
|
||||
x = np.round(x / turn) * turn
|
||||
x = np.clip(x, -1.0, 1.0)
|
||||
|
||||
if str(period_mode).lower() == "optimize":
|
||||
x[-1] = map_range_to_unit(period_guess, pmin, pmax)
|
||||
return x
|
||||
|
||||
|
||||
def save_live_db(path: str, x: np.ndarray, y: np.ndarray, meta: Dict[str, object]) -> None:
|
||||
np.savez(path, input_x=x, input_y=y, meta_json=np.array([json.dumps(meta, ensure_ascii=False)]))
|
||||
|
||||
|
||||
def print_progress_line(
|
||||
phase: str,
|
||||
idx: int,
|
||||
total: int,
|
||||
reward: float,
|
||||
best_reward: float,
|
||||
recover_total: int,
|
||||
elapsed_sec: float,
|
||||
global_step: int,
|
||||
total_expected: int,
|
||||
period_steps: float,
|
||||
) -> None:
|
||||
print(
|
||||
f"[{phase}] {idx}/{total} | reward={reward:.4f} | best={best_reward:.4f} | "
|
||||
f"period={period_steps:.3f} | recover={recover_total} | eval={global_step}/{total_expected} | elapsed={elapsed_sec:.1f}s",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
|
||||
cfg = dict(V7_CONFIG)
|
||||
cfg["name"] = str(args.name)
|
||||
cfg["control_points_per_channel"] = int(args.control_points)
|
||||
cfg["period_mode"] = str(args.period_mode)
|
||||
cfg["fixed_period"] = float(args.fixed_period)
|
||||
cfg["period_min"] = float(args.period_min)
|
||||
cfg["period_max"] = float(args.period_max)
|
||||
cfg["phase_interp"] = str(args.phase_interp)
|
||||
|
||||
period_info = estimate_period_from_npz_list(
|
||||
rel_paths=list(cfg["period_estimate_files"]),
|
||||
key_hint=str(cfg["period_estimate_key"]),
|
||||
min_period=float(cfg["period_min"]),
|
||||
max_period=float(cfg["period_max"]),
|
||||
)
|
||||
|
||||
if str(cfg["period_mode"]).lower() == "fixed":
|
||||
period_steps = float(np.clip(cfg["fixed_period"], cfg["period_min"], cfg["period_max"]))
|
||||
period_source = "fixed"
|
||||
else:
|
||||
p_est = period_info.get("period", None)
|
||||
if p_est is None:
|
||||
period_steps = float(np.clip(cfg["fixed_period"], cfg["period_min"], cfg["period_max"]))
|
||||
period_source = "fallback_fixed_no_estimate"
|
||||
else:
|
||||
period_steps = float(np.clip(float(p_est), cfg["period_min"], cfg["period_max"]))
|
||||
period_source = "auto_from_ppo_fft"
|
||||
|
||||
target_evals = int(cfg["target_cfd_steps"] // cfg["eval_steps"])
|
||||
|
||||
obj = FlowControlObjectiveV7(
|
||||
control_points_per_channel=int(cfg["control_points_per_channel"]),
|
||||
period_mode=str(cfg["period_mode"]),
|
||||
period_steps=float(period_steps),
|
||||
period_min=float(cfg["period_min"]),
|
||||
period_max=float(cfg["period_max"]),
|
||||
phase_interp=str(cfg["phase_interp"]),
|
||||
eval_steps=int(cfg["eval_steps"]),
|
||||
tail_steps=int(cfg["tail_steps"]),
|
||||
startup_steps=int(cfg["startup_steps"]),
|
||||
max_recover_resets=int(cfg["max_recover_resets"]),
|
||||
reset_each_candidate=bool(cfg["reset_each_candidate"]),
|
||||
obs_fail_bound=float(cfg["obs_fail_bound"]),
|
||||
obs_clip_bound=float(cfg["obs_clip_bound"]),
|
||||
)
|
||||
obj.init_env(device_id=int(cfg["device_id"]))
|
||||
|
||||
dims = int(obj.dims)
|
||||
num_initial_raw = int(max(1, round(float(cfg["num_initial_per_dim"]) * dims)))
|
||||
num_initial = int(min(int(cfg["max_num_initial"]), max(int(cfg["min_num_initial"]), num_initial_raw)))
|
||||
num_acquisitions = int((target_evals - int(num_initial)) // int(cfg["samples_per_acq"]))
|
||||
if num_acquisitions <= 0:
|
||||
raise RuntimeError(
|
||||
f"computed num_acquisitions <= 0 with target_evals={target_evals}, "
|
||||
f"num_initial={num_initial}, samples_per_acq={int(cfg['samples_per_acq'])}"
|
||||
)
|
||||
|
||||
max_init_attempts = int(max(num_initial, round(float(cfg["max_init_attempts_factor"]) * num_initial)))
|
||||
total_expected = int(num_initial + num_acquisitions * int(cfg["samples_per_acq"]))
|
||||
|
||||
name = str(cfg["name"])
|
||||
model_dir = os.path.join(ROOT, "models", "250421")
|
||||
out_dir = os.path.join(ROOT, "output")
|
||||
tb_dir = os.path.join(ROOT, "tensorboard", name)
|
||||
os.makedirs(model_dir, exist_ok=True)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
config_payload = {
|
||||
"name": name,
|
||||
"control_mode": "open_loop_periodic",
|
||||
"device_id": int(cfg["device_id"]),
|
||||
"surrogate_gpu_id": int(cfg["surrogate_gpu_id"]),
|
||||
"control_points_per_channel": int(cfg["control_points_per_channel"]),
|
||||
"controller_dims": int(dims),
|
||||
"period_mode": str(cfg["period_mode"]),
|
||||
"period_steps": float(period_steps),
|
||||
"period_source": period_source,
|
||||
"period_min": float(cfg["period_min"]),
|
||||
"period_max": float(cfg["period_max"]),
|
||||
"period_estimate": period_info,
|
||||
"phase_interp": str(cfg["phase_interp"]),
|
||||
"num_initial": int(num_initial),
|
||||
"num_initial_raw": int(num_initial_raw),
|
||||
"num_initial_per_dim": float(cfg["num_initial_per_dim"]),
|
||||
"min_num_initial": int(cfg["min_num_initial"]),
|
||||
"max_num_initial": int(cfg["max_num_initial"]),
|
||||
"num_acquisitions": int(num_acquisitions),
|
||||
"samples_per_acq": int(cfg["samples_per_acq"]),
|
||||
"surrogate_mode": str(cfg["surrogate_mode"]),
|
||||
"eval_steps": int(cfg["eval_steps"]),
|
||||
"tail_steps": int(cfg["tail_steps"]),
|
||||
"startup_steps": int(cfg["startup_steps"]),
|
||||
"max_recover_resets": int(cfg["max_recover_resets"]),
|
||||
"obs_fail_bound": float(cfg["obs_fail_bound"]),
|
||||
"obs_clip_bound": float(cfg["obs_clip_bound"]),
|
||||
"reset_each_candidate": bool(cfg["reset_each_candidate"]),
|
||||
"max_init_attempts": int(max_init_attempts),
|
||||
"max_init_attempts_factor": float(cfg["max_init_attempts_factor"]),
|
||||
"target_cfd_steps": int(cfg["target_cfd_steps"]),
|
||||
"target_total_evals": int(target_evals),
|
||||
"matched_total_evals": int(total_expected),
|
||||
"matched_cfd_steps": int(total_expected * int(cfg["eval_steps"])),
|
||||
}
|
||||
|
||||
with open(os.path.join(out_dir, f"{name}_structure_decision.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(config_payload, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print("control_mode:", "open_loop_periodic")
|
||||
print("device_id:", int(cfg["device_id"]))
|
||||
print("surrogate_gpu_id:", int(cfg["surrogate_gpu_id"]))
|
||||
print("control_points_per_channel:", int(cfg["control_points_per_channel"]))
|
||||
print("controller_dims:", int(dims))
|
||||
print("period_mode:", str(cfg["period_mode"]))
|
||||
print("period_steps:", float(period_steps))
|
||||
print("period_source:", period_source)
|
||||
print("num_initial:", int(num_initial))
|
||||
print("num_initial_raw:", int(num_initial_raw))
|
||||
print("num_acquisitions:", int(num_acquisitions))
|
||||
print("samples_per_acq:", int(cfg["samples_per_acq"]))
|
||||
print("target_cfd_steps:", int(cfg["target_cfd_steps"]))
|
||||
print("matched_cfd_steps:", int(total_expected * int(cfg["eval_steps"])))
|
||||
|
||||
ckpt_path = Path(os.path.join(model_dir, f"{name}_surrogate.keras"))
|
||||
surrogate = FlowControlSurrogateV6(
|
||||
input_dims=dims,
|
||||
epochs=int(cfg["surrogate_epochs"]),
|
||||
patience=30,
|
||||
check_point_path=ckpt_path,
|
||||
tf_device_id=int(cfg["surrogate_gpu_id"]),
|
||||
)
|
||||
|
||||
live_db_path = os.path.join(out_dir, f"{name}_database_live.npz")
|
||||
best_params_path = os.path.join(model_dir, f"{name}_best.npy")
|
||||
best_meta_path = os.path.join(out_dir, f"{name}_best_meta.pkl")
|
||||
final_meta_path = os.path.join(out_dir, f"{name}_final_meta.pkl")
|
||||
dante_log_path = os.path.join(out_dir, f"{name}_dante_log.csv")
|
||||
|
||||
writer = NullWriter() if SummaryWriter is None else SummaryWriter(log_dir=tb_dir)
|
||||
|
||||
with open(dante_log_path, "w", encoding="utf-8") as f:
|
||||
f.write("timestamp,phase,acq,candidate,reward,best_reward,dataset_size,recoveries_used,failure_code,period_steps\n")
|
||||
|
||||
input_x = np.empty((0, dims), dtype=np.float64)
|
||||
input_y = np.empty((0,), dtype=np.float64)
|
||||
history: List[Dict[str, object]] = []
|
||||
best_reward = -1.0
|
||||
best_params = np.zeros(dims, dtype=np.float64)
|
||||
invalid_skips = 0
|
||||
|
||||
t0 = time.time()
|
||||
global_step = 0
|
||||
|
||||
try:
|
||||
valid_init = 0
|
||||
init_attempts = 0
|
||||
while valid_init < num_initial:
|
||||
if init_attempts >= max_init_attempts:
|
||||
raise RuntimeError(
|
||||
f"init failed to collect enough valid samples: valid={valid_init}/{num_initial}, "
|
||||
f"attempts={init_attempts}/{max_init_attempts}, invalid_skips={invalid_skips}"
|
||||
)
|
||||
|
||||
init_attempts += 1
|
||||
x = sample_params(
|
||||
dims=dims,
|
||||
turn=obj.turn,
|
||||
period_mode=str(cfg["period_mode"]),
|
||||
period_guess=float(period_steps),
|
||||
pmin=float(cfg["period_min"]),
|
||||
pmax=float(cfg["period_max"]),
|
||||
)
|
||||
|
||||
info = obj.evaluate_candidate(x)
|
||||
reward = float(info["reward"])
|
||||
is_valid = int(info.get("failure_code", 0)) == 0
|
||||
|
||||
if is_valid:
|
||||
valid_init += 1
|
||||
input_x = np.vstack((input_x, x.reshape(1, -1)))
|
||||
input_y = np.append(input_y, float(info["scaled"]))
|
||||
writer.add_scalar("Reward", reward, global_step)
|
||||
else:
|
||||
invalid_skips += 1
|
||||
|
||||
if is_valid and reward > best_reward:
|
||||
best_reward = reward
|
||||
best_params = x.copy()
|
||||
np.save(best_params_path, best_params)
|
||||
with open(best_meta_path, "wb") as f:
|
||||
pickle.dump(
|
||||
{
|
||||
"best_reward": best_reward,
|
||||
"best_params": best_params,
|
||||
"config": config_payload,
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
save_live_db(
|
||||
live_db_path,
|
||||
input_x,
|
||||
input_y,
|
||||
{
|
||||
"name": name,
|
||||
"best_reward": best_reward,
|
||||
"dataset_size": int(len(input_y)),
|
||||
"control_mode": "open_loop_periodic",
|
||||
"recover_count": int(obj.recover_count),
|
||||
"invalid_skips": int(invalid_skips),
|
||||
},
|
||||
)
|
||||
|
||||
with open(dante_log_path, "a", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"{time.time():.3f},init,0,{init_attempts},{reward:.8f},{best_reward:.8f},{len(input_y)},{info['recoveries_used']},{info.get('failure_code', 0)},{float(info.get('period_steps', period_steps)):.6f}\n"
|
||||
)
|
||||
|
||||
global_step += 1
|
||||
print(
|
||||
f"[init-attempt {init_attempts}/{max_init_attempts}] valid={valid_init}/{num_initial} | "
|
||||
f"reward={reward:.4f} | best={best_reward:.4f} | period={float(info.get('period_steps', period_steps)):.3f} | "
|
||||
f"invalid_skips={invalid_skips} | recover={int(obj.recover_count)} | "
|
||||
f"eval={global_step}/{total_expected}+ | elapsed={float(time.time() - t0):.1f}s",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
for acq in range(int(num_acquisitions)):
|
||||
if len(input_y) < 2:
|
||||
print(f"[acq {acq + 1}] skipped: insufficient valid samples ({len(input_y)})", flush=True)
|
||||
continue
|
||||
|
||||
print(f"[acq {acq + 1}/{int(num_acquisitions)}] fitting surrogate on {len(input_y)} samples...", flush=True)
|
||||
|
||||
surrogate_mode = str(cfg.get("surrogate_mode", "mlp")).lower()
|
||||
if surrogate_mode == "ensemble":
|
||||
candidates = []
|
||||
for arch in ["mlp", "cnn"]:
|
||||
cand_ckpt = Path(str(ckpt_path).replace(".keras", f"_{arch}.keras"))
|
||||
surr = FlowControlSurrogateV6(
|
||||
input_dims=dims,
|
||||
epochs=int(cfg["surrogate_epochs"]),
|
||||
patience=30,
|
||||
check_point_path=cand_ckpt,
|
||||
tf_device_id=int(cfg["surrogate_gpu_id"]),
|
||||
architecture=arch,
|
||||
)
|
||||
try:
|
||||
m = surr(input_x, input_y, verbose=0)
|
||||
fm = dict(getattr(surr, "last_fit_metrics", {}) or {})
|
||||
candidates.append((arch, m, fm, cand_ckpt))
|
||||
except Exception as e:
|
||||
print(f"[acq {acq + 1}] surrogate {arch} failed: {e}", flush=True)
|
||||
|
||||
if not candidates:
|
||||
raise RuntimeError("all surrogate candidates failed in ensemble mode")
|
||||
|
||||
candidates.sort(key=lambda z: float(z[2].get("val_r2", -1e9)), reverse=True)
|
||||
best_arch, model, fit_metrics, best_ckpt = candidates[0]
|
||||
if best_ckpt.exists():
|
||||
shutil.copy2(best_ckpt, ckpt_path)
|
||||
fit_metrics["selected_architecture"] = best_arch
|
||||
else:
|
||||
surrogate.architecture = "cnn" if surrogate_mode == "cnn" else "mlp"
|
||||
model = surrogate(input_x, input_y, verbose=0)
|
||||
fit_metrics = dict(getattr(surrogate, "last_fit_metrics", {}) or {})
|
||||
fit_metrics["selected_architecture"] = str(surrogate.architecture)
|
||||
|
||||
if fit_metrics:
|
||||
writer.add_scalar("Surrogate/val_r2", float(fit_metrics.get("val_r2", 0.0)), acq)
|
||||
writer.add_scalar("Surrogate/val_mae", float(fit_metrics.get("val_mae", 0.0)), acq)
|
||||
print(
|
||||
f"[acq {acq + 1}] surrogate metrics: "
|
||||
f"val_r2={fit_metrics.get('val_r2', float('nan')):.4f}, "
|
||||
f"val_mae={fit_metrics.get('val_mae', float('nan')):.4f}, "
|
||||
f"selected={fit_metrics.get('selected_architecture', 'na')}, "
|
||||
f"device={fit_metrics.get('device', 'CPU')}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if ckpt_path.exists():
|
||||
shutil.copy2(ckpt_path, os.path.join(model_dir, f"{name}_surrogate_live.keras"))
|
||||
|
||||
explorer = TreeExploration(
|
||||
func=obj,
|
||||
model=model,
|
||||
num_samples_per_acquisition=int(cfg["samples_per_acq"]),
|
||||
)
|
||||
candidate_x = explorer.rollout(input_x, input_y, iteration=acq)
|
||||
|
||||
acq_rewards: List[float] = []
|
||||
acq_periods: List[float] = []
|
||||
for j, x in enumerate(candidate_x):
|
||||
info = obj.evaluate_candidate(x)
|
||||
reward = float(info["reward"])
|
||||
period_used = float(info.get("period_steps", period_steps))
|
||||
is_valid = int(info.get("failure_code", 0)) == 0
|
||||
|
||||
if is_valid:
|
||||
acq_rewards.append(reward)
|
||||
acq_periods.append(period_used)
|
||||
|
||||
input_x = np.vstack((input_x, np.asarray(x, dtype=np.float64).reshape(1, -1)))
|
||||
input_y = np.append(input_y, float(info["scaled"]))
|
||||
writer.add_scalar("Reward", reward, global_step)
|
||||
|
||||
if str(cfg["period_mode"]).lower() == "optimize":
|
||||
writer.add_scalar("Controller/period_steps", period_used, global_step)
|
||||
|
||||
if reward > best_reward:
|
||||
best_reward = reward
|
||||
best_params = np.asarray(x, dtype=np.float64).copy()
|
||||
np.save(best_params_path, best_params)
|
||||
with open(best_meta_path, "wb") as f:
|
||||
pickle.dump(
|
||||
{
|
||||
"best_reward": best_reward,
|
||||
"best_params": best_params,
|
||||
"config": config_payload,
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
save_live_db(
|
||||
live_db_path,
|
||||
input_x,
|
||||
input_y,
|
||||
{
|
||||
"name": name,
|
||||
"best_reward": best_reward,
|
||||
"dataset_size": int(len(input_y)),
|
||||
"control_mode": "open_loop_periodic",
|
||||
"recover_count": int(obj.recover_count),
|
||||
"acq": int(acq + 1),
|
||||
"invalid_skips": int(invalid_skips),
|
||||
},
|
||||
)
|
||||
else:
|
||||
invalid_skips += 1
|
||||
|
||||
with open(dante_log_path, "a", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"{time.time():.3f},acq,{acq + 1},{j + 1},{reward:.8f},{best_reward:.8f},{len(input_y)},{info['recoveries_used']},{info.get('failure_code', 0)},{period_used:.6f}\n"
|
||||
)
|
||||
|
||||
global_step += 1
|
||||
print_progress_line(
|
||||
phase=f"acq{acq + 1}",
|
||||
idx=j + 1,
|
||||
total=len(candidate_x),
|
||||
reward=reward,
|
||||
best_reward=best_reward,
|
||||
recover_total=int(obj.recover_count),
|
||||
elapsed_sec=float(time.time() - t0),
|
||||
global_step=global_step,
|
||||
total_expected=total_expected,
|
||||
period_steps=period_used,
|
||||
)
|
||||
|
||||
history.append(
|
||||
{
|
||||
"iteration": int(acq + 1),
|
||||
"new_mean": float(np.mean(acq_rewards) if acq_rewards else 0.0),
|
||||
"new_max": float(np.max(acq_rewards) if acq_rewards else 0.0),
|
||||
"period_mean": float(np.mean(acq_periods) if acq_periods else period_steps),
|
||||
"period_std": float(np.std(acq_periods) if acq_periods else 0.0),
|
||||
"best_reward": float(best_reward),
|
||||
"dataset_size": int(len(input_y)),
|
||||
"recover_total": int(obj.recover_count),
|
||||
}
|
||||
)
|
||||
|
||||
print(
|
||||
f"acq {acq + 1}/{num_acquisitions}: mean={history[-1]['new_mean']:.4f}, "
|
||||
f"max={history[-1]['new_max']:.4f}, best={best_reward:.4f}, "
|
||||
f"period_mean={history[-1]['period_mean']:.3f}, recover_total={obj.recover_count}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
with open(final_meta_path, "wb") as f:
|
||||
pickle.dump(
|
||||
{
|
||||
"name": name,
|
||||
"best_reward": best_reward,
|
||||
"best_params": best_params,
|
||||
"history": history,
|
||||
"elapsed_sec": float(time.time() - t0),
|
||||
"config": config_payload,
|
||||
"recover_total": int(obj.recover_count),
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
print("Training done")
|
||||
print("best_reward:", f"{best_reward:.4f}")
|
||||
print("recover_total:", obj.recover_count)
|
||||
print("invalid_skips:", invalid_skips)
|
||||
|
||||
finally:
|
||||
writer.close()
|
||||
if obj.env is not None:
|
||||
obj.env.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,51 @@
|
||||
# DANTE v6 Initial-Region Surrogate Failure Analysis
|
||||
|
||||
## Scope
|
||||
|
||||
- initial_samples: 100
|
||||
- dims: 42
|
||||
- note: analysis uses only init phase, not acquisition samples
|
||||
|
||||
## Data Geometry
|
||||
|
||||
- rank_ratio: 1.000
|
||||
- unique_ratio_rounded_0p05: 1.000
|
||||
- cov_condition_number: 1.53e+01
|
||||
- pca_components_for_90pct_var: 30
|
||||
- avg_nn_dist: 4.1265
|
||||
|
||||
## Classical Baselines
|
||||
|
||||
- ridge_std: r2_mean=-1.0772 ± 0.4586, mae_mean=6.0836
|
||||
- knn_std_k5: r2_mean=-0.2933 ± 0.2589, mae_mean=4.9774
|
||||
- rf_300: r2_mean=-0.2074 ± 0.2602, mae_mean=4.6365
|
||||
|
||||
## TensorFlow Models
|
||||
|
||||
- tf_device: GPU:1
|
||||
- tf_v4_like_no_scaling: r2_mean=-1.8222 ± 0.8408, mae_mean=7.1709
|
||||
- tf_v4_like_with_scaling: r2_mean=-0.7289 ± 0.2800, mae_mean=5.9893
|
||||
- tf_mid_128_64_32_with_scaling: r2_mean=-0.6206 ± 0.4865, mae_mean=5.6784
|
||||
|
||||
## Recovery/Noise Signal
|
||||
|
||||
- recoveries_ratio_ge1: 0.880
|
||||
- mean_scaled(recover>=1): 17.945237696501337
|
||||
- mean_scaled(recover=0): 15.966237587414065
|
||||
- std_scaled(recover>=1): 4.7475266358578665
|
||||
- std_scaled(recover=0): 9.213494585712375
|
||||
|
||||
## Temporal Drift
|
||||
|
||||
- corr(index, y): -0.1209
|
||||
- forward RF on x: r2=-0.0394, mae=5.0576
|
||||
- forward RF on [x,index]: r2=0.0153, mae=4.9602
|
||||
- forward ridge on index only: r2=0.0023, mae=4.9557
|
||||
|
||||
## Diagnosis
|
||||
|
||||
- Even non-neural baselines fail on init set: weakly learnable mapping or high label noise.
|
||||
- Input/output scaling is a first-order factor: original no-scaling setup underfits init region.
|
||||
- Model capacity/optimization also matters after scaling (mid_128_64_32 > v4-like).
|
||||
- Most init samples require recovery reset; this indicates environment-transition induced label noise in init pool.
|
||||
- Adding sample index improves forward prediction, indicating temporal/state drift beyond static x->y mapping.
|
||||
@@ -0,0 +1,28 @@
|
||||
# DANTE v6 vs PPO 诊断报告
|
||||
|
||||
## 1) PPO新terms重拟合与可达性
|
||||
|
||||
- features: ['bias1', 'obs0', 'obs1', 'dobs0', 'dobs1', 'sin_obs0', 'sin_obs1', 'cos_obs0', 'cos_obs1', 'tanh_obs0', 'tanh_obs1', 'act0_l1', 'act1_l1', 'act2_l1']
|
||||
- ch0: R2_test=1.0000, MAE_test=0.00002, nz=14
|
||||
- ch1: R2_test=1.0000, MAE_test=0.00001, nz=14
|
||||
- ch2: R2_test=1.0000, MAE_test=0.00002, nz=14
|
||||
- 参数空间可达性: out_of_range_terms=0, coef_abs_err_mean=0.000000, coef_abs_err_max=0.000000
|
||||
|
||||
## 2) PPO拟合控制率环境回放
|
||||
|
||||
- rollout steps=300, recoveries=0, tail60=0.5041, max_reward=0.7223
|
||||
- reward曲线图: dante_v6_ppo_reward_curve.png
|
||||
- 流场速度图: dante_v6_ppo_flow_speed.png
|
||||
|
||||
## 3) DANTE数据库与PPO目标点
|
||||
|
||||
- samples=818, num_initial=100, best_reward=0.3311
|
||||
- PCA距离: init_mean=0.6812, last_mean=5.3179, min=0.0080 at eval=196
|
||||
- PCA图: dante_v6_pca_overlay.png
|
||||
- 距离趋势图: dante_v6_distance_to_ppo.png
|
||||
- best曲线图: dante_v6_best_curve.png
|
||||
|
||||
## 4) 结论
|
||||
|
||||
- 是否趋近PPO点: False
|
||||
- 最高reward不更新判断: DANTE未明显趋近PPO目标点,代理采样方向与目标结构存在偏移,需修正采集策略或特征归一化。
|
||||
@@ -0,0 +1,23 @@
|
||||
# DANTE v6 Surrogate Study
|
||||
|
||||
- db_path: output/d1a3o12_250421_forces02_dante_v6_database_live.npz
|
||||
- n_samples: 976
|
||||
- dims: 42
|
||||
- holdout: 0.2
|
||||
- gpu_id: 1
|
||||
|
||||
## Ranking (by test_r2)
|
||||
|
||||
| rank | design | test_r2 | test_mae | val_r2 | epochs_ran | device |
|
||||
|---:|---|---:|---:|---:|---:|---|
|
||||
| 1 | mid_128_64_32 | 0.8647 | 1.2556 | 0.8778 | 88 | GPU:1 |
|
||||
| 2 | compact_128_64 | 0.8610 | 1.2621 | 0.8836 | 84 | GPU:1 |
|
||||
| 3 | wide_256_128_64 | 0.8528 | 1.2508 | 0.8786 | 68 | GPU:1 |
|
||||
| 4 | strong_reg_128_64_32 | 0.8463 | 1.3380 | 0.8592 | 138 | GPU:1 |
|
||||
|
||||
## Recommended
|
||||
|
||||
- design: mid_128_64_32
|
||||
- test_r2: 0.8647
|
||||
- test_mae: 1.2556
|
||||
- config: {"name": "mid_128_64_32", "hidden_units": [128, 64, 32], "dropout": 0.1, "weight_decay": 1e-06, "learning_rate": 0.001, "batch_size": 64}
|
||||
@@ -0,0 +1,253 @@
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
import numpy as np
|
||||
from sklearn.metrics import mean_absolute_error, r2_score
|
||||
from sklearn.model_selection import train_test_split
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
try:
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.optim as optim
|
||||
_TORCH_IMPORT_ERROR = None
|
||||
except Exception as exc:
|
||||
torch = None
|
||||
nn = None
|
||||
optim = None
|
||||
_TORCH_IMPORT_ERROR = exc
|
||||
|
||||
|
||||
class TorchScaledPredictWrapper:
|
||||
def __init__(self, torch_model: Any, x_scaler: StandardScaler, y_scaler: StandardScaler, device: str):
|
||||
self.torch_model = torch_model
|
||||
self.x_scaler = x_scaler
|
||||
self.y_scaler = y_scaler
|
||||
self.device = str(device)
|
||||
|
||||
def predict(self, x_in, **kwargs):
|
||||
x_in = np.asarray(x_in, dtype=np.float64)
|
||||
if x_in.ndim == 3 and x_in.shape[-1] == 1:
|
||||
x_in = x_in.squeeze(axis=-1)
|
||||
if x_in.ndim > 2:
|
||||
x_in = x_in.reshape(len(x_in), -1)
|
||||
x_scaled = self.x_scaler.transform(x_in).astype(np.float32)
|
||||
xt = torch.as_tensor(x_scaled, dtype=torch.float32, device=self.device)
|
||||
if xt.ndim == 2:
|
||||
pass
|
||||
else:
|
||||
xt = xt.reshape(len(xt), -1)
|
||||
|
||||
self.torch_model.eval()
|
||||
with torch.no_grad():
|
||||
if hasattr(self.torch_model, "expects_channel") and self.torch_model.expects_channel:
|
||||
# Conv1d on this runtime can hit intermittent cuDNN mapping errors;
|
||||
# keep GPU execution but bypass cuDNN for CNN forward passes.
|
||||
with torch.backends.cudnn.flags(enabled=False):
|
||||
y_scaled = self.torch_model(xt.unsqueeze(1)).detach().cpu().numpy().reshape(-1, 1)
|
||||
else:
|
||||
y_scaled = self.torch_model(xt).detach().cpu().numpy().reshape(-1, 1)
|
||||
|
||||
y_raw = self.y_scaler.inverse_transform(y_scaled)
|
||||
return y_raw.reshape(-1, 1)
|
||||
|
||||
|
||||
class TorchMLPRegressor(nn.Module):
|
||||
expects_channel = False
|
||||
|
||||
def __init__(self, input_dims: int, hidden_units: Sequence[int], dropout: float):
|
||||
super().__init__()
|
||||
layers = []
|
||||
prev = int(input_dims)
|
||||
for w in hidden_units:
|
||||
layers.append(nn.Linear(prev, int(w)))
|
||||
layers.append(nn.ELU())
|
||||
if dropout > 1e-8:
|
||||
layers.append(nn.Dropout(float(dropout)))
|
||||
prev = int(w)
|
||||
layers.append(nn.Linear(prev, 1))
|
||||
self.net = nn.Sequential(*layers)
|
||||
|
||||
def forward(self, x):
|
||||
return self.net(x)
|
||||
|
||||
|
||||
class TorchCNNRegressor(nn.Module):
|
||||
expects_channel = True
|
||||
|
||||
def __init__(self, input_dims: int, dropout: float):
|
||||
super().__init__()
|
||||
flat_dim = 16 * max(1, int(input_dims) - 2)
|
||||
self.net = nn.Sequential(
|
||||
nn.Conv1d(1, 128, kernel_size=3, padding=1),
|
||||
nn.ELU(),
|
||||
nn.MaxPool1d(kernel_size=2, stride=1),
|
||||
nn.Dropout(float(dropout)),
|
||||
nn.Conv1d(128, 64, kernel_size=3, padding=1),
|
||||
nn.ELU(),
|
||||
nn.MaxPool1d(kernel_size=2, stride=1),
|
||||
nn.Dropout(float(dropout)),
|
||||
nn.Conv1d(64, 32, kernel_size=3, padding=1),
|
||||
nn.ELU(),
|
||||
nn.Conv1d(32, 16, kernel_size=3, padding=1),
|
||||
nn.ELU(),
|
||||
nn.Flatten(),
|
||||
nn.Linear(flat_dim, 64),
|
||||
nn.ELU(),
|
||||
nn.Linear(64, 1),
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
return self.net(x)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlowControlSurrogateV6:
|
||||
input_dims: int
|
||||
learning_rate: float = 1e-3
|
||||
batch_size: int = 64
|
||||
epochs: int = 500
|
||||
test_size: float = 0.2
|
||||
train_test_split_random_state: int = 42
|
||||
patience: int = 30
|
||||
check_point_path: Path = Path("surrogate_v6.pt")
|
||||
hidden_units: Sequence[int] = field(default_factory=lambda: (128, 64, 32))
|
||||
architecture: str = "mlp"
|
||||
dropout: float = 0.10
|
||||
weight_decay: float = 1e-6
|
||||
tf_device_id: Optional[int] = None
|
||||
require_gpu: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
self.model: Optional[Any] = None
|
||||
self.x_scaler = StandardScaler()
|
||||
self.y_scaler = StandardScaler()
|
||||
self.last_fit_metrics: Dict[str, float] = {}
|
||||
|
||||
@staticmethod
|
||||
def _forward_with_runtime_guard(model: Any, x: Any):
|
||||
if hasattr(model, "expects_channel") and model.expects_channel:
|
||||
with torch.backends.cudnn.flags(enabled=False):
|
||||
return model(x.unsqueeze(1))
|
||||
return model(x)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_torch_ready() -> None:
|
||||
if torch is None:
|
||||
raise ImportError(
|
||||
f"PyTorch is required for FlowControlSurrogateV6 but is not available: {_TORCH_IMPORT_ERROR}"
|
||||
)
|
||||
|
||||
def _pick_device(self) -> str:
|
||||
self._ensure_torch_ready()
|
||||
if not torch.cuda.is_available():
|
||||
if self.require_gpu:
|
||||
raise RuntimeError("FlowControlSurrogateV6 requires GPU but torch.cuda is not available")
|
||||
return "cpu"
|
||||
|
||||
if self.tf_device_id is None:
|
||||
return "cuda:0"
|
||||
idx = int(max(0, min(torch.cuda.device_count() - 1, int(self.tf_device_id))))
|
||||
return f"cuda:{idx}"
|
||||
|
||||
def _build_model(self):
|
||||
arch = str(self.architecture).lower()
|
||||
if arch == "cnn":
|
||||
return TorchCNNRegressor(input_dims=self.input_dims, dropout=float(self.dropout))
|
||||
return TorchMLPRegressor(
|
||||
input_dims=self.input_dims,
|
||||
hidden_units=self.hidden_units,
|
||||
dropout=float(self.dropout),
|
||||
)
|
||||
|
||||
def __call__(self, x, y, verbose: int = 0):
|
||||
self._ensure_torch_ready()
|
||||
x = np.asarray(x, dtype=np.float64)
|
||||
y = np.asarray(y, dtype=np.float64).reshape(-1)
|
||||
|
||||
x_train, x_val, y_train, y_val = train_test_split(
|
||||
x,
|
||||
y,
|
||||
test_size=self.test_size,
|
||||
random_state=self.train_test_split_random_state,
|
||||
shuffle=True,
|
||||
)
|
||||
|
||||
x_train_s = self.x_scaler.fit_transform(x_train).astype(np.float32)
|
||||
x_val_s = self.x_scaler.transform(x_val).astype(np.float32)
|
||||
y_train_s = self.y_scaler.fit_transform(y_train.reshape(-1, 1)).reshape(-1).astype(np.float32)
|
||||
|
||||
device = self._pick_device()
|
||||
model = self._build_model().to(device)
|
||||
optimizer = optim.Adam(model.parameters(), lr=float(self.learning_rate), weight_decay=float(self.weight_decay))
|
||||
criterion = nn.MSELoss()
|
||||
|
||||
xt = torch.as_tensor(x_train_s, dtype=torch.float32, device=device)
|
||||
yt = torch.as_tensor(y_train_s, dtype=torch.float32, device=device).reshape(-1, 1)
|
||||
|
||||
xv = torch.as_tensor(x_val_s, dtype=torch.float32, device=device)
|
||||
|
||||
batch = int(max(8, min(int(self.batch_size), len(x_train_s))))
|
||||
best_loss = np.inf
|
||||
best_state = None
|
||||
bad_epochs = 0
|
||||
epochs_ran = 0
|
||||
|
||||
model.train()
|
||||
for ep in range(int(self.epochs)):
|
||||
perm = torch.randperm(xt.shape[0], device=device)
|
||||
epoch_loss = 0.0
|
||||
n_batches = 0
|
||||
|
||||
for i in range(0, xt.shape[0], batch):
|
||||
idx = perm[i : i + batch]
|
||||
xb = xt[idx]
|
||||
yb = yt[idx]
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
pred = self._forward_with_runtime_guard(model, xb)
|
||||
loss = criterion(pred, yb)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
epoch_loss += float(loss.item())
|
||||
n_batches += 1
|
||||
|
||||
train_loss = epoch_loss / max(1, n_batches)
|
||||
epochs_ran = ep + 1
|
||||
|
||||
if train_loss + 1e-10 < best_loss:
|
||||
best_loss = train_loss
|
||||
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
|
||||
bad_epochs = 0
|
||||
else:
|
||||
bad_epochs += 1
|
||||
|
||||
if bad_epochs >= int(self.patience):
|
||||
break
|
||||
|
||||
if best_state is not None:
|
||||
model.load_state_dict(best_state)
|
||||
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
y_val_pred_s = self._forward_with_runtime_guard(model, xv).detach().cpu().numpy().reshape(-1, 1)
|
||||
|
||||
y_val_pred = self.y_scaler.inverse_transform(y_val_pred_s).reshape(-1)
|
||||
|
||||
arch = str(self.architecture).lower()
|
||||
gpu_idx = int(str(device).split(":")[1]) if str(device).startswith("cuda") else -1
|
||||
self.last_fit_metrics = {
|
||||
"val_r2": float(r2_score(y_val, y_val_pred)),
|
||||
"val_mae": float(mean_absolute_error(y_val, y_val_pred)),
|
||||
"val_count": int(len(y_val)),
|
||||
"best_val_loss": float(best_loss),
|
||||
"epochs_ran": int(epochs_ran),
|
||||
"architecture": arch,
|
||||
"device": f"GPU:{gpu_idx}" if gpu_idx >= 0 else "CPU",
|
||||
"gpu_count": int(torch.cuda.device_count() if torch.cuda.is_available() else 0),
|
||||
"selected_gpu": int(gpu_idx),
|
||||
}
|
||||
|
||||
self.model = model
|
||||
return TorchScaledPredictWrapper(model, self.x_scaler, self.y_scaler, device=device)
|
||||
@@ -0,0 +1,289 @@
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pysindy as ps
|
||||
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
|
||||
|
||||
DATA_PATH = os.path.join(ROOT, "output", "d1a3o12_250421_forces02_sindy_dataset.pkl")
|
||||
OUT_DIR = os.path.join(ROOT, "output", "report_dante_v3_v4")
|
||||
OUT_JSON = os.path.join(OUT_DIR, "ppo_sindy_control_fit.json")
|
||||
|
||||
WARMUP_STEPS = 0
|
||||
MAX_LAG = 1
|
||||
THRESHOLDS = [0.0, 0.005, 0.01, 0.015, 0.02, 0.03, 0.05]
|
||||
NZ_RATIO_THRESHOLD = 0.70
|
||||
|
||||
|
||||
def episode_metric(rewards: np.ndarray, warmup: int = 150) -> float:
|
||||
r = np.asarray(rewards, dtype=np.float64).reshape(-1)
|
||||
if r.size == 0:
|
||||
return 0.0
|
||||
eff = r[warmup:] if r.size > warmup else r[-1:]
|
||||
return float(np.mean(eff[-100:])) if eff.size >= 100 else float(np.mean(eff))
|
||||
|
||||
|
||||
def load_episodes(path: str) -> Tuple[List[Dict], Dict]:
|
||||
with open(path, "rb") as f:
|
||||
data = pickle.load(f)
|
||||
|
||||
if isinstance(data, dict) and "episodes" in data:
|
||||
return list(data["episodes"]), dict(data.get("meta", {}))
|
||||
if isinstance(data, list):
|
||||
return data, {}
|
||||
raise RuntimeError(f"Unsupported dataset format: {type(data)}")
|
||||
|
||||
|
||||
def extract_episode_arrays(ep: Dict) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
actions = np.asarray(ep.get("actions", []), dtype=np.float64)
|
||||
observations = np.asarray(ep.get("observations", []), dtype=np.float64)
|
||||
rewards = np.asarray(ep.get("rewards", []), dtype=np.float64)
|
||||
|
||||
if actions.ndim != 2:
|
||||
actions = actions.reshape(actions.shape[0], -1)
|
||||
actions = actions[:, :3]
|
||||
|
||||
if observations.ndim != 2:
|
||||
observations = observations.reshape(observations.shape[0], -1)
|
||||
observations = observations[:, :2]
|
||||
|
||||
n = min(actions.shape[0], observations.shape[0], rewards.shape[0])
|
||||
return actions[:n], observations[:n], rewards[:n]
|
||||
|
||||
|
||||
def build_dataset(episodes: List[Dict], warmup: int = 150) -> Tuple[np.ndarray, np.ndarray, List[str], Dict]:
|
||||
x_rows = []
|
||||
y_rows = []
|
||||
|
||||
feat_names = [
|
||||
"bias1",
|
||||
"obs0",
|
||||
"obs1",
|
||||
"dobs0",
|
||||
"dobs1",
|
||||
"sin_obs0",
|
||||
"sin_obs1",
|
||||
"cos_obs0",
|
||||
"cos_obs1",
|
||||
"tanh_obs0",
|
||||
"tanh_obs1",
|
||||
"act0_l1",
|
||||
"act1_l1",
|
||||
"act2_l1",
|
||||
]
|
||||
|
||||
stats = {"episodes_used": 0, "samples_used": 0}
|
||||
|
||||
for ep in episodes:
|
||||
actions, obs2, rewards = extract_episode_arrays(ep)
|
||||
t_len = min(actions.shape[0], obs2.shape[0], rewards.shape[0])
|
||||
if t_len <= (MAX_LAG + warmup + 1):
|
||||
continue
|
||||
|
||||
for t in range(MAX_LAG, t_len):
|
||||
if t < warmup:
|
||||
continue
|
||||
|
||||
o = obs2[t]
|
||||
o1 = obs2[t - 1]
|
||||
a_prev = actions[t - 1]
|
||||
|
||||
x_rows.append(
|
||||
[
|
||||
1.0,
|
||||
o[0],
|
||||
o[1],
|
||||
o[0] - o1[0],
|
||||
o[1] - o1[1],
|
||||
np.sin(np.pi * o[0]),
|
||||
np.sin(np.pi * o[1]),
|
||||
np.cos(np.pi * o[0]),
|
||||
np.cos(np.pi * o[1]),
|
||||
np.tanh(o[0]),
|
||||
np.tanh(o[1]),
|
||||
a_prev[0],
|
||||
a_prev[1],
|
||||
a_prev[2],
|
||||
]
|
||||
)
|
||||
y_rows.append(actions[t])
|
||||
|
||||
stats["episodes_used"] += 1
|
||||
|
||||
if len(x_rows) < 512:
|
||||
raise RuntimeError(f"Too few samples for fitting: {len(x_rows)}")
|
||||
|
||||
x = np.asarray(x_rows, dtype=np.float64)
|
||||
y = np.asarray(y_rows, dtype=np.float64)
|
||||
stats["samples_used"] = int(x.shape[0])
|
||||
return x, y, feat_names, stats
|
||||
|
||||
|
||||
def r2(y: np.ndarray, yp: np.ndarray) -> float:
|
||||
ssr = float(np.sum((y - yp) ** 2))
|
||||
sst = float(np.sum((y - np.mean(y)) ** 2) + 1e-12)
|
||||
return float(1.0 - ssr / sst)
|
||||
|
||||
|
||||
def fit_channel_grid(x: np.ndarray, y: np.ndarray, thresholds: List[float]):
|
||||
std = np.std(x, axis=0)
|
||||
std = np.where(std < 1e-8, 1.0, std)
|
||||
xs = x / std
|
||||
|
||||
rows = []
|
||||
for th in thresholds:
|
||||
opt = ps.STLSQ(threshold=th, alpha=1e-4, max_iter=25)
|
||||
opt.fit(xs, y)
|
||||
coef = np.asarray(opt.coef_, dtype=np.float64).reshape(-1) / std
|
||||
yp = x @ coef
|
||||
rows.append(
|
||||
{
|
||||
"threshold": float(th),
|
||||
"nz": int(np.sum(np.abs(coef) > 1e-8)),
|
||||
"r2": r2(y, yp),
|
||||
"mae": float(np.mean(np.abs(y - yp))),
|
||||
"coef": coef,
|
||||
}
|
||||
)
|
||||
|
||||
best = max(rows, key=lambda z: z["r2"])
|
||||
return rows, best
|
||||
|
||||
|
||||
def top_terms(feat_names: List[str], coef: np.ndarray, topk: int = 10) -> List[Dict]:
|
||||
idx = np.argsort(np.abs(coef))[::-1]
|
||||
out = []
|
||||
for i in idx:
|
||||
c = float(coef[i])
|
||||
if abs(c) < 1e-8:
|
||||
continue
|
||||
out.append({"term": feat_names[i], "coef": c, "abs_coef": float(abs(c))})
|
||||
if len(out) >= topk:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
episodes_all, meta = load_episodes(DATA_PATH)
|
||||
|
||||
ppo_eps = [ep for ep in episodes_all if str(ep.get("source", "unknown")) == "ppo_eval"]
|
||||
if len(ppo_eps) < 10:
|
||||
# Fallback only when ppo episodes are too few.
|
||||
ppo_eps = episodes_all
|
||||
|
||||
metrics = []
|
||||
for i, ep in enumerate(ppo_eps):
|
||||
_, _, rewards = extract_episode_arrays(ep)
|
||||
metrics.append((i, episode_metric(rewards, warmup=WARMUP_STEPS)))
|
||||
metrics_sorted = sorted(metrics, key=lambda x: x[1], reverse=True)
|
||||
|
||||
x, y, feat_names, data_stats = build_dataset(ppo_eps, warmup=WARMUP_STEPS)
|
||||
|
||||
channel_models = []
|
||||
votes = np.zeros(len(feat_names), dtype=np.int64)
|
||||
coef_abs_sum = np.zeros(len(feat_names), dtype=np.float64)
|
||||
nz_ratios = []
|
||||
|
||||
for ch in range(3):
|
||||
rows, best = fit_channel_grid(x, y[:, ch], THRESHOLDS)
|
||||
coef = best["coef"]
|
||||
active = np.abs(coef) >= 0.01
|
||||
votes += active.astype(np.int64)
|
||||
coef_abs_sum += np.abs(coef)
|
||||
nz_nonbias = int(np.sum(np.abs(coef[1:]) > 1e-8))
|
||||
denom_nonbias = int(max(1, len(feat_names) - 1))
|
||||
nz_ratio = float(nz_nonbias / denom_nonbias)
|
||||
nz_ratios.append(nz_ratio)
|
||||
|
||||
channel_models.append(
|
||||
{
|
||||
"channel": ch,
|
||||
"r2": float(best["r2"]),
|
||||
"mae": float(best["mae"]),
|
||||
"best_sparse": {
|
||||
"threshold": float(best["threshold"]),
|
||||
"nz": int(best["nz"]),
|
||||
"nz_nonbias": nz_nonbias,
|
||||
"nz_ratio": nz_ratio,
|
||||
},
|
||||
"top_terms": top_terms(feat_names, coef, topk=10),
|
||||
"grid": [
|
||||
{
|
||||
"threshold": float(z["threshold"]),
|
||||
"nz": int(z["nz"]),
|
||||
"r2": float(z["r2"]),
|
||||
"mae": float(z["mae"]),
|
||||
}
|
||||
for z in rows
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
score_idx = sorted(
|
||||
range(len(feat_names)),
|
||||
key=lambda k: (int(votes[k]), float(coef_abs_sum[k])),
|
||||
reverse=True,
|
||||
)
|
||||
global_top = [feat_names[k] for k in score_idx if feat_names[k] != "bias1"]
|
||||
over_complex_channels = [int(cm["channel"]) for cm in channel_models if cm["best_sparse"]["nz_ratio"] >= NZ_RATIO_THRESHOLD]
|
||||
use_sindy_prior = len(over_complex_channels) == 0
|
||||
|
||||
if use_sindy_prior:
|
||||
prior_reason = (
|
||||
f"all channels nz_ratio < {NZ_RATIO_THRESHOLD:.2f}; sparse structure is sufficiently compressible"
|
||||
)
|
||||
else:
|
||||
prior_reason = (
|
||||
f"channels {over_complex_channels} have nz_ratio >= {NZ_RATIO_THRESHOLD:.2f}; sparse structure is too dense"
|
||||
)
|
||||
|
||||
result = {
|
||||
"data_path": DATA_PATH,
|
||||
"dataset_meta": meta,
|
||||
"warmup_steps": WARMUP_STEPS,
|
||||
"max_lag": MAX_LAG,
|
||||
"nz_ratio_threshold": NZ_RATIO_THRESHOLD,
|
||||
"episodes_total": len(episodes_all),
|
||||
"episodes_used_source": "ppo_eval" if len([ep for ep in episodes_all if str(ep.get("source", "")) == "ppo_eval"]) >= 10 else "all",
|
||||
"episodes_used_count": len(ppo_eps),
|
||||
"episode_metrics_top10": metrics_sorted[:10],
|
||||
"fit_data_stats": data_stats,
|
||||
"threshold_grid": THRESHOLDS,
|
||||
"feature_names": feat_names,
|
||||
"channel_models": channel_models,
|
||||
"global_term_votes": {feat_names[k]: int(votes[k]) for k in range(len(feat_names))},
|
||||
"global_term_abs_coef_sum": {feat_names[k]: float(coef_abs_sum[k]) for k in range(len(feat_names))},
|
||||
"global_top_terms": global_top[:12],
|
||||
"complexity_decision": {
|
||||
"nz_ratio_threshold": float(NZ_RATIO_THRESHOLD),
|
||||
"channel_nz_ratio": {f"ch{i}": float(z) for i, z in enumerate(nz_ratios)},
|
||||
"over_complex_channels": over_complex_channels,
|
||||
"use_sindy_prior": bool(use_sindy_prior),
|
||||
"reason": prior_reason,
|
||||
},
|
||||
}
|
||||
|
||||
with open(OUT_JSON, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"saved: {OUT_JSON}")
|
||||
print("episodes used:", len(ppo_eps), "/", len(episodes_all))
|
||||
print("samples:", data_stats["samples_used"])
|
||||
print("global top terms:", result["global_top_terms"][:8])
|
||||
for cm in channel_models:
|
||||
print(
|
||||
f"ch{cm['channel']} r2={cm['r2']:.4f} mae={cm['mae']:.5f} "
|
||||
f"nz={cm['best_sparse']['nz']} nz_ratio={cm['best_sparse']['nz_ratio']:.3f}"
|
||||
)
|
||||
print("use_sindy_prior:", result["complexity_decision"]["use_sindy_prior"])
|
||||
print("reason:", result["complexity_decision"]["reason"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,658 @@
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
from sklearn.decomposition import PCA
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
from dante_pinball.env.gym_env_dante_total_force import CustomEnv
|
||||
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
OUT_DIR = os.path.join(ROOT, "output", "report_dante_v2_v5_v6")
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
V6_RUN = "d1a3o12_250421_forces02_dante_v6_3"
|
||||
V7_RUN = "d1a3o12_250421_forces02_dante_v7_1"
|
||||
SEEDS = [11, 29, 47]
|
||||
N_ACT = 3
|
||||
EVAL_STEPS = 300
|
||||
|
||||
|
||||
def feature_dict(obs_t: np.ndarray, obs_prev: np.ndarray, act_prev: np.ndarray) -> Dict[str, float]:
|
||||
o0, o1 = float(obs_t[0]), float(obs_t[1])
|
||||
p0, p1 = float(obs_prev[0]), float(obs_prev[1])
|
||||
a0, a1, a2 = float(act_prev[0]), float(act_prev[1]), float(act_prev[2])
|
||||
return {
|
||||
"obs0": o0,
|
||||
"obs1": o1,
|
||||
"dobs0": o0 - p0,
|
||||
"dobs1": o1 - p1,
|
||||
"sin_obs0": float(np.sin(np.pi * o0)),
|
||||
"sin_obs1": float(np.sin(np.pi * o1)),
|
||||
"cos_obs0": float(np.cos(np.pi * o0)),
|
||||
"cos_obs1": float(np.cos(np.pi * o1)),
|
||||
"tanh_obs0": float(np.tanh(o0)),
|
||||
"tanh_obs1": float(np.tanh(o1)),
|
||||
"act0_l1": a0,
|
||||
"act1_l1": a1,
|
||||
"act2_l1": a2,
|
||||
}
|
||||
|
||||
|
||||
def inv_tanh_map(q: float) -> float:
|
||||
qq = float(np.clip(q, -0.999, 0.999))
|
||||
x = np.arctanh(qq) / 1.25
|
||||
return float(np.clip(x, -1.0, 1.0))
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunData:
|
||||
name: str
|
||||
x: np.ndarray
|
||||
y: np.ndarray
|
||||
num_initial: int
|
||||
cfg: Dict
|
||||
|
||||
|
||||
def load_run(name: str) -> RunData:
|
||||
db_path = os.path.join(ROOT, "output", f"{name}_database_live.npz")
|
||||
cfg_path = os.path.join(ROOT, "output", f"{name}_structure_decision.json")
|
||||
|
||||
z = np.load(db_path, allow_pickle=True)
|
||||
x = np.asarray(z["input_x"], dtype=np.float64)
|
||||
y = np.asarray(z["input_y"], dtype=np.float64)
|
||||
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
return RunData(name=name, x=x, y=y, num_initial=int(cfg["num_initial"]), cfg=cfg)
|
||||
|
||||
|
||||
def ppo_npz(seed: int) -> str:
|
||||
return os.path.join(OUT_DIR, f"raw_oldenv_seed_{seed}.npz")
|
||||
|
||||
|
||||
def ppo_point_for_v6(seed: int, basis_terms: List[str]) -> np.ndarray:
|
||||
z = np.load(ppo_npz(seed))
|
||||
obs = np.asarray(z["ppo_obs"], dtype=np.float64)
|
||||
act = np.asarray(z["ppo_actions"], dtype=np.float64)
|
||||
|
||||
rows_x = []
|
||||
rows_y = []
|
||||
for t in range(1, len(obs)):
|
||||
fd = feature_dict(obs[t], obs[t - 1], act[t - 1])
|
||||
rows_x.append([1.0] + [float(fd[k]) for k in basis_terms])
|
||||
rows_y.append(act[t])
|
||||
|
||||
X = np.asarray(rows_x, dtype=np.float64)
|
||||
Y = np.asarray(rows_y, dtype=np.float64)
|
||||
|
||||
params = []
|
||||
for ch in range(3):
|
||||
coef, *_ = np.linalg.lstsq(X, Y[:, ch], rcond=None)
|
||||
bias = float(coef[0])
|
||||
params.append(inv_tanh_map(bias / 1.0))
|
||||
for c in coef[1:]:
|
||||
params.append(inv_tanh_map(float(c) / 2.0))
|
||||
|
||||
return np.asarray(params, dtype=np.float64)
|
||||
|
||||
|
||||
def phase_weights(phase: float, k: int) -> np.ndarray:
|
||||
z = float(np.mod(phase, 1.0)) * k
|
||||
i0 = int(np.floor(z)) % k
|
||||
frac = float(z - np.floor(z))
|
||||
i1 = (i0 + 1) % k
|
||||
w = np.zeros(k, dtype=np.float64)
|
||||
w[i0] += (1.0 - frac)
|
||||
w[i1] += frac
|
||||
return w
|
||||
|
||||
|
||||
def ppo_point_for_v7(seed: int, k: int, period_steps: float) -> np.ndarray:
|
||||
z = np.load(ppo_npz(seed))
|
||||
act = np.asarray(z["ppo_actions"], dtype=np.float64)
|
||||
|
||||
n = int(act.shape[0])
|
||||
phase = 0.0
|
||||
step_phase = 1.0 / max(1e-6, float(period_steps))
|
||||
|
||||
W = np.zeros((n, k), dtype=np.float64)
|
||||
for t in range(n):
|
||||
W[t] = phase_weights(phase, k)
|
||||
phase = (phase + step_phase) % 1.0
|
||||
|
||||
params = []
|
||||
for ch in range(3):
|
||||
coef, *_ = np.linalg.lstsq(W, act[:, ch], rcond=None)
|
||||
coef = np.clip(coef, -1.0, 1.0)
|
||||
params.extend(coef.tolist())
|
||||
|
||||
return np.asarray(params, dtype=np.float64)
|
||||
|
||||
|
||||
def fit_pca_and_project(x: np.ndarray, extra: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
scaler = StandardScaler()
|
||||
xs = scaler.fit_transform(x)
|
||||
extra_s = scaler.transform(extra)
|
||||
|
||||
pca = PCA(n_components=2, random_state=0)
|
||||
x2 = pca.fit_transform(xs)
|
||||
extra2 = pca.transform(extra_s)
|
||||
return x2, extra2, pca.explained_variance_ratio_
|
||||
|
||||
|
||||
def plot_pca(run_name: str, x2: np.ndarray, y: np.ndarray, ppo2: np.ndarray, evr: np.ndarray, out_png: str) -> None:
|
||||
plt.figure(figsize=(8, 6))
|
||||
sc = plt.scatter(x2[:, 0], x2[:, 1], c=y, cmap="viridis", s=12, alpha=0.78)
|
||||
plt.colorbar(sc, label="reward_scaled")
|
||||
|
||||
colors = ["#e41a1c", "#377eb8", "#ff7f00"]
|
||||
for i, seed in enumerate(SEEDS):
|
||||
plt.scatter(
|
||||
[ppo2[i, 0]],
|
||||
[ppo2[i, 1]],
|
||||
s=130,
|
||||
c=colors[i],
|
||||
marker="*",
|
||||
edgecolors="k",
|
||||
linewidths=0.9,
|
||||
label=f"PPO fitted seed {seed}",
|
||||
zorder=6,
|
||||
)
|
||||
|
||||
plt.title(f"{run_name}: PCA with 3 PPO fitted points\\nPC1 {evr[0]*100:.1f}% | PC2 {evr[1]*100:.1f}%")
|
||||
plt.xlabel("PC1")
|
||||
plt.ylabel("PC2")
|
||||
plt.legend(loc="best", fontsize=9)
|
||||
plt.grid(alpha=0.25)
|
||||
plt.tight_layout()
|
||||
plt.savefig(out_png, dpi=170)
|
||||
plt.close()
|
||||
|
||||
|
||||
class LinearBasisController:
|
||||
def __init__(self, basis_terms: List[str]):
|
||||
self.basis_terms = list(basis_terms)
|
||||
self.num_basis = len(self.basis_terms)
|
||||
self.total_params = N_ACT * (1 + self.num_basis)
|
||||
self.params = np.zeros(self.total_params, dtype=np.float64)
|
||||
self.obs_l1 = np.zeros(2, dtype=np.float64)
|
||||
self.prev_action = np.zeros(3, dtype=np.float64)
|
||||
|
||||
def reset_state(self, obs0: np.ndarray) -> None:
|
||||
obs0 = np.asarray(obs0, dtype=np.float64).reshape(-1)
|
||||
if obs0.size < 2:
|
||||
obs0 = np.zeros(2, dtype=np.float64)
|
||||
self.obs_l1 = obs0[:2].copy()
|
||||
self.prev_action = np.zeros(3, dtype=np.float64)
|
||||
|
||||
def set_params(self, x: np.ndarray) -> None:
|
||||
x = np.asarray(x, dtype=np.float64).reshape(-1)
|
||||
if x.size != self.total_params:
|
||||
raise ValueError(f"controller params mismatch: {x.size} != {self.total_params}")
|
||||
self.params = np.clip(x, -1.0, 1.0)
|
||||
|
||||
def _feature_dict(self, obs: np.ndarray) -> Dict[str, float]:
|
||||
o = np.asarray(obs, dtype=np.float64).reshape(-1)
|
||||
if o.size < 2:
|
||||
o = np.zeros(2, dtype=np.float64)
|
||||
|
||||
o0, o1 = float(o[0]), float(o[1])
|
||||
o0_l1, o1_l1 = float(self.obs_l1[0]), float(self.obs_l1[1])
|
||||
a0, a1, a2 = float(self.prev_action[0]), float(self.prev_action[1]), float(self.prev_action[2])
|
||||
|
||||
return {
|
||||
"obs0": o0,
|
||||
"obs1": o1,
|
||||
"dobs0": o0 - o0_l1,
|
||||
"dobs1": o1 - o1_l1,
|
||||
"sin_obs0": float(np.sin(np.pi * o0)),
|
||||
"sin_obs1": float(np.sin(np.pi * o1)),
|
||||
"cos_obs0": float(np.cos(np.pi * o0)),
|
||||
"cos_obs1": float(np.cos(np.pi * o1)),
|
||||
"tanh_obs0": float(np.tanh(o0)),
|
||||
"tanh_obs1": float(np.tanh(o1)),
|
||||
"act0_l1": a0,
|
||||
"act1_l1": a1,
|
||||
"act2_l1": a2,
|
||||
}
|
||||
|
||||
def predict(self, obs: np.ndarray) -> np.ndarray:
|
||||
feat = self._feature_dict(obs)
|
||||
out = np.zeros(3, dtype=np.float64)
|
||||
stride = 1 + self.num_basis
|
||||
|
||||
for ch in range(3):
|
||||
off = ch * stride
|
||||
y = np.tanh(1.25 * self.params[off])
|
||||
for k, term in enumerate(self.basis_terms):
|
||||
y += (2.0 * np.tanh(1.25 * self.params[off + 1 + k])) * feat.get(term, 0.0)
|
||||
out[ch] = y
|
||||
|
||||
out = np.clip(out, -1.0, 1.0)
|
||||
obs2 = np.asarray(obs, dtype=np.float64).reshape(-1)
|
||||
if obs2.size < 2:
|
||||
obs2 = np.zeros(2, dtype=np.float64)
|
||||
self.obs_l1 = obs2[:2].copy()
|
||||
self.prev_action = out.copy()
|
||||
return out.astype(np.float32)
|
||||
|
||||
|
||||
class PeriodicOpenLoopController:
|
||||
def __init__(self, control_points_per_channel: int, period_steps: float):
|
||||
self.k = int(control_points_per_channel)
|
||||
self.ctrl_points = np.zeros((N_ACT, self.k), dtype=np.float64)
|
||||
self.phase = 0.0
|
||||
self.current_period_steps = float(period_steps)
|
||||
self.total_params = int(N_ACT * self.k)
|
||||
|
||||
def reset_state(self) -> None:
|
||||
self.phase = 0.0
|
||||
|
||||
def _eval_channel(self, points: np.ndarray, phase: float) -> float:
|
||||
p = np.asarray(points, dtype=np.float64).reshape(-1)
|
||||
z = float(np.mod(phase, 1.0)) * self.k
|
||||
i0 = int(np.floor(z)) % self.k
|
||||
frac = float(z - np.floor(z))
|
||||
i1 = (i0 + 1) % self.k
|
||||
return float((1.0 - frac) * p[i0] + frac * p[i1])
|
||||
|
||||
def set_params(self, x: np.ndarray) -> None:
|
||||
x = np.asarray(x, dtype=np.float64).reshape(-1)
|
||||
if x.size != self.total_params:
|
||||
raise ValueError(f"controller params mismatch: {x.size} != {self.total_params}")
|
||||
core = np.clip(x, -1.0, 1.0)
|
||||
self.ctrl_points = core.reshape(N_ACT, self.k)
|
||||
|
||||
def predict(self, _obs: np.ndarray) -> np.ndarray:
|
||||
action = np.zeros(N_ACT, dtype=np.float64)
|
||||
for ch in range(N_ACT):
|
||||
action[ch] = self._eval_channel(self.ctrl_points[ch], self.phase)
|
||||
action = np.clip(action, -1.0, 1.0)
|
||||
step_phase = 1.0 / max(1e-6, float(self.current_period_steps))
|
||||
self.phase = float((self.phase + step_phase) % 1.0)
|
||||
return action.astype(np.float32)
|
||||
|
||||
|
||||
def extract_ux_uy(env: CustomEnv) -> Tuple[np.ndarray, np.ndarray]:
|
||||
nx = env.flow_field.FIELD_SHAPE[0]
|
||||
ny = env.flow_field.FIELD_SHAPE[1]
|
||||
env.flow_field.get_ddf()
|
||||
ddf = env.flow_field.ddf.copy().reshape((9, ny, nx)).transpose(2, 1, 0)
|
||||
ux = ddf[:, :, 1] + ddf[:, :, 5] + ddf[:, :, 8] - ddf[:, :, 3] - ddf[:, :, 6] - ddf[:, :, 7]
|
||||
uy = ddf[:, :, 2] + ddf[:, :, 5] + ddf[:, :, 6] - ddf[:, :, 4] - ddf[:, :, 7] - ddf[:, :, 8]
|
||||
return ux.astype(np.float64), uy.astype(np.float64)
|
||||
|
||||
|
||||
def vorticity_from_ux_uy(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
|
||||
dvy_dx = np.gradient(uy, axis=0)
|
||||
dvx_dy = np.gradient(ux, axis=1)
|
||||
return (dvy_dx - dvx_dy).astype(np.float64)
|
||||
|
||||
|
||||
def rollout_controller_v6(params: np.ndarray, basis_terms: List[str], device_id: int = 1) -> Dict[str, np.ndarray]:
|
||||
env = CustomEnv(device_id=int(device_id))
|
||||
ctrl = LinearBasisController(basis_terms)
|
||||
ctrl.set_params(params)
|
||||
|
||||
obs, _ = env.reset()
|
||||
obs = np.asarray(obs, dtype=np.float32)
|
||||
ctrl.reset_state(obs)
|
||||
|
||||
obs_hist = []
|
||||
act_hist = []
|
||||
rew_hist = []
|
||||
|
||||
try:
|
||||
for _ in range(EVAL_STEPS):
|
||||
act = ctrl.predict(obs)
|
||||
next_obs, reward, done, trunc, _ = env.step(act)
|
||||
obs_hist.append(np.asarray(obs, dtype=np.float64).copy())
|
||||
act_hist.append(np.asarray(act, dtype=np.float64).copy())
|
||||
rew_hist.append(float(reward))
|
||||
obs = np.asarray(next_obs, dtype=np.float32)
|
||||
if done or trunc:
|
||||
obs, _ = env.reset()
|
||||
obs = np.asarray(obs, dtype=np.float32)
|
||||
ctrl.reset_state(obs)
|
||||
|
||||
ux, uy = extract_ux_uy(env)
|
||||
finally:
|
||||
env.close()
|
||||
|
||||
return {
|
||||
"obs": np.asarray(obs_hist, dtype=np.float64),
|
||||
"act": np.asarray(act_hist, dtype=np.float64),
|
||||
"rew": np.asarray(rew_hist, dtype=np.float64),
|
||||
"ux": ux,
|
||||
"uy": uy,
|
||||
}
|
||||
|
||||
|
||||
def rollout_controller_v7(params: np.ndarray, k: int, period_steps: float, device_id: int = 0) -> Dict[str, np.ndarray]:
|
||||
env = CustomEnv(device_id=int(device_id))
|
||||
ctrl = PeriodicOpenLoopController(control_points_per_channel=k, period_steps=float(period_steps))
|
||||
ctrl.set_params(params)
|
||||
|
||||
obs, _ = env.reset()
|
||||
obs = np.asarray(obs, dtype=np.float32)
|
||||
ctrl.reset_state()
|
||||
|
||||
obs_hist = []
|
||||
act_hist = []
|
||||
rew_hist = []
|
||||
|
||||
try:
|
||||
for _ in range(EVAL_STEPS):
|
||||
act = ctrl.predict(obs)
|
||||
next_obs, reward, done, trunc, _ = env.step(act)
|
||||
obs_hist.append(np.asarray(obs, dtype=np.float64).copy())
|
||||
act_hist.append(np.asarray(act, dtype=np.float64).copy())
|
||||
rew_hist.append(float(reward))
|
||||
obs = np.asarray(next_obs, dtype=np.float32)
|
||||
if done or trunc:
|
||||
obs, _ = env.reset()
|
||||
obs = np.asarray(obs, dtype=np.float32)
|
||||
ctrl.reset_state()
|
||||
|
||||
ux, uy = extract_ux_uy(env)
|
||||
finally:
|
||||
env.close()
|
||||
|
||||
return {
|
||||
"obs": np.asarray(obs_hist, dtype=np.float64),
|
||||
"act": np.asarray(act_hist, dtype=np.float64),
|
||||
"rew": np.asarray(rew_hist, dtype=np.float64),
|
||||
"ux": ux,
|
||||
"uy": uy,
|
||||
}
|
||||
|
||||
|
||||
def best_ppo_seed() -> Tuple[int, float]:
|
||||
best_seed = None
|
||||
best_tail = -1e18
|
||||
for s in SEEDS:
|
||||
z = np.load(ppo_npz(s))
|
||||
r = np.asarray(z["ppo_rewards"], dtype=np.float64)
|
||||
tail = float(np.mean(r[-100:]))
|
||||
if tail > best_tail:
|
||||
best_tail = tail
|
||||
best_seed = s
|
||||
return int(best_seed), float(best_tail)
|
||||
|
||||
|
||||
def load_ppo_series(seed: int) -> Dict[str, np.ndarray]:
|
||||
z = np.load(ppo_npz(seed))
|
||||
return {
|
||||
"obs": np.asarray(z["ppo_obs"], dtype=np.float64),
|
||||
"act": np.asarray(z["ppo_actions"], dtype=np.float64),
|
||||
"rew": np.asarray(z["ppo_rewards"], dtype=np.float64),
|
||||
}
|
||||
|
||||
|
||||
def plot_obs_act_time(ppo: Dict[str, np.ndarray], v6: Dict[str, np.ndarray], v7: Dict[str, np.ndarray], out_png: str) -> None:
|
||||
t = np.arange(EVAL_STEPS)
|
||||
fig, axes = plt.subplots(5, 1, figsize=(12, 12), sharex=True, constrained_layout=True)
|
||||
|
||||
series = [
|
||||
(0, "obs0", ppo["obs"][:, 0], v6["obs"][:, 0], v7["obs"][:, 0]),
|
||||
(1, "obs1", ppo["obs"][:, 1], v6["obs"][:, 1], v7["obs"][:, 1]),
|
||||
(2, "act0", ppo["act"][:, 0], v6["act"][:, 0], v7["act"][:, 0]),
|
||||
(3, "act1", ppo["act"][:, 1], v6["act"][:, 1], v7["act"][:, 1]),
|
||||
(4, "act2", ppo["act"][:, 2], v6["act"][:, 2], v7["act"][:, 2]),
|
||||
]
|
||||
|
||||
for idx, name, y0, y1, y2 in series:
|
||||
ax = axes[idx]
|
||||
ax.plot(t, y0, lw=1.2, label="PPO")
|
||||
ax.plot(t, y1, lw=1.2, label="v6")
|
||||
ax.plot(t, y2, lw=1.2, label="v7")
|
||||
ax.set_ylabel(name)
|
||||
ax.grid(alpha=0.25)
|
||||
if idx == 0:
|
||||
ax.legend(loc="best", ncol=3)
|
||||
axes[-1].set_xlabel("time step")
|
||||
fig.suptitle("Obs-Act time series comparison (300 steps)")
|
||||
fig.savefig(out_png, dpi=170)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def plot_vorticity_maps(omega_ppo: np.ndarray, omega_v6: np.ndarray, omega_v7: np.ndarray, out_png: str) -> float:
|
||||
gmax = float(max(np.max(np.abs(omega_ppo)), np.max(np.abs(omega_v6)), np.max(np.abs(omega_v7))))
|
||||
vmax = max(1e-12, 0.1 * gmax)
|
||||
|
||||
fig, axes = plt.subplots(1, 3, figsize=(15, 4.8), constrained_layout=True)
|
||||
data = [(omega_ppo, "PPO"), (omega_v6, "v6"), (omega_v7, "v7")]
|
||||
for ax, (om, title) in zip(axes, data):
|
||||
im = ax.imshow(om.T, origin="lower", cmap="RdBu_r", vmin=-vmax, vmax=vmax)
|
||||
ax.set_title(title)
|
||||
ax.set_xlabel("x")
|
||||
ax.set_ylabel("y")
|
||||
cbar = fig.colorbar(im, ax=axes.ravel().tolist(), shrink=0.95)
|
||||
cbar.set_label("vorticity")
|
||||
fig.suptitle(f"Final vorticity maps, unified range +/-{vmax:.4f} (10% global max)")
|
||||
fig.savefig(out_png, dpi=170)
|
||||
plt.close(fig)
|
||||
return float(vmax)
|
||||
|
||||
|
||||
def build_design(obs: np.ndarray, act: np.ndarray, terms: List[str]) -> Tuple[np.ndarray, np.ndarray]:
|
||||
X_rows = []
|
||||
Y_rows = []
|
||||
for t in range(1, len(obs)):
|
||||
fd = feature_dict(obs[t], obs[t - 1], act[t - 1])
|
||||
X_rows.append([1.0] + [float(fd[k]) for k in terms])
|
||||
Y_rows.append(act[t])
|
||||
return np.asarray(X_rows, dtype=np.float64), np.asarray(Y_rows, dtype=np.float64)
|
||||
|
||||
|
||||
def fit_multi_seed_linear(terms: List[str]) -> Dict[str, object]:
|
||||
Ws = []
|
||||
r2s = []
|
||||
for s in SEEDS:
|
||||
z = np.load(ppo_npz(s))
|
||||
obs = np.asarray(z["ppo_obs"], dtype=np.float64)
|
||||
act = np.asarray(z["ppo_actions"], dtype=np.float64)
|
||||
X, Y = build_design(obs, act, terms)
|
||||
W = []
|
||||
r2_ch = []
|
||||
for ch in range(3):
|
||||
c, *_ = np.linalg.lstsq(X, Y[:, ch], rcond=None)
|
||||
pred = X @ c
|
||||
ssr = float(np.sum((Y[:, ch] - pred) ** 2))
|
||||
sst = float(np.sum((Y[:, ch] - np.mean(Y[:, ch])) ** 2) + 1e-12)
|
||||
r2 = float(1.0 - ssr / sst)
|
||||
W.append(c)
|
||||
r2_ch.append(r2)
|
||||
Ws.append(np.asarray(W, dtype=np.float64))
|
||||
r2s.append(np.asarray(r2_ch, dtype=np.float64))
|
||||
Wm = np.mean(np.asarray(Ws), axis=0)
|
||||
r2m = np.mean(np.asarray(r2s), axis=0)
|
||||
return {
|
||||
"terms": ["bias1"] + list(terms),
|
||||
"W_mean": Wm,
|
||||
"r2_by_action_mean": r2m,
|
||||
"r2_mean": float(np.mean(r2m)),
|
||||
}
|
||||
|
||||
|
||||
def matrix_to_latex(W: np.ndarray, precision: int = 4) -> str:
|
||||
rows = []
|
||||
for i in range(W.shape[0]):
|
||||
rows.append(" & ".join([f"{float(v):.{precision}f}" for v in W[i]]))
|
||||
body = " \\\\ ".join(rows)
|
||||
return "\\begin{bmatrix}" + body + "\\end{bmatrix}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
v6 = load_run(V6_RUN)
|
||||
v7 = load_run(V7_RUN)
|
||||
|
||||
v6_basis = list(v6.cfg["basis_terms"])
|
||||
v6_ppo_pts = np.vstack([ppo_point_for_v6(s, v6_basis) for s in SEEDS])
|
||||
|
||||
k = int(v7.cfg["control_points_per_channel"])
|
||||
period_steps = float(v7.cfg["period_steps"])
|
||||
v7_ppo_pts = np.vstack([ppo_point_for_v7(s, k=k, period_steps=period_steps) for s in SEEDS])
|
||||
|
||||
v6_x2, v6_p2, v6_evr = fit_pca_and_project(v6.x, v6_ppo_pts)
|
||||
v7_x2, v7_p2, v7_evr = fit_pca_and_project(v7.x, v7_ppo_pts)
|
||||
|
||||
fig_v6_pca = os.path.join(OUT_DIR, "brief_v6_3_pca_with_ppo3.png")
|
||||
fig_v7_pca = os.path.join(OUT_DIR, "brief_v7_1_pca_with_ppo3.png")
|
||||
plot_pca(v6.name, v6_x2, v6.y, v6_p2, v6_evr, fig_v6_pca)
|
||||
plot_pca(v7.name, v7_x2, v7.y, v7_p2, v7_evr, fig_v7_pca)
|
||||
|
||||
with open(os.path.join(ROOT, "output", f"{V6_RUN}_best_meta.pkl"), "rb") as f:
|
||||
v6_meta = pickle.load(f)
|
||||
with open(os.path.join(ROOT, "output", f"{V7_RUN}_best_meta.pkl"), "rb") as f:
|
||||
v7_meta = pickle.load(f)
|
||||
|
||||
v6_best = np.asarray(v6_meta["best_params"], dtype=np.float64)
|
||||
v7_best = np.asarray(v7_meta["best_params"], dtype=np.float64)
|
||||
|
||||
v6_roll = rollout_controller_v6(v6_best, basis_terms=v6_basis, device_id=1)
|
||||
v7_roll = rollout_controller_v7(v7_best, k=k, period_steps=period_steps, device_id=0)
|
||||
|
||||
seed_star, tail_star = best_ppo_seed()
|
||||
ppo_series = load_ppo_series(seed_star)
|
||||
|
||||
fig_ts = os.path.join(OUT_DIR, "brief_v6_v7_ppo_obs_act_time.png")
|
||||
plot_obs_act_time(ppo_series, v6_roll, v7_roll, fig_ts)
|
||||
|
||||
ppo_flow_npz = os.path.join(OUT_DIR, "fig_oldenv_flow_best.npz")
|
||||
zf = np.load(ppo_flow_npz)
|
||||
ppo_ux = np.asarray(zf["ux"], dtype=np.float64)
|
||||
ppo_uy = np.asarray(zf["uy"], dtype=np.float64)
|
||||
|
||||
omega_ppo = vorticity_from_ux_uy(ppo_ux, ppo_uy)
|
||||
omega_v6 = vorticity_from_ux_uy(v6_roll["ux"], v6_roll["uy"])
|
||||
omega_v7 = vorticity_from_ux_uy(v7_roll["ux"], v7_roll["uy"])
|
||||
|
||||
fig_omega = os.path.join(OUT_DIR, "brief_v6_v7_ppo_final_vorticity.png")
|
||||
omega_vmax = plot_vorticity_maps(omega_ppo, omega_v6, omega_v7, fig_omega)
|
||||
|
||||
with open(os.path.join(OUT_DIR, "sindy_group_sparsity_scan.json"), "r", encoding="utf-8") as f:
|
||||
group_scan = json.load(f)
|
||||
with open(os.path.join(OUT_DIR, "sindy_constrained_profile_search.json"), "r", encoding="utf-8") as f:
|
||||
constrained = json.load(f)
|
||||
with open(os.path.join(OUT_DIR, "dante_ackley_highdim_sweep.json"), "r", encoding="utf-8") as f:
|
||||
highdim = json.load(f)
|
||||
with open(os.path.join(OUT_DIR, "v6_v7_reaudit_frozen_facts_20260323.json"), "r", encoding="utf-8") as f:
|
||||
frozen = json.load(f)
|
||||
with open(os.path.join(OUT_DIR, "v6_v7_acq_diagnostics_summary_20260323.json"), "r", encoding="utf-8") as f:
|
||||
acq = json.load(f)
|
||||
|
||||
full_terms = [
|
||||
"obs0", "obs1", "dobs0", "dobs1", "sin_obs0", "sin_obs1", "cos_obs0", "cos_obs1",
|
||||
"tanh_obs0", "tanh_obs1", "act0_l1", "act1_l1", "act2_l1",
|
||||
]
|
||||
reduced_terms = list(constrained["best_chrono"]["basis_terms"])
|
||||
|
||||
full_fit = fit_multi_seed_linear(full_terms)
|
||||
reduced_fit = fit_multi_seed_linear(reduced_terms)
|
||||
|
||||
highdim_results = list(highdim.get("results", []))
|
||||
highdim_neg_r2 = int(sum(1 for r in highdim_results if float(r.get("holdout_r2", 0.0)) < 0.0))
|
||||
highdim_neg_gain = int(sum(1 for r in highdim_results if float(r.get("acq_gain_scaled", 0.0)) < 0.0))
|
||||
|
||||
summary = {
|
||||
"runs": {"v6": V6_RUN, "v7": V7_RUN, "ppo_seed_used": int(seed_star), "ppo_tail100": float(tail_star)},
|
||||
"figures": {
|
||||
"v6_pca": fig_v6_pca,
|
||||
"v7_pca": fig_v7_pca,
|
||||
"obs_act_time": fig_ts,
|
||||
"vorticity": fig_omega,
|
||||
},
|
||||
"vorticity_unified_abs_limit": float(omega_vmax),
|
||||
"reduction": {
|
||||
"full_r2_mean_over_seeds": float(group_scan["full_model"]["r2_mean_over_seeds"]),
|
||||
"best_chrono_basis_terms": reduced_terms,
|
||||
"best_chrono_r2_mean_over_seeds": float(constrained["best_chrono"]["chrono_r2_mean_over_seeds"]),
|
||||
"best_chrono_r2_min_over_seeds": float(constrained["best_chrono"]["chrono_r2_min_over_seeds"]),
|
||||
"group_scan_chosen": group_scan.get("chosen", {}),
|
||||
"full_fit": {
|
||||
"terms": full_fit["terms"],
|
||||
"W_mean": np.asarray(full_fit["W_mean"]).tolist(),
|
||||
"r2_by_action_mean": np.asarray(full_fit["r2_by_action_mean"]).tolist(),
|
||||
"r2_mean": float(full_fit["r2_mean"]),
|
||||
},
|
||||
"reduced_fit": {
|
||||
"terms": reduced_fit["terms"],
|
||||
"W_mean": np.asarray(reduced_fit["W_mean"]).tolist(),
|
||||
"r2_by_action_mean": np.asarray(reduced_fit["r2_by_action_mean"]).tolist(),
|
||||
"r2_mean": float(reduced_fit["r2_mean"]),
|
||||
},
|
||||
"latex": {
|
||||
"full_terms": "\\phi_{full}=[1,obs_0,obs_1,\\Delta obs_0,\\Delta obs_1,\\sin(\\pi obs_0),\\sin(\\pi obs_1),\\cos(\\pi obs_0),\\cos(\\pi obs_1),\\tanh(obs_0),\\tanh(obs_1),a_{0,t-1},a_{1,t-1},a_{2,t-1}]^\\top",
|
||||
"reduced_terms": "\\phi_{red}=[1,obs_1,\\sin(\\pi obs_0),\\cos(\\pi obs_0),a_{1,t-1}]^\\top",
|
||||
"W_full_mean": matrix_to_latex(np.asarray(full_fit["W_mean"], dtype=np.float64)),
|
||||
"W_reduced_mean": matrix_to_latex(np.asarray(reduced_fit["W_mean"], dtype=np.float64)),
|
||||
},
|
||||
},
|
||||
"highdim": {
|
||||
"n_cases": int(len(highdim_results)),
|
||||
"n_neg_holdout_r2": int(highdim_neg_r2),
|
||||
"n_neg_acq_gain": int(highdim_neg_gain),
|
||||
},
|
||||
"dual_surrogate": frozen.get("surrogate_log_stats", {}),
|
||||
"acq_summary": acq,
|
||||
}
|
||||
|
||||
summary_path = os.path.join(OUT_DIR, "v6_v7_ppo_briefing_summary.json")
|
||||
with open(summary_path, "w", encoding="utf-8") as f:
|
||||
json.dump(summary, f, indent=2, ensure_ascii=False)
|
||||
|
||||
md_path = os.path.join(OUT_DIR, "v6_v7_ppo_briefing_20260324.md")
|
||||
with open(md_path, "w", encoding="utf-8") as f:
|
||||
f.write("# v6/v7/PPO 综合简报\n\n")
|
||||
f.write("## 1) PCA(含3个PPO拟合点)\n")
|
||||
f.write(f"- v6图: {fig_v6_pca}\n")
|
||||
f.write(f"- v7图: {fig_v7_pca}\n\n")
|
||||
|
||||
f.write("## 2) obs-act时序 + 最终涡量\n")
|
||||
f.write(f"- 时序图: {fig_ts}\n")
|
||||
f.write(f"- 最终涡量图: {fig_omega}\n")
|
||||
f.write(f"- 涡量统一色条范围: ±{omega_vmax:.6f}(按三者全局|omega|max的10%)\n")
|
||||
f.write(f"- PPO时序选用seed={seed_star}(tail100={tail_star:.5f})\n\n")
|
||||
|
||||
f.write("## 3) 函数约简、关键函数、最终组合\n")
|
||||
f.write(f"- 全特征模型平均R2: {group_scan['full_model']['r2_mean_over_seeds']:.6f}\n")
|
||||
f.write(f"- 约束搜索best_chrono基函数: {reduced_terms}\n")
|
||||
f.write(f"- best_chrono平均R2: {constrained['best_chrono']['chrono_r2_mean_over_seeds']:.6f}\n")
|
||||
f.write(f"- best_chrono最差seed R2: {constrained['best_chrono']['chrono_r2_min_over_seeds']:.6f}\n")
|
||||
f.write("- 关键函数解释: obs1给出主状态幅值,sin/cos(pi*obs0)提供周期相位,act1_l1提供单步记忆。\n\n")
|
||||
|
||||
f.write("### 学到方程与拟合方程(LaTeX)\n")
|
||||
f.write("- 全特征学到方程(3动作联合线性写法):\n")
|
||||
f.write("$$\\mathbf{a}_t = W_{full}\\,\\phi_{full,t}$$\n")
|
||||
f.write("$$" + summary['reduction']['latex']['full_terms'] + "$$\n")
|
||||
f.write("$$W_{full}=" + summary['reduction']['latex']['W_full_mean'] + "$$\n")
|
||||
f.write(f"- 全特征拟合R2(本次重算, action均值): {full_fit['r2_mean']:.6f}\n\n")
|
||||
|
||||
f.write("- 约简拟合方程(best_chrono):\n")
|
||||
f.write("$$\\mathbf{a}_t = W_{red}\\,\\phi_{red,t}$$\n")
|
||||
f.write("$$" + summary['reduction']['latex']['reduced_terms'] + "$$\n")
|
||||
f.write("$$W_{red}=" + summary['reduction']['latex']['W_reduced_mean'] + "$$\n")
|
||||
f.write(f"- 约简拟合R2(本次重算, action均值): {reduced_fit['r2_mean']:.6f}\n\n")
|
||||
|
||||
f.write("## 4) 高维能力与现实约束反思\n")
|
||||
f.write(f"- 高维扫描样本数: {len(highdim_results)}\n")
|
||||
f.write(f"- holdout_r2<0 的case数: {highdim_neg_r2}/{len(highdim_results)}\n")
|
||||
f.write(f"- 首轮acq_gain<0 的case数: {highdim_neg_gain}/{len(highdim_results)}\n")
|
||||
f.write("- 解释: 高维下代理可辨识度不足时,Tree探索会被误导,出现边界漂移与收益停滞。\n")
|
||||
f.write("- 与论文对齐: DANTE强调DNN surrogate与自适应探索协同;在你的流体控制里,受噪声、时序漂移和高维参数化耦合影响,样本效率会显著下降。\n")
|
||||
f.write("- 双代理是否有帮助: 当前日志显示ensemble路径提供了可运行冗余(CNN失败时MLP兜底),但在v6_3其val_r2均值仍偏低,收益主要体现在稳定性而非显著提升最优值。\n")
|
||||
|
||||
print("saved:", summary_path)
|
||||
print("saved:", md_path)
|
||||
print("saved figs:", fig_v6_pca, fig_v7_pca, fig_ts, fig_omega)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,271 @@
|
||||
import gymnasium as gym
|
||||
import numpy as np
|
||||
from gymnasium import spaces
|
||||
from collections import deque
|
||||
from typing import Tuple
|
||||
import sys
|
||||
import os
|
||||
import queue
|
||||
|
||||
os.environ["OMP_NUM_THREADS"] = "1"
|
||||
os.environ["MKL_NUM_THREADS"] = "1"
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.abspath(os.path.join(current_dir, os.pardir))
|
||||
sys.path.append(parent_dir)
|
||||
from CelerisLab import FlowField
|
||||
from CelerisLab import utils
|
||||
|
||||
config_cuda = utils.load_cuda_config(
|
||||
os.path.join(parent_dir, "configs", "config_cuda.json")
|
||||
)
|
||||
config_field = utils.load_flow_field_config(
|
||||
os.path.join(parent_dir, "configs", "config_flowfield.json")
|
||||
)
|
||||
|
||||
S_DIM, A_DIM = 2, 3
|
||||
U0 = config_field.velocity
|
||||
SAMPLE_INTERVAL = 800
|
||||
FIFO_LEN = 150
|
||||
CONV_LEN = 36
|
||||
if config_field.data_type == "FP32":
|
||||
DATA_TYPE = np.float32
|
||||
else:
|
||||
raise ValueError(f"Unsupported data type {config_field.data_type}.")
|
||||
|
||||
|
||||
class CustomEnv(gym.Env):
|
||||
"""DANTE-only environment with deterministic reset semantics and numeric-failure surfacing."""
|
||||
|
||||
metadata = {"render_modes": ["human"], "render_fps": 1000 / SAMPLE_INTERVAL}
|
||||
|
||||
FAILURE_NONE = 0
|
||||
FAILURE_NUMERIC = 1
|
||||
FAILURE_NONFINITE_OBS = 2
|
||||
FAILURE_OBS_OOB = 3
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: int = 0,
|
||||
obs_fail_bound: float = 2.0,
|
||||
obs_clip_bound: float = 3.0,
|
||||
reward_weights: Tuple[float, float, float] = (0.3, 0.3, 0.4),
|
||||
):
|
||||
super().__init__()
|
||||
self.action_space = spaces.Box(low=-1, high=1, shape=(A_DIM,), dtype=DATA_TYPE)
|
||||
self.observation_space = spaces.Box(
|
||||
low=-obs_clip_bound,
|
||||
high=obs_clip_bound,
|
||||
shape=(S_DIM,),
|
||||
dtype=DATA_TYPE,
|
||||
)
|
||||
|
||||
self.obs_fail_bound = float(obs_fail_bound)
|
||||
self.obs_clip_bound = float(obs_clip_bound)
|
||||
rw = np.asarray(reward_weights, dtype=DATA_TYPE)
|
||||
if rw.size != 3:
|
||||
raise ValueError("reward_weights must have length 3: (cd, cl, sim)")
|
||||
rw_sum = float(np.sum(rw))
|
||||
if rw_sum <= 0:
|
||||
raise ValueError("reward_weights sum must be positive")
|
||||
self.reward_weights = rw / rw_sum
|
||||
|
||||
self.fifo_states = deque(maxlen=FIFO_LEN)
|
||||
self.target_states = np.empty((0, 6), dtype=DATA_TYPE)
|
||||
self.force_norm_fact = 1.0
|
||||
self.torque_norm_fact = 1.0
|
||||
self.sens_norm_fact = np.ones(6, dtype=DATA_TYPE)
|
||||
self.sens_deviation = np.zeros(6, dtype=DATA_TYPE)
|
||||
|
||||
self.reward_cd = 0.0
|
||||
self.reward_cl = 0.0
|
||||
self.reward_sim = 0.0
|
||||
self.current_step = 0
|
||||
|
||||
self.flow_field = FlowField(config_field, config_cuda, device_id)
|
||||
L0 = 20
|
||||
u0 = config_field.velocity
|
||||
nx = self.flow_field.FIELD_SHAPE[0]
|
||||
ny = self.flow_field.FIELD_SHAPE[1]
|
||||
|
||||
center: Tuple[float, float, float] = (10 * L0, (ny - 1) / 2, 0)
|
||||
self.flow_field.add_cylinder(center, L0)
|
||||
center = (40 * L0, (ny - 1) / 2 + 2 * L0, 0)
|
||||
self.flow_field.add_sensor(center, L0 / 4)
|
||||
center = (40 * L0, (ny - 1) / 2, 0)
|
||||
self.flow_field.add_sensor(center, L0 / 4)
|
||||
center = (40 * L0, (ny - 1) / 2 - 2 * L0, 0)
|
||||
self.flow_field.add_sensor(center, L0 / 4)
|
||||
self.flow_field.run(int(4 * nx / u0), np.zeros(4, dtype=DATA_TYPE))
|
||||
|
||||
for _ in range(FIFO_LEN):
|
||||
self.flow_field.run(SAMPLE_INTERVAL, np.zeros(4, dtype=DATA_TYPE))
|
||||
new_state = self.flow_field.obs.copy()[2:8]
|
||||
self.target_states = np.vstack((self.target_states, new_state))
|
||||
|
||||
center = (30 * L0, (ny - 1) / 2, 0)
|
||||
self.flow_field.add_cylinder(center, L0 / 2)
|
||||
center = (31.3 * L0, (ny - 1) / 2 + 0.75 * L0, 0)
|
||||
self.flow_field.add_cylinder(center, L0 / 2)
|
||||
center = (31.3 * L0, (ny - 1) / 2 - 0.75 * L0, 0)
|
||||
self.flow_field.add_cylinder(center, L0 / 2)
|
||||
self.flow_field.run(int(4 * nx / u0), np.zeros(7, dtype=DATA_TYPE))
|
||||
self.flow_field.get_ddf()
|
||||
self.flow_field.save_ddf()
|
||||
|
||||
for _ in range(FIFO_LEN):
|
||||
self.flow_field.run(SAMPLE_INTERVAL, np.zeros(7, dtype=DATA_TYPE))
|
||||
self.fifo_states.append(self.flow_field.obs.copy()[2:14])
|
||||
|
||||
temp_states = np.array(self.fifo_states)
|
||||
self.force_norm_fact = 6 * np.max(np.abs(temp_states[:, 6:12]))
|
||||
temp_torque = (
|
||||
-temp_states[:, 1]
|
||||
- temp_states[:, 2] * np.sqrt(3) / 2
|
||||
+ temp_states[:, 3] / 2
|
||||
+ temp_states[:, 4] * np.sqrt(3) / 2
|
||||
+ temp_states[:, 5] / 2
|
||||
)
|
||||
self.torque_norm_fact = 10 * np.max(np.abs(temp_torque))
|
||||
|
||||
for i in range(6):
|
||||
self.sens_deviation[i] = np.mean(temp_states[:, i])
|
||||
self.sens_norm_fact[i] = 5 * np.max(np.abs(temp_states[:, i] - self.sens_deviation[i]))
|
||||
|
||||
self.flow_field.apply_ddf()
|
||||
|
||||
for _ in range(FIFO_LEN):
|
||||
self.flow_field.run(
|
||||
SAMPLE_INTERVAL,
|
||||
np.array([0.0, 0.0, 0.0, 0.0, 0.0, -4 * u0, 4 * u0], dtype=DATA_TYPE),
|
||||
)
|
||||
self.fifo_states.append(self.flow_field.obs.copy()[2:14])
|
||||
|
||||
self.save_states = self.fifo_states.copy()
|
||||
# self.flow_field.apply_ddf()
|
||||
self.flow_field.get_ddf()
|
||||
self.flow_field.save_ddf()
|
||||
|
||||
def _calc_lag(self, target: np.ndarray, state: np.ndarray) -> int:
|
||||
target_mean = np.mean(target)
|
||||
state_mean = np.mean(state)
|
||||
correlation = np.correlate(target - target_mean, state - state_mean, "full")
|
||||
lags = np.arange(-len(target) + 1, len(target))
|
||||
return int(lags[np.argmax(correlation)])
|
||||
|
||||
def _calc_dtw_sim(self, target: np.ndarray, state: np.ndarray) -> float:
|
||||
n = len(target)
|
||||
m = len(state)
|
||||
dtw_matrix = np.full((n + 1, m + 1), np.inf)
|
||||
dtw_matrix[0, 0] = 0
|
||||
for i in range(1, n + 1):
|
||||
for j in range(1, m + 1):
|
||||
cost = abs(target[i - 1] - state[j - 1])
|
||||
last_min = min(
|
||||
dtw_matrix[i - 1, j],
|
||||
dtw_matrix[i, j - 1],
|
||||
dtw_matrix[i - 1, j - 1],
|
||||
)
|
||||
dtw_matrix[i, j] = cost + last_min
|
||||
return float(1 - (dtw_matrix[n, m] / len(target)))
|
||||
|
||||
def _compute_obs_reward(self):
|
||||
states = np.array(self.fifo_states)
|
||||
forces = states[-1, 6:12] / self.force_norm_fact
|
||||
|
||||
obs_drag = float((forces[0] + forces[2] + forces[4]) / 3)
|
||||
obs_lift = float((forces[1] + forces[3] + forces[5]) / 3)
|
||||
|
||||
similarities = 0.0
|
||||
id_sens = 1
|
||||
target_seq = self.target_states[CONV_LEN : 2 * CONV_LEN, id_sens]
|
||||
state_seq = states[-CONV_LEN:, id_sens]
|
||||
lag = self._calc_lag(target_seq, state_seq)
|
||||
|
||||
for i in range(0, 6):
|
||||
target_seq = np.roll(self.target_states[:, i], -lag)[CONV_LEN : 2 * CONV_LEN]
|
||||
state_seq = states[-CONV_LEN:, i]
|
||||
similarities += self._calc_dtw_sim(target_seq, state_seq) / 6
|
||||
|
||||
self.reward_cd = float(np.exp(-np.abs(obs_drag * 20)))
|
||||
self.reward_cl = float(np.exp(-np.abs(obs_lift * 80)))
|
||||
self.reward_sim = float(np.exp(-10 * np.abs(similarities - 1)))
|
||||
reward = float(
|
||||
np.minimum(
|
||||
self.reward_weights[0] * self.reward_cd
|
||||
+ self.reward_weights[1] * self.reward_cl
|
||||
+ self.reward_weights[2] * self.reward_sim,
|
||||
1.0,
|
||||
)
|
||||
)
|
||||
observation = np.array([forces[0], forces[1]], dtype=DATA_TYPE)
|
||||
return observation, reward
|
||||
|
||||
def step(self, action):
|
||||
assert self.action_space.contains(action), "%r (%s) invalid" % (
|
||||
action,
|
||||
type(action),
|
||||
)
|
||||
|
||||
result_queue = queue.Queue()
|
||||
|
||||
def run_flow_field(act):
|
||||
self.flow_field.context.push()
|
||||
u0 = config_field.velocity
|
||||
try:
|
||||
temp = np.zeros(7, dtype=DATA_TYPE)
|
||||
temp[4:7] = np.array((act * 8 + [0, -4, 4]) * u0, dtype=DATA_TYPE)
|
||||
self.flow_field.run(SAMPLE_INTERVAL, temp)
|
||||
finally:
|
||||
self.flow_field.context.pop()
|
||||
self.fifo_states.append(self.flow_field.obs.copy()[2:14])
|
||||
|
||||
def proc_data():
|
||||
result_queue.put(self._compute_obs_reward())
|
||||
|
||||
run_flow_field(action)
|
||||
|
||||
if self.flow_field.has_numeric_error():
|
||||
self.current_step += 1
|
||||
obs = np.zeros(S_DIM, dtype=DATA_TYPE)
|
||||
info = {
|
||||
"failure_code": self.FAILURE_NUMERIC,
|
||||
"numeric_error": True,
|
||||
"raw_obs": obs.copy(),
|
||||
}
|
||||
return obs, 0.0, False, True, info
|
||||
|
||||
proc_data()
|
||||
observation, reward = result_queue.get()
|
||||
raw_obs = np.asarray(observation, dtype=DATA_TYPE).copy()
|
||||
|
||||
failure_code = self.FAILURE_NONE
|
||||
if not np.all(np.isfinite(observation)):
|
||||
failure_code = self.FAILURE_NONFINITE_OBS
|
||||
elif np.any(np.abs(observation) > self.obs_fail_bound):
|
||||
failure_code = self.FAILURE_OBS_OOB
|
||||
|
||||
truncated = failure_code != self.FAILURE_NONE
|
||||
if truncated:
|
||||
reward = 0.0
|
||||
observation = np.zeros(S_DIM, dtype=DATA_TYPE)
|
||||
else:
|
||||
observation = np.clip(observation, -self.obs_clip_bound, self.obs_clip_bound)
|
||||
|
||||
self.current_step += 1
|
||||
info = {
|
||||
"failure_code": int(failure_code),
|
||||
"numeric_error": False,
|
||||
"raw_obs": raw_obs,
|
||||
}
|
||||
return observation.astype(np.float32), float(reward), False, bool(truncated), info
|
||||
|
||||
def reset(self, seed=None):
|
||||
self.flow_field.restore_ddf()
|
||||
self.flow_field.apply_ddf()
|
||||
self.fifo_states = self.save_states.copy()
|
||||
self.current_step = 0
|
||||
return np.zeros(S_DIM, dtype=np.float32), {}
|
||||
|
||||
def close(self):
|
||||
self.flow_field.__del__()
|
||||
@@ -0,0 +1,216 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
from sklearn.metrics import mean_absolute_error, r2_score
|
||||
from sklearn.model_selection import train_test_split
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "2")
|
||||
os.environ.setdefault("TF_FORCE_GPU_ALLOW_GROWTH", "true")
|
||||
|
||||
import tensorflow as tf
|
||||
from tensorflow.keras.callbacks import EarlyStopping
|
||||
from tensorflow.keras.layers import Conv1D, Dense, Dropout, Flatten, Input, MaxPooling1D
|
||||
from tensorflow.keras.models import Sequential
|
||||
from tensorflow.keras.optimizers import Adam
|
||||
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
|
||||
OUT_DIR = os.path.join(ROOT, "output", "report_dante_v2_v5_v6")
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
DB_PATH = os.path.join(ROOT, "output", "d1a3o12_250421_forces02_dante_v6_database_live.npz")
|
||||
DECISION_PATH = os.path.join(ROOT, "output", "d1a3o12_250421_forces02_dante_v6_1_structure_decision.json")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelSpec:
|
||||
name: str
|
||||
kind: str # mlp | cnn
|
||||
|
||||
|
||||
def set_tf_device(device_id: int = 1) -> str:
|
||||
gpus = tf.config.list_physical_devices("GPU")
|
||||
if not gpus:
|
||||
return "CPU"
|
||||
idx = int(max(0, min(len(gpus) - 1, device_id)))
|
||||
try:
|
||||
tf.config.set_visible_devices([gpus[idx]], "GPU")
|
||||
tf.config.experimental.set_memory_growth(gpus[idx], True)
|
||||
return f"GPU:{idx}"
|
||||
except RuntimeError:
|
||||
return f"GPU:{idx}(runtime_initialized)"
|
||||
|
||||
|
||||
def load_init_dataset() -> Tuple[np.ndarray, np.ndarray, Dict]:
|
||||
if not os.path.exists(DB_PATH):
|
||||
raise FileNotFoundError(DB_PATH)
|
||||
if not os.path.exists(DECISION_PATH):
|
||||
raise FileNotFoundError(DECISION_PATH)
|
||||
|
||||
with open(DECISION_PATH, "r", encoding="utf-8") as f:
|
||||
decision = json.load(f)
|
||||
|
||||
n_init = int(decision["num_initial"])
|
||||
db = np.load(DB_PATH, allow_pickle=True)
|
||||
x = np.asarray(db["input_x"], dtype=np.float64)
|
||||
y = np.asarray(db["input_y"], dtype=np.float64).reshape(-1)
|
||||
|
||||
if len(x) < n_init:
|
||||
raise RuntimeError(f"DB samples {len(x)} < num_initial {n_init}")
|
||||
|
||||
x0 = x[:n_init]
|
||||
y0 = y[:n_init]
|
||||
return x0, y0, decision
|
||||
|
||||
|
||||
def make_mlp(input_dims: int) -> Sequential:
|
||||
model = Sequential(
|
||||
[
|
||||
Input(shape=(input_dims,)),
|
||||
Dense(128, activation="elu"),
|
||||
Dropout(0.10),
|
||||
Dense(64, activation="elu"),
|
||||
Dropout(0.10),
|
||||
Dense(32, activation="elu"),
|
||||
Dense(1, activation="linear"),
|
||||
]
|
||||
)
|
||||
model.compile(optimizer=Adam(learning_rate=1e-3), loss="mse", metrics=["mae"])
|
||||
return model
|
||||
|
||||
|
||||
def make_cnn(input_dims: int) -> Sequential:
|
||||
model = Sequential(
|
||||
[
|
||||
Input(shape=(input_dims, 1)),
|
||||
Conv1D(128, kernel_size=3, padding="same", activation="elu"),
|
||||
MaxPooling1D(pool_size=2, strides=1),
|
||||
Dropout(0.2),
|
||||
Conv1D(64, kernel_size=3, padding="same", activation="elu"),
|
||||
MaxPooling1D(pool_size=2, strides=1),
|
||||
Dropout(0.2),
|
||||
Conv1D(32, kernel_size=3, padding="same", activation="elu"),
|
||||
Conv1D(16, kernel_size=3, padding="same", activation="elu"),
|
||||
Flatten(),
|
||||
Dense(64, activation="elu"),
|
||||
Dense(1, activation="linear"),
|
||||
]
|
||||
)
|
||||
model.compile(optimizer=Adam(learning_rate=1e-3), loss="mse", metrics=["mae"])
|
||||
return model
|
||||
|
||||
|
||||
def run_one_split(x: np.ndarray, y: np.ndarray, seed: int, spec: ModelSpec) -> Dict:
|
||||
x_tr, x_te, y_tr, y_te = train_test_split(x, y, test_size=0.30, random_state=seed, shuffle=True)
|
||||
|
||||
x_scaler = StandardScaler()
|
||||
y_scaler = StandardScaler()
|
||||
x_tr_s = x_scaler.fit_transform(x_tr)
|
||||
x_te_s = x_scaler.transform(x_te)
|
||||
y_tr_s = y_scaler.fit_transform(y_tr.reshape(-1, 1)).reshape(-1)
|
||||
|
||||
if spec.kind == "mlp":
|
||||
model = make_mlp(x.shape[1])
|
||||
xtr_in = x_tr_s
|
||||
xte_in = x_te_s
|
||||
elif spec.kind == "cnn":
|
||||
model = make_cnn(x.shape[1])
|
||||
xtr_in = x_tr_s.reshape(len(x_tr_s), x.shape[1], 1)
|
||||
xte_in = x_te_s.reshape(len(x_te_s), x.shape[1], 1)
|
||||
else:
|
||||
raise ValueError(spec.kind)
|
||||
|
||||
cb = [EarlyStopping(monitor="val_loss", patience=25, restore_best_weights=True)]
|
||||
hist = model.fit(
|
||||
xtr_in,
|
||||
y_tr_s,
|
||||
validation_split=0.25,
|
||||
batch_size=32,
|
||||
epochs=250,
|
||||
verbose=0,
|
||||
callbacks=cb,
|
||||
)
|
||||
|
||||
y_hat_s = model.predict(xte_in, verbose=0).reshape(-1, 1)
|
||||
y_hat = y_scaler.inverse_transform(y_hat_s).reshape(-1)
|
||||
|
||||
return {
|
||||
"seed": int(seed),
|
||||
"r2": float(r2_score(y_te, y_hat)),
|
||||
"mae": float(mean_absolute_error(y_te, y_hat)),
|
||||
"epochs": int(len(hist.history.get("loss", []))),
|
||||
"best_val_loss": float(np.min(hist.history.get("val_loss", [np.nan]))),
|
||||
}
|
||||
|
||||
|
||||
def summarize(rows: List[Dict]) -> Dict:
|
||||
r2 = np.array([r["r2"] for r in rows], dtype=np.float64)
|
||||
mae = np.array([r["mae"] for r in rows], dtype=np.float64)
|
||||
return {
|
||||
"r2_mean": float(np.mean(r2)),
|
||||
"r2_std": float(np.std(r2)),
|
||||
"mae_mean": float(np.mean(mae)),
|
||||
"mae_std": float(np.std(mae)),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
t0 = time.time()
|
||||
device = set_tf_device(1)
|
||||
x, y, decision = load_init_dataset()
|
||||
|
||||
specs = [
|
||||
ModelSpec(name="mlp_128_64_32", kind="mlp"),
|
||||
ModelSpec(name="cnn_paper_like", kind="cnn"),
|
||||
]
|
||||
seeds = [0, 1, 2, 3, 4]
|
||||
|
||||
results = {}
|
||||
for spec in specs:
|
||||
rows = [run_one_split(x, y, s, spec) for s in seeds]
|
||||
results[spec.name] = {
|
||||
"kind": spec.kind,
|
||||
"per_split": rows,
|
||||
"summary": summarize(rows),
|
||||
}
|
||||
|
||||
r2_mlp = results["mlp_128_64_32"]["summary"]["r2_mean"]
|
||||
r2_cnn = results["cnn_paper_like"]["summary"]["r2_mean"]
|
||||
|
||||
out = {
|
||||
"db_path": DB_PATH,
|
||||
"decision_path": DECISION_PATH,
|
||||
"device": device,
|
||||
"n_init": int(len(x)),
|
||||
"dims": int(x.shape[1]),
|
||||
"y_stats": {
|
||||
"mean": float(np.mean(y)),
|
||||
"std": float(np.std(y)),
|
||||
"min": float(np.min(y)),
|
||||
"max": float(np.max(y)),
|
||||
},
|
||||
"models": results,
|
||||
"delta": {
|
||||
"r2_cnn_minus_mlp": float(r2_cnn - r2_mlp),
|
||||
"better_model": "cnn_paper_like" if r2_cnn > r2_mlp else "mlp_128_64_32",
|
||||
},
|
||||
"elapsed_sec": float(time.time() - t0),
|
||||
}
|
||||
|
||||
out_json = os.path.join(OUT_DIR, "v6_1_init_surrogate_mlp_vs_cnn.json")
|
||||
with open(out_json, "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print("saved", out_json)
|
||||
print("better_model", out["delta"]["better_model"])
|
||||
print("delta_r2", out["delta"]["r2_cnn_minus_mlp"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,318 @@
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
from sklearn.decomposition import PCA
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
OUT_DIR = os.path.join(ROOT, "output", "report_dante_v2_v5_v6")
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
SEEDS = [11, 29, 47]
|
||||
|
||||
V6_NAME = "d1a3o12_250421_forces02_dante_v6_2"
|
||||
V7_NAME = "d1a3o12_250421_forces02_dante_v7"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunData:
|
||||
name: str
|
||||
x: np.ndarray
|
||||
y: np.ndarray
|
||||
num_initial: int
|
||||
dims: int
|
||||
cfg: Dict
|
||||
|
||||
|
||||
def load_run(name: str) -> RunData:
|
||||
db_path = os.path.join(ROOT, "output", f"{name}_database_live.npz")
|
||||
cfg_path = os.path.join(ROOT, "output", f"{name}_structure_decision.json")
|
||||
|
||||
z = np.load(db_path, allow_pickle=True)
|
||||
x = np.asarray(z["input_x"], dtype=np.float64)
|
||||
y = np.asarray(z["input_y"], dtype=np.float64)
|
||||
cfg = json.load(open(cfg_path, "r", encoding="utf-8"))
|
||||
|
||||
return RunData(
|
||||
name=name,
|
||||
x=x,
|
||||
y=y,
|
||||
num_initial=int(cfg["num_initial"]),
|
||||
dims=int(cfg["controller_dims"]),
|
||||
cfg=cfg,
|
||||
)
|
||||
|
||||
|
||||
def feature_dict(obs_t: np.ndarray, obs_prev: np.ndarray, act_prev: np.ndarray) -> Dict[str, float]:
|
||||
o0, o1 = float(obs_t[0]), float(obs_t[1])
|
||||
p0, p1 = float(obs_prev[0]), float(obs_prev[1])
|
||||
a0, a1, a2 = float(act_prev[0]), float(act_prev[1]), float(act_prev[2])
|
||||
return {
|
||||
"obs0": o0,
|
||||
"obs1": o1,
|
||||
"dobs0": o0 - p0,
|
||||
"dobs1": o1 - p1,
|
||||
"sin_obs0": float(np.sin(np.pi * o0)),
|
||||
"sin_obs1": float(np.sin(np.pi * o1)),
|
||||
"cos_obs0": float(np.cos(np.pi * o0)),
|
||||
"cos_obs1": float(np.cos(np.pi * o1)),
|
||||
"tanh_obs0": float(np.tanh(o0)),
|
||||
"tanh_obs1": float(np.tanh(o1)),
|
||||
"act0_l1": a0,
|
||||
"act1_l1": a1,
|
||||
"act2_l1": a2,
|
||||
}
|
||||
|
||||
|
||||
def inv_tanh_map(q: float) -> float:
|
||||
qq = float(np.clip(q, -0.999, 0.999))
|
||||
x = np.arctanh(qq) / 1.25
|
||||
return float(np.clip(x, -1.0, 1.0))
|
||||
|
||||
|
||||
def ppo_point_for_v6(seed: int, basis_terms: List[str]) -> np.ndarray:
|
||||
p = os.path.join(ROOT, "output", "report_dante_v2_v5_v6", f"raw_oldenv_seed_{seed}.npz")
|
||||
z = np.load(p)
|
||||
obs = np.asarray(z["ppo_obs"], dtype=np.float64)
|
||||
act = np.asarray(z["ppo_actions"], dtype=np.float64)
|
||||
|
||||
rows_x = []
|
||||
rows_y = []
|
||||
for t in range(1, len(obs)):
|
||||
fd = feature_dict(obs[t], obs[t - 1], act[t - 1])
|
||||
row = [1.0] + [float(fd[k]) for k in basis_terms]
|
||||
rows_x.append(row)
|
||||
rows_y.append(act[t])
|
||||
|
||||
X = np.asarray(rows_x, dtype=np.float64)
|
||||
Y = np.asarray(rows_y, dtype=np.float64)
|
||||
|
||||
params = []
|
||||
for ch in range(3):
|
||||
coef, *_ = np.linalg.lstsq(X, Y[:, ch], rcond=None)
|
||||
bias = float(coef[0])
|
||||
params.append(inv_tanh_map(bias / 1.0))
|
||||
for c in coef[1:]:
|
||||
params.append(inv_tanh_map(float(c) / 2.0))
|
||||
|
||||
return np.asarray(params, dtype=np.float64)
|
||||
|
||||
|
||||
def phase_weights(phase: float, k: int) -> np.ndarray:
|
||||
z = float(np.mod(phase, 1.0)) * k
|
||||
i0 = int(np.floor(z)) % k
|
||||
frac = float(z - np.floor(z))
|
||||
i1 = (i0 + 1) % k
|
||||
w = np.zeros(k, dtype=np.float64)
|
||||
w[i0] += (1.0 - frac)
|
||||
w[i1] += frac
|
||||
return w
|
||||
|
||||
|
||||
def ppo_point_for_v7(seed: int, k: int, period_steps: float) -> np.ndarray:
|
||||
p = os.path.join(ROOT, "output", "report_dante_v2_v5_v6", f"raw_oldenv_seed_{seed}.npz")
|
||||
z = np.load(p)
|
||||
act = np.asarray(z["ppo_actions"], dtype=np.float64)
|
||||
|
||||
n = int(act.shape[0])
|
||||
phase = 0.0
|
||||
step_phase = 1.0 / max(1e-6, float(period_steps))
|
||||
|
||||
W = np.zeros((n, k), dtype=np.float64)
|
||||
for t in range(n):
|
||||
W[t] = phase_weights(phase, k)
|
||||
phase = (phase + step_phase) % 1.0
|
||||
|
||||
params = []
|
||||
for ch in range(3):
|
||||
coef, *_ = np.linalg.lstsq(W, act[:, ch], rcond=None)
|
||||
coef = np.clip(coef, -1.0, 1.0)
|
||||
params.extend(coef.tolist())
|
||||
|
||||
return np.asarray(params, dtype=np.float64)
|
||||
|
||||
|
||||
def fit_pca_and_project(x: np.ndarray, extra: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
scaler = StandardScaler()
|
||||
xs = scaler.fit_transform(x)
|
||||
extra_s = scaler.transform(extra)
|
||||
|
||||
pca = PCA(n_components=2, random_state=0)
|
||||
x2 = pca.fit_transform(xs)
|
||||
extra2 = pca.transform(extra_s)
|
||||
return x2, extra2, pca.explained_variance_ratio_
|
||||
|
||||
|
||||
def distance_metrics(x: np.ndarray, y: np.ndarray, ppo_points: np.ndarray, n0: int) -> Dict:
|
||||
centroid = np.mean(ppo_points, axis=0)
|
||||
d = np.linalg.norm(x - centroid.reshape(1, -1), axis=1)
|
||||
|
||||
n = len(d)
|
||||
xx = np.arange(n, dtype=np.float64)
|
||||
slope = float(np.polyfit(xx, d, deg=1)[0]) if n >= 2 else 0.0
|
||||
|
||||
init_mean = float(np.mean(d[:n0])) if n0 > 0 else float(np.mean(d))
|
||||
late_mean = float(np.mean(d[n0:])) if n > n0 else init_mean
|
||||
|
||||
thr = np.quantile(y, 0.9)
|
||||
idx_hi = np.where(y >= thr)[0]
|
||||
hi_mean = float(np.mean(d[idx_hi])) if len(idx_hi) > 0 else float("nan")
|
||||
|
||||
return {
|
||||
"init_mean": init_mean,
|
||||
"late_mean": late_mean,
|
||||
"late_minus_init": float(late_mean - init_mean),
|
||||
"slope": slope,
|
||||
"high_reward_dist_mean": hi_mean,
|
||||
"overall_dist_mean": float(np.mean(d)),
|
||||
"distance": d.tolist(),
|
||||
"ppo_centroid": centroid.tolist(),
|
||||
}
|
||||
|
||||
|
||||
def plot_pca(
|
||||
run_name: str,
|
||||
x2: np.ndarray,
|
||||
y: np.ndarray,
|
||||
ppo2: np.ndarray,
|
||||
evr: np.ndarray,
|
||||
out_png: str,
|
||||
) -> None:
|
||||
plt.figure(figsize=(8, 6))
|
||||
sc = plt.scatter(x2[:, 0], x2[:, 1], c=y, cmap="viridis", s=12, alpha=0.75)
|
||||
plt.colorbar(sc, label="reward_scaled")
|
||||
|
||||
colors = ["#e41a1c", "#377eb8", "#ff7f00"]
|
||||
for i, seed in enumerate(SEEDS):
|
||||
plt.scatter(
|
||||
[ppo2[i, 0]],
|
||||
[ppo2[i, 1]],
|
||||
s=120,
|
||||
c=colors[i],
|
||||
marker="*",
|
||||
edgecolors="k",
|
||||
linewidths=0.9,
|
||||
label=f"PPO seed {seed}",
|
||||
zorder=6,
|
||||
)
|
||||
|
||||
plt.title(
|
||||
f"{run_name} PCA with PPO points\\n"
|
||||
f"PC1 {evr[0]*100:.1f}% | PC2 {evr[1]*100:.1f}%"
|
||||
)
|
||||
plt.xlabel("PC1")
|
||||
plt.ylabel("PC2")
|
||||
plt.legend(loc="best", fontsize=9)
|
||||
plt.grid(alpha=0.25)
|
||||
plt.tight_layout()
|
||||
plt.savefig(out_png, dpi=170)
|
||||
plt.close()
|
||||
|
||||
|
||||
def plot_distance_curve(run_name: str, d: np.ndarray, n0: int, out_png: str) -> None:
|
||||
n = len(d)
|
||||
x = np.arange(1, n + 1)
|
||||
plt.figure(figsize=(9, 4.8))
|
||||
plt.plot(x, d, lw=1.4, color="#1f77b4")
|
||||
plt.axvline(n0, color="k", ls="--", lw=1.0, label=f"init end @ {n0}")
|
||||
plt.title(f"{run_name}: distance to PPO centroid")
|
||||
plt.xlabel("sample order")
|
||||
plt.ylabel("L2 distance")
|
||||
plt.grid(alpha=0.25)
|
||||
plt.legend(loc="best")
|
||||
plt.tight_layout()
|
||||
plt.savefig(out_png, dpi=170)
|
||||
plt.close()
|
||||
|
||||
|
||||
def diagnose(run_name: str, m: Dict) -> Dict:
|
||||
outward = bool(m["late_minus_init"] > 0.0 and m["slope"] > 0.0)
|
||||
high_reward_near = bool(m["high_reward_dist_mean"] < m["overall_dist_mean"])
|
||||
|
||||
return {
|
||||
"run": run_name,
|
||||
"outward_sampling_still_exists": outward,
|
||||
"high_reward_closer_to_ppo_than_overall": high_reward_near,
|
||||
"metrics": {
|
||||
"late_minus_init": float(m["late_minus_init"]),
|
||||
"slope": float(m["slope"]),
|
||||
"high_reward_dist_mean": float(m["high_reward_dist_mean"]),
|
||||
"overall_dist_mean": float(m["overall_dist_mean"]),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
v6 = load_run(V6_NAME)
|
||||
v7 = load_run(V7_NAME)
|
||||
|
||||
basis_terms = list(v6.cfg["basis_terms"])
|
||||
v6_ppo = np.vstack([ppo_point_for_v6(s, basis_terms) for s in SEEDS])
|
||||
|
||||
k = int(v7.cfg["control_points_per_channel"])
|
||||
period_steps = float(v7.cfg["period_steps"])
|
||||
v7_ppo = np.vstack([ppo_point_for_v7(s, k=k, period_steps=period_steps) for s in SEEDS])
|
||||
|
||||
v6_x2, v6_ppo2, v6_evr = fit_pca_and_project(v6.x, v6_ppo)
|
||||
v7_x2, v7_ppo2, v7_evr = fit_pca_and_project(v7.x, v7_ppo)
|
||||
|
||||
v6_m = distance_metrics(v6.x, v6.y, v6_ppo, v6.num_initial)
|
||||
v7_m = distance_metrics(v7.x, v7.y, v7_ppo, v7.num_initial)
|
||||
|
||||
p_v6_pca = os.path.join(OUT_DIR, "v6_2_pca_with_ppo_points.png")
|
||||
p_v7_pca = os.path.join(OUT_DIR, "v7_pca_with_ppo_points.png")
|
||||
p_v6_dist = os.path.join(OUT_DIR, "v6_2_distance_order_vs_ppo_centroid.png")
|
||||
p_v7_dist = os.path.join(OUT_DIR, "v7_distance_order_vs_ppo_centroid.png")
|
||||
|
||||
plot_pca(v6.name, v6_x2, v6.y, v6_ppo2, v6_evr, p_v6_pca)
|
||||
plot_pca(v7.name, v7_x2, v7.y, v7_ppo2, v7_evr, p_v7_pca)
|
||||
plot_distance_curve(v6.name, np.asarray(v6_m["distance"]), v6.num_initial, p_v6_dist)
|
||||
plot_distance_curve(v7.name, np.asarray(v7_m["distance"]), v7.num_initial, p_v7_dist)
|
||||
|
||||
summary = {
|
||||
"v6": {
|
||||
"name": v6.name,
|
||||
"dims": int(v6.dims),
|
||||
"num_samples": int(len(v6.y)),
|
||||
"num_initial": int(v6.num_initial),
|
||||
"best_reward": float(np.max(v6.y) / 100.0),
|
||||
"distance_metrics": {k: v for k, v in v6_m.items() if k != "distance"},
|
||||
"diagnosis": diagnose(v6.name, v6_m),
|
||||
},
|
||||
"v7": {
|
||||
"name": v7.name,
|
||||
"dims": int(v7.dims),
|
||||
"num_samples": int(len(v7.y)),
|
||||
"num_initial": int(v7.num_initial),
|
||||
"best_reward": float(np.max(v7.y) / 100.0),
|
||||
"period_steps": period_steps,
|
||||
"distance_metrics": {k: v for k, v in v7_m.items() if k != "distance"},
|
||||
"diagnosis": diagnose(v7.name, v7_m),
|
||||
},
|
||||
"figures": {
|
||||
"v6_pca": p_v6_pca,
|
||||
"v6_distance": p_v6_dist,
|
||||
"v7_pca": p_v7_pca,
|
||||
"v7_distance": p_v7_dist,
|
||||
},
|
||||
}
|
||||
|
||||
out_json = os.path.join(OUT_DIR, "v6_v7_pca_distance_with_ppo_summary.json")
|
||||
with open(out_json, "w", encoding="utf-8") as f:
|
||||
json.dump(summary, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print("saved", out_json)
|
||||
print(json.dumps({
|
||||
"v6_late_minus_init": summary["v6"]["distance_metrics"]["late_minus_init"],
|
||||
"v6_slope": summary["v6"]["distance_metrics"]["slope"],
|
||||
"v7_late_minus_init": summary["v7"]["distance_metrics"]["late_minus_init"],
|
||||
"v7_slope": summary["v7"]["distance_metrics"]["slope"],
|
||||
}, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
# v6/v7/PPO 综合简报
|
||||
|
||||
## 1) PCA(含3个PPO拟合点)
|
||||
- v6图: /home/frank14f/Frank_LBM/output/report_dante_v2_v5_v6/brief_v6_3_pca_with_ppo3.png
|
||||
- v7图: /home/frank14f/Frank_LBM/output/report_dante_v2_v5_v6/brief_v7_1_pca_with_ppo3.png
|
||||
|
||||
## 2) obs-act时序 + 最终涡量
|
||||
- 时序图: /home/frank14f/Frank_LBM/output/report_dante_v2_v5_v6/brief_v6_v7_ppo_obs_act_time.png
|
||||
- 最终涡量图: /home/frank14f/Frank_LBM/output/report_dante_v2_v5_v6/brief_v6_v7_ppo_final_vorticity.png
|
||||
- 涡量统一色条范围: ±0.003432(按三者全局|omega|max的10%)
|
||||
- PPO时序选用seed=11(tail100=0.53995)
|
||||
|
||||
## 3) 函数约简、关键函数、最终组合
|
||||
- 全特征模型平均R2: 0.955101
|
||||
- 约束搜索best_chrono基函数: ['obs1', 'sin_obs0', 'cos_obs0', 'act1_l1']
|
||||
- best_chrono平均R2: 0.957993
|
||||
- best_chrono最差seed R2: 0.948454
|
||||
- 关键函数解释: obs1给出主状态幅值,sin/cos(pi*obs0)提供周期相位,act1_l1提供单步记忆。
|
||||
|
||||
### 学到方程与拟合方程(LaTeX)
|
||||
- 全特征学到方程(3动作联合线性写法):
|
||||
$$\mathbf{a}_t = W_{full}\,\phi_{full,t}$$
|
||||
$$\phi_{full}=[1,obs_0,obs_1,\Delta obs_0,\Delta obs_1,\sin(\pi obs_0),\sin(\pi obs_1),\cos(\pi obs_0),\cos(\pi obs_1),\tanh(obs_0),\tanh(obs_1),a_{0,t-1},a_{1,t-1},a_{2,t-1}]^\top$$
|
||||
$$W_{full}=\begin{bmatrix}0.1571 & 1613.4234 & 127.6118 & -0.0178 & -0.0231 & 132.0173 & 10.3495 & -0.1619 & -0.0087 & -2028.1743 & -159.3687 & -0.0094 & 0.0129 & 0.0130 \\ 0.0229 & 6369.5027 & -875.1549 & 0.0028 & 0.0395 & 516.7321 & -71.4089 & -0.0922 & -0.0001 & -7993.1110 & 1099.0137 & 0.0506 & -0.0346 & 0.0110 \\ 0.0565 & -1065.8604 & 421.0853 & 0.0500 & -0.0717 & -86.0829 & 34.4277 & -0.0099 & 0.0210 & 1336.5921 & -529.4751 & -0.0876 & 0.0107 & 0.0159\end{bmatrix}$$
|
||||
- 全特征拟合R2(本次重算, action均值): 0.962039
|
||||
|
||||
- 约简拟合方程(best_chrono):
|
||||
$$\mathbf{a}_t = W_{red}\,\phi_{red,t}$$
|
||||
$$\phi_{red}=[1,obs_1,\sin(\pi obs_0),\cos(\pi obs_0),a_{1,t-1}]^\top$$
|
||||
$$W_{red}=\begin{bmatrix}-0.0131 & 0.7323 & 0.0014 & 0.0008 & -0.0115 \\ -0.0128 & -0.3989 & -0.0799 & -0.0552 & -0.0168 \\ 0.0312 & -0.3286 & 0.0996 & 0.0348 & 0.0018\end{bmatrix}$$
|
||||
- 约简拟合R2(本次重算, action均值): 0.960987
|
||||
|
||||
## 4) 高维能力与现实约束反思
|
||||
- 高维扫描样本数: 6
|
||||
- holdout_r2<0 的case数: 6/6
|
||||
- 首轮acq_gain<0 的case数: 6/6
|
||||
- 解释: 高维下代理可辨识度不足时,Tree探索会被误导,出现边界漂移与收益停滞。
|
||||
- 与论文对齐: DANTE强调DNN surrogate与自适应探索协同;在你的流体控制里,受噪声、时序漂移和高维参数化耦合影响,样本效率会显著下降。
|
||||
- 双代理是否有帮助: 当前日志显示ensemble路径提供了可运行冗余(CNN失败时MLP兜底),但在v6_3其val_r2均值仍偏低,收益主要体现在稳定性而非显著提升最优值。
|
||||
@@ -0,0 +1,434 @@
|
||||
# v6/v7 Zero-Base Re-Audit (2026-03-23)
|
||||
|
||||
## 1) User Goal (frozen)
|
||||
|
||||
- Core objective: use DANTE to solve a CFD control problem under expensive evaluations.
|
||||
- Key transformation: convert control policy search into low-sample parameter optimization.
|
||||
- Practical constraints:
|
||||
- one run is very expensive (about one day), so no destructive trial-and-error on running jobs;
|
||||
- surrogate must be trainable from small initial database;
|
||||
- acquisition should discover high-reward basin instead of drifting to easy-to-fit boundary regions.
|
||||
|
||||
## 2) Paper-to-Task Mapping (DANTE original intent)
|
||||
|
||||
From `DANTE/paper/s43588-025-00858-x.md`:
|
||||
|
||||
- DANTE is designed for non-cumulative objective optimization with limited data.
|
||||
- Key mechanisms are:
|
||||
- DUCB exploration term based on visit counts and surrogate value;
|
||||
- conditional selection (avoid value deterioration);
|
||||
- local backpropagation of visits;
|
||||
- adaptive exploration scaling;
|
||||
- top-visit + high-score mixed sampling.
|
||||
- Paper also emphasizes DNN surrogate expressivity as a key success factor.
|
||||
- For control tasks (paper lunar landing case), conversion is done by fixing initial condition and optimizing pre-designed action parameterization.
|
||||
|
||||
Interpretation for this project:
|
||||
|
||||
- the controller parameterization quality is first-order (decides landscape smoothness and identifiability);
|
||||
- surrogate quality under small data is second-order but still critical;
|
||||
- if parameterization induces heavy truncation/failure regions, DANTE will tend to exploit boundary patterns and stall locally.
|
||||
|
||||
## 3) Original DANTE Code Baseline (reference behavior)
|
||||
|
||||
From `DANTE/dante/tree_exploration.py`:
|
||||
|
||||
- Tree expansion mutates one or multiple dimensions with discrete step `turn`.
|
||||
- Choose step uses UCB-like criterion with `value + exploration_weight * sqrt(logN/(n+1))`.
|
||||
- Conditional selection exists: continue with root unless child UCB exceeds root.
|
||||
- Local backpropagation is implemented as local visit count update (`self.N[path] += 1`).
|
||||
- Candidate set mixes:
|
||||
- most visited nodes,
|
||||
- top predicted nodes,
|
||||
- random nodes.
|
||||
|
||||
From `DANTE/dante/neural_surrogate.py`:
|
||||
|
||||
- Surrogate design is deep Conv1D-heavy, matching paper claim.
|
||||
|
||||
Important baseline implication:
|
||||
|
||||
- DANTE search quality assumes surrogate can provide stable relative ranking.
|
||||
- If surrogate fitting is unstable/biased, UCB dynamics may push toward artificial easy zones (often boundaries).
|
||||
|
||||
## 4) v6/v7 Parameterization Audit
|
||||
|
||||
### v6 (closed-loop basis controller)
|
||||
|
||||
From `scripts/d1a3o12_250421_dante_v6.py`:
|
||||
|
||||
- parameterization: per-action bias + basis coefficients (compact basis profile), total dims = 18 in current run;
|
||||
- controller includes derivative and one-step action history terms (`dobs*`, `act*_l1`), introducing piecewise/non-smooth response wrt parameters;
|
||||
- candidate evaluation uses hard reset and truncation-aware fallback;
|
||||
- invalid sample (`failure_code != 0`) is not added to training set.
|
||||
|
||||
Potential risk:
|
||||
|
||||
- derivative/history terms can create sensitive local discontinuities under rollout + truncation, making small-data surrogate fitting harder.
|
||||
|
||||
### v7 (open-loop periodic)
|
||||
|
||||
From `scripts/d1a3o12_250421_dante_v7_openloop.py`:
|
||||
|
||||
- parameterization: direct periodic control points, dims = 24 (3 channels * 8 control points), optional period parameter;
|
||||
- non-integer period supported via continuous phase accumulation;
|
||||
- period initialized from PPO data FFT median (`period_steps ~= 15.789` currently).
|
||||
|
||||
Expected advantage:
|
||||
|
||||
- objective wrt parameters is smoother than closed-loop derivative/history mapping;
|
||||
- better surrogate learnability under limited data.
|
||||
|
||||
## 5) Live Evidence From Current Runs
|
||||
|
||||
Data extracted from:
|
||||
|
||||
- `output/d1a3o12_250421_forces02_dante_v6_3_database_live.npz`
|
||||
- `output/d1a3o12_250421_forces02_dante_v6_3_dante_log.csv`
|
||||
- `output/d1a3o12_250421_forces02_dante_v7_1_database_live.npz`
|
||||
- `output/d1a3o12_250421_forces02_dante_v7_1_dante_log.csv`
|
||||
- runtime logs: `scripts/nohup_dante_v6.out`, `scripts/nohup_dante_v7.out`
|
||||
|
||||
### 5.1 Boundary drift (major)
|
||||
|
||||
v6_3:
|
||||
|
||||
- init boundary ratio `|x|>=0.95`: 0.0744
|
||||
- acquired boundary ratio `|x|>=0.95`: 0.5342
|
||||
- init exact boundary `|x|==1`: 0.0225
|
||||
- acquired exact boundary `|x|==1`: 0.5010
|
||||
|
||||
v7_1:
|
||||
|
||||
- init boundary ratio `|x|>=0.95`: 0.0727
|
||||
- acquired boundary ratio `|x|>=0.95`: 0.4485
|
||||
- init exact boundary `|x|==1`: 0.0247
|
||||
- acquired exact boundary `|x|==1`: 0.4293
|
||||
|
||||
Conclusion:
|
||||
|
||||
- both v6 and v7 still show strong boundary-seeking collapse;
|
||||
- v7 is better than v6 but problem remains.
|
||||
|
||||
### 5.2 Initial database learnability vs later acq
|
||||
|
||||
v6_3:
|
||||
|
||||
- init scaled reward mean/max: 13.2496 / 29.3712
|
||||
- acquired scaled reward mean/max: 14.4590 / 25.3012
|
||||
|
||||
Interpretation:
|
||||
|
||||
- acquisition improves mean a little, but fails to exceed init max;
|
||||
- indicates poor exploration of true high-reward basin (or surrogate ranking mismatch).
|
||||
|
||||
v7_1:
|
||||
|
||||
- init scaled reward mean/max: 17.4189 / 33.5647
|
||||
- acquired scaled reward mean/max: 19.2403 / 36.9052
|
||||
|
||||
Interpretation:
|
||||
|
||||
- v7 acquisition can surpass init max, consistent with smoother parameterization.
|
||||
|
||||
### 5.3 Invalid/truncation pressure
|
||||
|
||||
v6_3:
|
||||
|
||||
- invalid ratio in dante_log: 0.3626 (194 / 535)
|
||||
|
||||
v7_1:
|
||||
|
||||
- invalid ratio in dante_log: 0.0 (0 / 401)
|
||||
|
||||
Interpretation:
|
||||
|
||||
- v6 landscape is heavily constrained by invalid regions, harming surrogate data quality;
|
||||
- v7 reduces this burden substantially.
|
||||
|
||||
### 5.4 Surrogate quality and cuDNN symptom
|
||||
|
||||
v6 log (`nohup_dante_v6.out`):
|
||||
|
||||
- repeated `surrogate cnn failed: cuDNN ...`;
|
||||
- val_r2 stats: mean -0.3763, max 0.0820, min -1.9995.
|
||||
|
||||
v7 log (`nohup_dante_v7.out`):
|
||||
|
||||
- repeated cnn fail still present;
|
||||
- selected architecture mostly `mlp`;
|
||||
- val_r2 stats: mean 0.0790, max 0.3766, min -0.2300.
|
||||
|
||||
Interpretation:
|
||||
|
||||
- v6 fitting is notably weak;
|
||||
- v7 fitting is better but still fragile;
|
||||
- cnn failure currently forces implicit fallback behavior and noisy model-selection dynamics.
|
||||
|
||||
## 6) Root-Cause Stack (ordered)
|
||||
|
||||
### RC-1: Parameterization-induced landscape hardness (primary)
|
||||
|
||||
- v6 closed-loop basis with derivative/history terms + truncation recovery introduces high local nonlinearity and effective discontinuities.
|
||||
- This directly raises surrogate fitting difficulty from small initial data.
|
||||
|
||||
### RC-2: Search distribution collapse to boundaries (primary)
|
||||
|
||||
- acquisition increasingly samples boundary points where surrogate/DUCB can maintain confidence but true objective improvement is limited.
|
||||
- consistent with "easy-to-fit but locally suboptimal" phenomenon described by user.
|
||||
|
||||
### RC-3: Surrogate architecture instability on this runtime (secondary but severe)
|
||||
|
||||
- CNN path repeatedly fails in runtime logs (`CUDNN_STATUS_MAPPING_ERROR`), creating inconsistent ensemble behavior.
|
||||
|
||||
### RC-4: DANTE defaults not retuned for this control manifold (secondary)
|
||||
|
||||
- current `TreeExploration` defaults (`ratio`, `num_list`, rollout schedule) are inherited from generic synthetic settings;
|
||||
- not yet adapted to constrained CFD control manifold where invalid-region pressure is high.
|
||||
|
||||
## 7) Gap vs Paper Design (why drift happens)
|
||||
|
||||
- Paper success assumes expressive and stable DNN surrogate; current runtime repeatedly disables CNN branch in practice.
|
||||
- Paper uses adaptive exploration with data-driven scaling; current runs mostly use fixed defaults, lacking targeted anti-collapse constraints.
|
||||
- Paper top-visit sampling helps diversity; but when candidate generation is already boundary-dominated, top-visit can reinforce collapse.
|
||||
|
||||
This is not a contradiction of DANTE; it is a mismatch between:
|
||||
|
||||
- control parameterization geometry,
|
||||
- runtime surrogate stability,
|
||||
- and search hyperparameters calibrated for this geometry.
|
||||
|
||||
## 8) Immediate Non-Destructive Plan (no killing running jobs)
|
||||
|
||||
1. Continue current jobs untouched; only monitor and collect diagnostics.
|
||||
2. Build an offline replay audit from existing `database_live + dante_log`:
|
||||
- per-acq boundary ratio trend,
|
||||
- per-acq surrogate r2 trend,
|
||||
- per-acq invalid ratio trend,
|
||||
- best-so-far progression.
|
||||
3. Design v6.1/v7.1 candidates as code patches only (not executed yet):
|
||||
- explicit anti-boundary regularization in candidate filter or score,
|
||||
- tree expansion step schedule tied to valid-region occupancy,
|
||||
- manifold-aware initialization (seed around top PPO + noise, not pure uniform only).
|
||||
4. Run tiny dry-run diagnostics on copied DB (no CFD call) to test whether modified acquisition reduces boundary concentration.
|
||||
5. Only after user确认, start a new expensive run.
|
||||
|
||||
## 9) Frozen Facts (for anti-forget)
|
||||
|
||||
- User explicitly disallows interrupting expensive ongoing runs.
|
||||
- Main blocker hierarchy:
|
||||
1) initial DB hard to fit,
|
||||
2) best region not reached,
|
||||
3) DANTE drifts to boundaries and gets local-trapped.
|
||||
- Core mission is control-to-parameter conversion quality, not just fixing runtime errors.
|
||||
|
||||
---
|
||||
|
||||
This file is the session source-of-truth for re-audit decisions and will be continuously appended.
|
||||
|
||||
## 10) Additional Mismatch Checks (new)
|
||||
|
||||
### 10.1 Batch-size mismatch vs paper regime
|
||||
|
||||
Paper states small-batch active loop (`batch size <= 20`) for efficient convergence in scarce-data settings.
|
||||
|
||||
Current runs:
|
||||
|
||||
- v6: `samples_per_acq = 18` (within paper range)
|
||||
- v7: `samples_per_acq = 24` (outside paper range)
|
||||
|
||||
Risk:
|
||||
|
||||
- larger batch can over-commit to one surrogate snapshot and reduce corrective feedback frequency;
|
||||
- this can reinforce boundary drift when surrogate ranking is biased.
|
||||
|
||||
### 10.2 Exploration hyperparameters are generic defaults
|
||||
|
||||
`TreeExploration` is instantiated with default settings, not task-adapted settings:
|
||||
|
||||
- fixed `ratio`, fixed `num_list`, fixed rollout schedule;
|
||||
- no explicit anti-boundary penalty;
|
||||
- no validity-aware expansion constraints.
|
||||
|
||||
Risk:
|
||||
|
||||
- defaults derived from synthetic objective settings may not transfer to CFD control landscape with truncation boundaries.
|
||||
|
||||
### 10.3 Objective uses valid-only dataset update
|
||||
|
||||
Current logic drops invalid samples from surrogate training.
|
||||
|
||||
Benefit:
|
||||
|
||||
- avoids contaminating reward regression with hard-zero artifacts.
|
||||
|
||||
Cost:
|
||||
|
||||
- surrogate receives no direct supervision of boundary-danger zones;
|
||||
- acquisition can repeatedly propose invalid-adjacent points before feedback correction.
|
||||
|
||||
### 10.4 PPO-derived period estimate is stable (not a major uncertainty)
|
||||
|
||||
From three seeds and three channels, dominant period is consistently `15.789`.
|
||||
|
||||
Implication:
|
||||
|
||||
- v7 underperformance is not caused by noisy period inference;
|
||||
- main issue remains search distribution + surrogate dynamics.
|
||||
|
||||
## 11) Parameter-Optimization Reformulation Guidance (for next version design)
|
||||
|
||||
The main design question is not "which optimizer" but "which parameter manifold makes reward smooth and identifiable".
|
||||
|
||||
### 11.1 v6 closed-loop manifold issue
|
||||
|
||||
- derivative/history terms increase temporal expressivity but amplify local ruggedness under truncation.
|
||||
- this tends to create disconnected feasible islands, hard for small-data surrogate.
|
||||
|
||||
### 11.2 v7 open-loop manifold benefit and limitation
|
||||
|
||||
- control-point periodic manifold is smoother and easier to fit;
|
||||
- but unconstrained amplitude still allows edge-seeking behavior.
|
||||
|
||||
### 11.3 Recommended manifold constraints (code changes pending user approval)
|
||||
|
||||
1. Soft amplitude regularization in acquisition score:
|
||||
- add penalty term proportional to boundary occupancy ratio.
|
||||
2. Smoothness prior for open-loop waveform:
|
||||
- penalize adjacent control-point jumps (`L2` on first differences).
|
||||
3. Feasible-region seeding:
|
||||
- replace pure-uniform init with mixture:
|
||||
- 60% local perturbation around top PPO trajectories,
|
||||
- 40% broad random coverage.
|
||||
4. Adaptive batch sizing:
|
||||
- reduce to 12-18 when surrogate `val_r2` is low; restore when stable.
|
||||
5. Validity-aware replay buffer for surrogate:
|
||||
- keep a small labeled set of invalids with separate classifier head (or weighted regression mask) to teach boundary avoidance.
|
||||
|
||||
## 12) Next-Step Deliverables (without stopping current runs)
|
||||
|
||||
1. Generate per-acquisition diagnostics from current live logs:
|
||||
- boundary ratio trend;
|
||||
- invalid ratio trend;
|
||||
- surrogate val_r2 trend;
|
||||
- best-so-far improvement slope.
|
||||
2. Draft patch set for v6.1/v7.1 only as code diff (not executed).
|
||||
3. Provide a strict pre-run checklist to prevent another full-day failed run.
|
||||
|
||||
## 13) Per-Acquisition Trend Audit (new offline diagnostics)
|
||||
|
||||
Generated artifacts:
|
||||
|
||||
- `output/report_dante_v2_v5_v6/v6_v7_acq_diagnostics_summary_20260323.json`
|
||||
- `output/report_dante_v2_v5_v6/d1a3o12_250421_forces02_dante_v6_3_acq_diagnostics.csv`
|
||||
- `output/report_dante_v2_v5_v6/d1a3o12_250421_forces02_dante_v6_3_acq_diagnostics.png`
|
||||
- `output/report_dante_v2_v5_v6/d1a3o12_250421_forces02_dante_v7_1_acq_diagnostics.csv`
|
||||
- `output/report_dante_v2_v5_v6/d1a3o12_250421_forces02_dante_v7_1_acq_diagnostics.png`
|
||||
|
||||
### 13.1 v6_3 trend summary
|
||||
|
||||
- logged acquisitions: 17
|
||||
- mean invalid ratio: 0.4444
|
||||
- mean accepted-boundary ratio (`|x|>=0.95`): 0.4902
|
||||
- accepted-boundary slope over acquisitions: +0.00278 (still worsening)
|
||||
- best reward at acq end: 0.29371179 -> 0.29371179 (no improvement)
|
||||
- mean best-gain-per-acq: 0.0
|
||||
- surrogate mean val_r2: -0.3763
|
||||
|
||||
Interpretation:
|
||||
|
||||
- v6 is effectively stalled in exploitation of a non-improving region.
|
||||
- surrogate quality remains too weak to provide useful ranking lift.
|
||||
- boundary pressure and invalid pressure co-exist and reinforce local trapping.
|
||||
|
||||
### 13.2 v7_1 trend summary
|
||||
|
||||
- logged acquisitions: 8
|
||||
- mean invalid ratio: 0.0
|
||||
- mean accepted-boundary ratio (`|x|>=0.95`): 0.4353
|
||||
- accepted-boundary slope over acquisitions: -0.0772 (boundary pressure decreasing in current window)
|
||||
- best reward at acq end: 0.33564682 -> 0.36905161 (improving)
|
||||
- mean best-gain-per-acq: +0.00220
|
||||
- surrogate mean val_r2: +0.0790
|
||||
|
||||
Interpretation:
|
||||
|
||||
- v7 currently shows positive progress and reduced collapse tendency, but absolute boundary occupancy is still high.
|
||||
- this confirms parameterization smoothing helps, yet anti-boundary control is still missing.
|
||||
|
||||
### 13.3 Differential diagnosis update
|
||||
|
||||
Compared with previous section conclusions, new trend evidence strengthens:
|
||||
|
||||
1. The main blocker is not only cuDNN/runtime instability; even with fallback, v6 search dynamics are fundamentally unhealthy.
|
||||
2. The controller parameterization change (v6 -> v7) directly changes optimization geometry and data efficiency.
|
||||
3. Without explicit boundary-aware acquisition shaping, both variants remain vulnerable to local attractors near constraints.
|
||||
|
||||
## 14) Patch Blueprint (audit-level, not executed)
|
||||
|
||||
### 14.1 v6.1 (closed-loop) minimal-risk changes
|
||||
|
||||
1. Replace default basis profile from `compact_deriv_nl` to a smoother profile for first-stage search.
|
||||
2. Add acquisition-time boundary penalty (score-level, not objective rewrite):
|
||||
- penalize candidate if high fraction of dimensions satisfy `|x| >= 0.95`.
|
||||
3. Add surrogate reliability gate:
|
||||
- if `val_r2 < 0`, reduce effective exploration radius and acquisition batch for next round.
|
||||
|
||||
Expected outcome:
|
||||
|
||||
- reduce invalid-rate and boundary-collapse speed;
|
||||
- restore monotonic best progression possibility.
|
||||
|
||||
### 14.2 v7.1 (open-loop) minimal-risk changes
|
||||
|
||||
1. Set `samples_per_acq` from 24 to <=20 (paper-consistent low-batch regime).
|
||||
2. Add waveform smoothness regularization in candidate scoring:
|
||||
- penalty on first differences of control points for each channel.
|
||||
3. Add amplitude soft cap in acquisition ranking to avoid full-range saturation.
|
||||
|
||||
Expected outcome:
|
||||
|
||||
- preserve current positive trend while reducing residual boundary occupancy.
|
||||
|
||||
## 15) Zero-Risk Validation Checklist (before any new 1-day run)
|
||||
|
||||
1. Offline replay check on existing DB/log:
|
||||
- boundary ratio in top-ranked candidates should drop vs baseline.
|
||||
2. Surrogate holdout check:
|
||||
- `val_r2` distribution should improve or at least not degrade.
|
||||
3. Short synthetic call-chain smoke (no CFD heavy run):
|
||||
- no runtime errors in surrogate fit + rollout.
|
||||
4. Dry launch first 1-2 acquisitions only, then inspect:
|
||||
- invalid ratio,
|
||||
- boundary ratio,
|
||||
- best gain in acquisition.
|
||||
5. Only if above pass, start full-day run.
|
||||
|
||||
## 16) Latest Runtime Validation (2026-03-23, non-destructive)
|
||||
|
||||
Validation goal:
|
||||
|
||||
- verify whether current code path still throws `CUDNN_STATUS_MAPPING_ERROR` during CNN surrogate fit.
|
||||
|
||||
Validation method (no long-run, no environment-day run):
|
||||
|
||||
1. Confirm no active v6/v7 process.
|
||||
2. Use current `scripts/dante_v6_surrogate_torch.py` directly.
|
||||
3. Load live DB from:
|
||||
- `output/d1a3o12_250421_forces02_dante_v6_3_database_live.npz`
|
||||
- `output/d1a3o12_250421_forces02_dante_v7_1_database_live.npz`
|
||||
4. Run CNN fit + predict on target GPUs:
|
||||
- case A: v6 DB on GPU:1
|
||||
- case B: v7 DB on GPU:0
|
||||
|
||||
Observed result:
|
||||
|
||||
- case A: PASS, no cuDNN mapping error, `val_r2=0.3204`, `device=GPU:1`.
|
||||
- case B: PASS, no cuDNN mapping error, `val_r2=0.5205`, `device=GPU:0`.
|
||||
- overall status: `RESULT PASS`.
|
||||
|
||||
Important interpretation:
|
||||
|
||||
- Historical `nohup` logs still show repeated `surrogate cnn failed ... CUDNN_STATUS_MAPPING_ERROR` because they come from earlier runs.
|
||||
- Current surrogate module path can execute CNN fit/predict successfully on GPU in isolated validation.
|
||||
- Full conclusion for "problem fully solved" still requires at least one fresh acquisition-stage real run log without this error.
|
||||
Reference in New Issue
Block a user