第一轮分析工作暂存

This commit is contained in:
Frank14f
2026-06-09 18:46:59 +08:00
parent 4612928398
commit d1b9922c6b
111 changed files with 24287 additions and 1581 deletions
@@ -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=11tail100=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.