Files
AutoControlCourse/case_demo_functions.py
T

1050 lines
46 KiB
Python
Raw Normal View History

import os
import sys
2026-04-07 19:23:12 +08:00
# ===== OpenMP冲突兼容设置,避免PyTorch初始化报错 =====
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
2026-04-07 19:23:12 +08:00
# English-only plot style (no Chinese fonts needed)
plt.rcParams['font.family'] = 'serif'
plt.rcParams['font.serif'] = ['DejaVu Serif', 'Times New Roman']
plt.rcParams['axes.unicode_minus'] = True
MODEL_SRC_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Model", "src")
if MODEL_SRC_PATH not in sys.path:
sys.path.insert(0, MODEL_SRC_PATH)
2026-04-07 19:23:12 +08:00
MODEL_DATA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Model", "data")
2026-04-07 19:23:12 +08:00
# ============================================================
# 阶段零:模型蒸馏演示 (GPR/CSV → NN)
# ============================================================
def run_distillation_demo(epochs, learning_rate, hidden_size, progress=None):
"""
运行蒸馏并返回训练曲线 + 对比散点图。
"""
try:
from distill_gpr_to_nn import distill_from_csv
csv_path = os.path.join(MODEL_DATA_PATH, "Cleaned_Engine_Data_Full.csv")
nn_path = os.path.join(MODEL_DATA_PATH, "engine_nn_proxy.pth")
epochs = int(np.clip(epochs, 500, 8000))
learning_rate = float(np.clip(learning_rate, 1e-4, 1e-2))
hidden_size = int(np.clip(hidden_size, 16, 256))
if progress is not None:
progress(0.0, desc="加载数据...")
def _progress_cb(epoch, total, loss):
if progress is not None:
progress(epoch / total, desc=f"训练中 Epoch {epoch}/{total}, Loss={loss:.6f}")
result = distill_from_csv(
csv_path, nn_path,
epochs=epochs, lr=learning_rate, hidden_size=hidden_size,
verbose=True, progress_callback=_progress_cb
)
loss_hist = result['loss_history']
Y_true = result['Y_train']
Y_pred = result['Y_nn']
# ---- Plots ----
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
fig.suptitle('GPR / Data → NN Knowledge Distillation Results',
fontweight='bold', fontsize=13)
# Loss curve
axes[0].semilogy(loss_hist, 'b-', lw=1.2)
axes[0].set_xlabel('Epoch')
axes[0].set_ylabel('MSE Loss (log scale)')
axes[0].set_title('Training Loss Curve')
axes[0].grid(True, linestyle=':', alpha=0.7)
# Fuel flow parity
axes[1].scatter(Y_true[:, 0], Y_pred[:, 0], s=8, alpha=0.5, c='tab:orange')
lim = [0, max(Y_true[:, 0].max(), Y_pred[:, 0].max()) * 1.05]
axes[1].plot(lim, lim, 'k--', lw=1, alpha=0.7)
axes[1].set_xlabel('True Fuel Flow (kg/h)')
axes[1].set_ylabel('NN Predicted Fuel Flow (kg/h)')
axes[1].set_title(f'Fuel Flow Parity (MAPE={result["rel_error_fuel"]:.2f}%)')
axes[1].set_xlim(lim); axes[1].set_ylim(lim)
axes[1].set_aspect('equal')
axes[1].grid(True, linestyle=':', alpha=0.7)
# Power parity
axes[2].scatter(Y_true[:, 1], Y_pred[:, 1], s=8, alpha=0.5, c='tab:blue')
lim = [0, max(Y_true[:, 1].max(), Y_pred[:, 1].max()) * 1.05]
axes[2].plot(lim, lim, 'k--', lw=1, alpha=0.7)
axes[2].set_xlabel('True Power (kW)')
axes[2].set_ylabel('NN Predicted Power (kW)')
axes[2].set_title(f'Power Parity (MAPE={result["rel_error_power"]:.2f}%)')
axes[2].set_xlim(lim); axes[2].set_ylim(lim)
axes[2].set_aspect('equal')
axes[2].grid(True, linestyle=':', alpha=0.7)
fig.tight_layout(rect=[0, 0, 1, 0.94])
n_params = sum(p.numel() for p in result['nn_model'].parameters())
summary = (
f"### Distillation Results\n"
f"- **NN Architecture**: MLP 3→{hidden_size}{hidden_size}→2 (Tanh)\n"
f"- **Parameters**: {n_params:,}\n"
f"- **Training Samples**: {len(Y_true)}\n"
f"- **Epochs**: {epochs}, LR: {learning_rate:.1e}\n"
f"- **Final Loss**: {loss_hist[-1]:.6f}\n"
f"- **Fuel Flow MAPE**: {result['rel_error_fuel']:.2f}%\n"
f"- **Power MAPE**: {result['rel_error_power']:.2f}%\n"
f"- **Model saved** to `Model/data/engine_nn_proxy.pth`"
)
return fig, summary
except Exception as e:
import traceback
return None, f"Distillation failed: {e}\n```\n{traceback.format_exc()}\n```"
# ============================================================
# GPR 模型训练/加载 与 可视化
# ============================================================
def run_gpr_training(mode="load", progress=None):
"""
GPR 模型训练或加载已有模型,并生成可视化图表。
Parameters
----------
mode : str
"train" — 从头训练(需 botorch/gpytorch/sklearn
"load" — 加载已有的 .pth 权重文件
progress : gr.Progress or None
"""
import csv as csv_mod
try:
csv_path = os.path.join(MODEL_DATA_PATH, "Cleaned_Engine_Data_Full.csv")
gpr_pth = os.path.join(MODEL_DATA_PATH, "engine_gpr_model.pth")
# ---------- 读取 CSV 原始数据(不依赖 pandas----------
if progress is not None:
progress(0.05, desc="读取 CSV 数据...")
with open(csv_path, 'r', encoding='utf-8') as f:
reader = csv_mod.reader(f)
header = next(reader)
rows = [r for r in reader]
col_idx = {name: i for i, name in enumerate(header)}
data = np.array([[float(x) for x in r] for r in rows], dtype=np.float64)
X_cols = ['Altitude_m', 'Mach', 'RPM']
Y_cols = ['WF_kg_h', 'Power_kW']
X_raw = data[:, [col_idx[c] for c in X_cols]]
Y_raw = data[:, [col_idx[c] for c in Y_cols]]
n_total = len(data)
# ---------- 尝试导入 GPR 依赖 ----------
gpr_available = False
gpr_model = None
try:
from engine_gpr_class import EngineGPRModel
gpr_available = True
except ImportError:
gpr_available = False
if mode == "train":
if not gpr_available:
return None, ("### ⚠️ GPR 训练失败\n\n"
"缺少依赖包:`botorch`, `gpytorch`, `sklearn`。\n\n"
"请执行 `pip install botorch gpytorch scikit-learn` 后重试,"
"或选择 **加载已有模型** 模式。")
if progress is not None:
progress(0.10, desc="初始化 GPR 模型 ...")
gpr_model = EngineGPRModel(csv_path=csv_path)
if progress is not None:
progress(0.15, desc="训练 GPR(超参数优化中)...")
gpr_model.train(save_path=gpr_pth)
if progress is not None:
progress(0.80, desc="GPR 训练完成,生成可视化 ...")
elif mode == "load":
if not gpr_available:
# --- 无 botorch:仅展示原始数据统计 ---
if progress is not None:
progress(0.30, desc="绘制数据统计图 ...")
fig = _plot_data_overview(X_raw, Y_raw, X_cols, Y_cols)
summary = (
f"### 📊 数据概览(无 GPR 依赖)\n"
f"- **数据集**: Cleaned_Engine_Data_Full.csv\n"
f"- **样本数**: {n_total}\n"
f"- **输入特征**: {', '.join(X_cols)}\n"
f"- **输出目标**: {', '.join(Y_cols)}\n\n"
f"> ⚠️ 未安装 `botorch`/`gpytorch`,无法加载 GPR 模型。\n"
f"> 请执行 `pip install botorch gpytorch scikit-learn` 后重试。"
)
return fig, summary
if not os.path.exists(gpr_pth):
return None, ("### ⚠️ 未找到已训练的 GPR 权重文件\n\n"
f"路径:`{gpr_pth}`\n\n"
"请先选择 **从头训练** 模式。")
if progress is not None:
progress(0.10, desc="加载 GPR 模型 ...")
gpr_model = EngineGPRModel(csv_path=csv_path)
ok = gpr_model.load_model(pth_path=gpr_pth)
if not ok:
return None, "### ⚠️ GPR 模型加载失败,请检查权重文件完整性。"
if progress is not None:
progress(0.40, desc="加载完成,生成可视化 ...")
# ---------- GPR 模型已就绪,生成可视化 ----------
if progress is not None:
progress(0.50, desc="GPR 网格预测 ...")
# 预测网格
H_range = np.linspace(X_raw[:, 0].min(), X_raw[:, 0].max(), 40)
Ma_range = np.linspace(X_raw[:, 1].min(), X_raw[:, 1].max(), 5)
RPM_range = np.linspace(max(X_raw[:, 2].min(), 1000), X_raw[:, 2].max(), 40)
H, Ma, RPM = np.meshgrid(H_range, Ma_range, RPM_range, indexing='ij')
X_grid = np.column_stack([H.ravel(), Ma.ravel(), RPM.ravel()])
pred_mean, pred_var = gpr_model.predict(X_grid)
if progress is not None:
progress(0.75, desc="绘图中 ...")
# 在训练数据点上的预测精度(过滤极小值,与 NN 训练一致)
valid_mask = (Y_raw[:, 0] > 0.5) & (Y_raw[:, 1] > 0.5) & (X_raw[:, 2] > 500)
X_eval = X_raw[valid_mask]
Y_eval = Y_raw[valid_mask]
train_pred, _ = gpr_model.predict(X_eval)
mape_fuel = np.mean(np.abs(train_pred[:, 0] - Y_eval[:, 0]) / np.maximum(Y_eval[:, 0], 1e-6)) * 100
mape_power = np.mean(np.abs(train_pred[:, 1] - Y_eval[:, 1]) / np.maximum(Y_eval[:, 1], 1e-6)) * 100
# ---- Plot ----
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle('GPR Model Training / Validation Results', fontweight='bold', fontsize=13)
# Fuel flow parity
axes[0, 0].scatter(Y_eval[:, 0], train_pred[:, 0], s=10, alpha=0.5, c='tab:orange')
lim = [0, max(Y_eval[:, 0].max(), train_pred[:, 0].max()) * 1.05]
axes[0, 0].plot(lim, lim, 'k--', lw=1)
axes[0, 0].set_xlabel('True Fuel Flow (kg/h)')
axes[0, 0].set_ylabel('GPR Predicted Fuel Flow (kg/h)')
axes[0, 0].set_title(f'Fuel Flow Parity (MAPE={mape_fuel:.2f}%)')
axes[0, 0].set_xlim(lim); axes[0, 0].set_ylim(lim)
axes[0, 0].set_aspect('equal'); axes[0, 0].grid(True, ls=':', alpha=0.7)
# Power parity
axes[0, 1].scatter(Y_eval[:, 1], train_pred[:, 1], s=10, alpha=0.5, c='tab:blue')
lim = [0, max(Y_eval[:, 1].max(), train_pred[:, 1].max()) * 1.05]
axes[0, 1].plot(lim, lim, 'k--', lw=1)
axes[0, 1].set_xlabel('True Power (kW)')
axes[0, 1].set_ylabel('GPR Predicted Power (kW)')
axes[0, 1].set_title(f'Power Parity (MAPE={mape_power:.2f}%)')
axes[0, 1].set_xlim(lim); axes[0, 1].set_ylim(lim)
axes[0, 1].set_aspect('equal'); axes[0, 1].grid(True, ls=':', alpha=0.7)
# Variance heatmap (Mach=0 slice)
ma0_idx = np.argmin(np.abs(Ma_range - 0.0))
var_reshaped = pred_var.reshape(len(H_range), len(Ma_range), len(RPM_range), 2)
wf_var_slice = var_reshaped[:, ma0_idx, :, 0]
pow_var_slice = var_reshaped[:, ma0_idx, :, 1]
RPM_g, H_g = np.meshgrid(RPM_range, H_range)
cf0 = axes[1, 0].contourf(RPM_g, H_g, np.log10(np.maximum(wf_var_slice, 1e-16)),
levels=30, cmap='jet', alpha=0.85)
axes[1, 0].set_xlabel('RPM')
axes[1, 0].set_ylabel('Altitude (m)')
axes[1, 0].set_title('Fuel Flow Variance (log₁₀, Mach=0)')
fig.colorbar(cf0, ax=axes[1, 0], shrink=0.8)
cf1 = axes[1, 1].contourf(RPM_g, H_g, np.log10(np.maximum(pow_var_slice, 1e-16)),
levels=30, cmap='jet', alpha=0.85)
axes[1, 1].set_xlabel('RPM')
axes[1, 1].set_ylabel('Altitude (m)')
axes[1, 1].set_title('Power Variance (log₁₀, Mach=0)')
fig.colorbar(cf1, ax=axes[1, 1], shrink=0.8)
fig.tight_layout(rect=[0, 0, 1, 0.94])
mode_label = "从头训练" if mode == "train" else "加载已有模型"
import torch as _torch
device_info = "CUDA" if _torch.cuda.is_available() else "CPU"
summary = (
f"### GPR 模型结果\n"
f"- **模式**: {mode_label}\n"
f"- **计算设备**: {device_info}\n"
f"- **训练样本**: {n_total}(有效评估样本: {int(valid_mask.sum())}\n"
f"- **网格预测点**: {len(X_grid)}\n"
f"- **Fuel Flow MAPE**: {mape_fuel:.2f}%\n"
f"- **Power MAPE**: {mape_power:.2f}%\n"
f"- **模型文件**: `Model/data/engine_gpr_model.pth`"
)
if progress is not None:
progress(1.0, desc="完成")
return fig, summary
except Exception as e:
import traceback
return None, f"GPR 训练/加载失败: {e}\n```\n{traceback.format_exc()}\n```"
def _plot_data_overview(X_raw, Y_raw, X_cols, Y_cols):
"""当 GPR 依赖不可用时,仅绘制原始数据统计概览。"""
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle('Engine Data Overview (GPR dependencies unavailable)', fontweight='bold', fontsize=13)
# Altitude vs Fuel Flow
axes[0, 0].scatter(X_raw[:, 0], Y_raw[:, 0], s=6, alpha=0.4, c='tab:orange')
axes[0, 0].set_xlabel('Altitude (m)')
axes[0, 0].set_ylabel('Fuel Flow (kg/h)')
axes[0, 0].set_title('Altitude vs Fuel Flow')
axes[0, 0].grid(True, ls=':', alpha=0.7)
# RPM vs Power
axes[0, 1].scatter(X_raw[:, 2], Y_raw[:, 1], s=6, alpha=0.4, c='tab:blue')
axes[0, 1].set_xlabel('RPM')
axes[0, 1].set_ylabel('Power (kW)')
axes[0, 1].set_title('RPM vs Power')
axes[0, 1].grid(True, ls=':', alpha=0.7)
# RPM vs Fuel Flow colored by Mach
sc = axes[1, 0].scatter(X_raw[:, 2], Y_raw[:, 0], s=6, alpha=0.4, c=X_raw[:, 1], cmap='viridis')
axes[1, 0].set_xlabel('RPM')
axes[1, 0].set_ylabel('Fuel Flow (kg/h)')
axes[1, 0].set_title('RPM vs Fuel Flow (color=Mach)')
fig.colorbar(sc, ax=axes[1, 0], shrink=0.8, label='Mach')
axes[1, 0].grid(True, ls=':', alpha=0.7)
# Fuel Flow vs Power
axes[1, 1].scatter(Y_raw[:, 0], Y_raw[:, 1], s=6, alpha=0.4, c='tab:green')
axes[1, 1].set_xlabel('Fuel Flow (kg/h)')
axes[1, 1].set_ylabel('Power (kW)')
axes[1, 1].set_title('Fuel Flow vs Power')
axes[1, 1].grid(True, ls=':', alpha=0.7)
fig.tight_layout(rect=[0, 0, 1, 0.94])
return fig
# ============================================================
# 阶段一:发动机控制器设计 (PID / MPC 可选)
# ============================================================
def run_engine_design(sim_time_s, dt, initial_power_kw, target_power_kw,
controller_type,
kp, ki, kd, tau_fuel, K_inertia,
mpc_horizon, mpc_W_power, mpc_W_dcost, mpc_overshoot_limit,
progress=None):
"""发动机控制器阶跃响应仿真"""
try:
import torch
from lightweight_model import EngineNNProxy
# 参数裁剪
sim_time_s = float(np.clip(sim_time_s, 5, 120))
dt = float(np.clip(dt, 0.01, 0.2))
initial_power_kw = float(np.clip(initial_power_kw, 20, 260))
target_power_kw = float(np.clip(target_power_kw, 20, 300))
tau_fuel = float(np.clip(tau_fuel, 0.02, 2.0))
K_inertia = float(np.clip(K_inertia, 5, 1000))
nn_pth = os.path.join(MODEL_DATA_PATH, "engine_nn_proxy.pth")
if not os.path.exists(nn_pth):
return None, "Error: `engine_nn_proxy.pth` not found. Please run the **Distillation** tab first."
engine_nn = EngineNNProxy()
engine_nn.load_state_dict(torch.load(nn_pth, map_location='cpu'))
engine_nn.eval()
# 初始稳态转速 — 纯Python二分法(不依赖scipy
def _bisect(func, a, b, tol=1e-4, maxiter=50):
fa, fb = func(a), func(b)
if fa * fb > 0:
return a if abs(fa) < abs(fb) else b
for _ in range(maxiter):
c = (a + b) / 2.0
fc = func(c)
if abs(fc) < tol or (b - a) / 2 < tol:
return c
if fa * fc < 0:
b, fb = c, fc
else:
a, fa = c, fc
return (a + b) / 2.0
def _solve_rpm(target_p):
def obj(n):
inp = torch.tensor([[0.0, 0.0, n]], dtype=torch.float32)
with torch.no_grad():
return engine_nn(inp).numpy()[0, 1] - target_p
return _bisect(obj, 1000, 58000)
N_current = _solve_rpm(initial_power_kw)
with torch.no_grad():
pred0 = engine_nn(torch.tensor([[0.0, 0.0, N_current]], dtype=torch.float32)).numpy()
Wf_act = pred0[0, 0]
Wf_cmd = Wf_act
use_mpc = (controller_type == "MPC")
if use_mpc:
from mpc_controller import TurboShaftMPCController
mpc_horizon = int(np.clip(mpc_horizon, 3, 30))
mpc_W_power = float(np.clip(mpc_W_power, 1, 1000))
mpc_W_dcost = float(np.clip(mpc_W_dcost, 0.01, 50))
mpc_overshoot_limit = float(np.clip(mpc_overshoot_limit, 0.01, 0.30))
mpc = TurboShaftMPCController(
tau_fuel=tau_fuel, K_inertia=K_inertia, dt=dt,
horizon=mpc_horizon, min_fuel=5.0, max_fuel=400.0,
overshoot_limit=mpc_overshoot_limit
)
mpc.W_power = mpc_W_power
mpc.W_dcost = mpc_W_dcost
mpc.reset(initial_output=Wf_cmd, initial_N=N_current)
else:
from increPID import IncrementalPIDController
kp = float(np.clip(kp, 0.01, 30))
ki = float(np.clip(ki, 0.0, 30))
kd = float(np.clip(kd, 0.0, 10))
pid = IncrementalPIDController(
kp=kp, ki=ki, kd=kd, dt=dt,
output_min=5.0, output_max=400.0,
input_scale=300.0, output_scale=400.0
)
pid.reset(initial_output=Wf_act)
time_array = np.arange(0, sim_time_s, dt)
t_step = sim_time_s * 0.15
logs = {'N': [], 'Wf_act': [], 'Wf_cmd': [], 'Power': [], 'Power_target': []}
n_steps = len(time_array)
for step_i, t in enumerate(time_array):
if progress is not None and step_i % max(1, n_steps // 20) == 0:
progress(step_i / n_steps, desc=f"发动机仿真 {step_i}/{n_steps} (t={t:.1f}s)")
target_p = initial_power_kw if t < t_step else target_power_kw
delta_N = 5.0
batch_inp = torch.tensor([
[0.0, 0.0, N_current],
[0.0, 0.0, N_current + delta_N]
], dtype=torch.float32)
with torch.no_grad():
batch_pred = engine_nn(batch_inp).numpy()
Wf_req = batch_pred[0, 0]
Power = batch_pred[0, 1]
if use_mpc:
k_wf = (batch_pred[1, 0] - batch_pred[0, 0]) / delta_N
k_p = (batch_pred[1, 1] - batch_pred[0, 1]) / delta_N
Wf_cmd = mpc.compute(
current_N=N_current, current_Wfact=Wf_act,
target_power=target_p,
precalc_params=(Wf_req, Power, k_wf, k_p)
)
else:
Wf_cmd = pid.compute(setpoint=target_p, measurement=Power)
dWf = (Wf_cmd - Wf_act) / tau_fuel
Wf_act += dWf * dt
dN = K_inertia * (Wf_act - Wf_req)
N_current += dN * dt
logs['N'].append(N_current)
logs['Wf_act'].append(Wf_act)
logs['Wf_cmd'].append(Wf_cmd)
logs['Power'].append(Power)
logs['Power_target'].append(target_p)
if progress is not None:
progress(1.0, desc="绘图中...")
# ---- Performance metrics ----
power_arr = np.array(logs['Power'])
step_idx = int(t_step / dt)
post_step = power_arr[step_idx:]
tail = max(1, len(post_step) // 10)
ss_error = np.mean(np.abs(post_step[-tail:] - target_power_kw))
delta = target_power_kw - initial_power_kw
overshoot = 0.0
if abs(delta) > 1:
if delta > 0:
overshoot = max(0, (np.max(post_step) - target_power_kw) / delta * 100)
else:
overshoot = max(0, (target_power_kw - np.min(post_step)) / abs(delta) * 100)
rise_time = float('nan')
if abs(delta) > 1:
thresh_10 = initial_power_kw + 0.1 * delta
thresh_90 = initial_power_kw + 0.9 * delta
t10 = t90 = None
for i in range(step_idx, len(power_arr)):
if delta > 0:
if t10 is None and power_arr[i] >= thresh_10:
t10 = time_array[i] - time_array[step_idx]
if t90 is None and power_arr[i] >= thresh_90:
t90 = time_array[i] - time_array[step_idx]
else:
if t10 is None and power_arr[i] <= thresh_10:
t10 = time_array[i] - time_array[step_idx]
if t90 is None and power_arr[i] <= thresh_90:
t90 = time_array[i] - time_array[step_idx]
if t10 is not None and t90 is not None:
rise_time = t90 - t10
settling_time = float('nan')
if abs(delta) > 1:
band = abs(delta) * 0.02
for i in range(len(post_step) - 1, -1, -1):
if abs(post_step[i] - target_power_kw) > band:
settling_time = (i + 1) * dt
break
# ---- Plots (English) ----
ctrl_label = "MPC" if use_mpc else "PID"
fig, axes = plt.subplots(3, 1, figsize=(12, 9), sharex=True)
fig.suptitle(f'Engine Controller Step Response ({ctrl_label})',
fontweight='bold', fontsize=13)
axes[0].plot(time_array, logs['Power_target'], 'k--', lw=1.5, label='Target Power')
axes[0].plot(time_array, logs['Power'], 'r-', lw=1.5, label='Actual Power')
axes[0].set_ylabel('Power (kW)')
axes[0].set_title('Power Tracking')
axes[0].grid(True, linestyle=':'); axes[0].legend()
axes[1].plot(time_array, logs['N'], 'b-', lw=1.5)
axes[1].set_ylabel('Speed (RPM)')
axes[1].set_title('Engine Rotor Speed')
axes[1].grid(True, linestyle=':')
axes[2].plot(time_array, logs['Wf_cmd'], 'k--', lw=1.2, label='Fuel Command')
axes[2].plot(time_array, logs['Wf_act'], 'r-', lw=1.2, label='Actual Fuel')
axes[2].set_ylabel('Fuel Flow (kg/h)')
axes[2].set_xlabel('Time (s)')
axes[2].set_title('Fuel Control Signal')
axes[2].grid(True, linestyle=':'); axes[2].legend()
fig.tight_layout(rect=[0, 0, 1, 0.96])
if use_mpc:
param_str = (f"Horizon={mpc_horizon}, W_power={mpc_W_power:.1f}, "
f"W_Δcost={mpc_W_dcost:.2f}, Overshoot≤{mpc_overshoot_limit*100:.0f}%")
else:
param_str = f"Kp={kp:.3f}, Ki={ki:.3f}, Kd={kd:.3f}"
summary = (
f"### Engine Controller Results ({ctrl_label})\n"
f"- **Controller**: {ctrl_label}{param_str}\n"
f"- **Power Step**: {initial_power_kw:.0f}{target_power_kw:.0f} kW\n"
f"- **Steady-State Error**: {ss_error:.2f} kW\n"
f"- **Overshoot**: {overshoot:.1f}%\n"
f"- **Rise Time (10%-90%)**: {rise_time:.3f} s\n"
f"- **Settling Time (2% band)**: {settling_time:.3f} s\n"
f"- **Fuel Actuator τ**: {tau_fuel:.2f} s | Rotor Inertia K: {K_inertia:.0f}"
)
return fig, summary
except Exception as e:
import traceback
return None, f"Engine simulation failed: {e}\n```\n{traceback.format_exc()}\n```"
# ============================================================
# 阶段二:电机控制器设计 (PID / MPC 可选)
# ============================================================
def run_motor_design(sim_time_s, dt, target_rpm, load_torque,
controller_type,
kp, ki, kd, J,
mpc_W_speed, mpc_W_dcost, mpc_overshoot_limit,
progress=None):
"""电机控制器阶跃响应 + 负载扰动仿真"""
try:
from motor_sim import MotorSim
sim_time_s = float(np.clip(sim_time_s, 5, 120))
dt = float(np.clip(dt, 0.01, 0.2))
target_rpm = float(np.clip(target_rpm, 200, 6000))
load_torque = float(np.clip(load_torque, 5, 500))
J = float(np.clip(J, 0.1, 10.0))
use_mpc = (controller_type == "MPC")
if use_mpc:
mpc_W_speed = float(np.clip(mpc_W_speed, 1, 500))
mpc_W_dcost = float(np.clip(mpc_W_dcost, 0.01, 50))
mpc_overshoot_limit = float(np.clip(mpc_overshoot_limit, 0.01, 0.30))
motor = MotorSim(
P_rate=300e3, w_rate=575.95, J=J,
mpc_W_speed=mpc_W_speed, mpc_W_dcost=mpc_W_dcost,
mpc_overshoot_limit=mpc_overshoot_limit,
)
else:
kp = float(np.clip(kp, 0.01, 80))
ki = float(np.clip(ki, 0.0, 150))
kd = float(np.clip(kd, 0.0, 10))
motor = MotorSim(
P_rate=300e3, w_rate=575.95, J=J,
mpc_W_speed=0.0, mpc_W_dcost=0.0,
mpc_overshoot_limit=0.05,
)
time_array = np.arange(0, sim_time_s, dt)
t_step = sim_time_s * 0.10
t_load_step = sim_time_s * 0.60
v_bus = 520.0
p_supply = 0.0
if not use_mpc:
from increPID import IncrementalPIDController
w_rate = 575.95
tau_rate = 300e3 / w_rate
pid_motor = IncrementalPIDController(
kp=kp, ki=ki, kd=kd, dt=dt,
output_min=-tau_rate, output_max=tau_rate,
input_scale=w_rate, output_scale=tau_rate
)
pid_motor.reset(0.0)
logs = {'rpm': [], 'target_rpm': [], 'torque': [],
'p_bus_req': [], 'p_shaft': [], 'p_loss': [], 'load': []}
n_steps = len(time_array)
for step_i, t in enumerate(time_array):
if progress is not None and step_i % max(1, n_steps // 20) == 0:
progress(step_i / n_steps, desc=f"电机仿真 {step_i}/{n_steps} (t={t:.1f}s)")
n_set = 500.0 if t < t_step else target_rpm
if t < t_step:
load_t = 20.0
elif t < t_load_step:
load_t = load_torque
else:
load_t = load_torque * 1.5
if not use_mpc:
w_set_rad = n_set * 2 * np.pi / 60.0
T_cmd = pid_motor.compute(setpoint=w_set_rad, measurement=motor.w_M)
w_eff = max(abs(motor.w_M), 1.0)
p_cmd_kw = -T_cmd * w_eff / 1000.0
state = motor.step(dt=dt, n_setpoint=n_set, p_bus_actual_kw=p_cmd_kw,
v_bus=v_bus, t_load=load_t, t_ext=0.0)
else:
state = motor.step(dt=dt, n_setpoint=n_set, p_bus_actual_kw=p_supply,
v_bus=v_bus, t_load=load_t, t_ext=0.0)
logs['rpm'].append(state['n_rpm'])
logs['target_rpm'].append(n_set)
logs['torque'].append(state['t_motor'])
logs['p_bus_req'].append(state['p_bus_req_kw'])
logs['p_shaft'].append(state['p_shaft_kw'])
logs['p_loss'].append(state['p_loss_kw'])
logs['load'].append(load_t)
p_supply = state['p_bus_req_kw']
if progress is not None:
progress(1.0, desc="绘图中...")
# ---- Performance ----
rpm_arr = np.array(logs['rpm'])
step_idx = int(t_step / dt)
post_step_rpm = rpm_arr[step_idx:]
tail = max(1, len(post_step_rpm) // 10)
ss_error = np.mean(np.abs(post_step_rpm[-tail:] - target_rpm))
delta_rpm = target_rpm - 500.0
overshoot = 0.0
if abs(delta_rpm) > 1 and delta_rpm > 0:
overshoot = max(0, (np.max(post_step_rpm) - target_rpm) / delta_rpm * 100)
load_step_idx = int(t_load_step / dt)
max_dip = 0
if load_step_idx < len(rpm_arr):
post_load = rpm_arr[load_step_idx:]
max_dip = max(0, target_rpm - np.min(post_load)) if len(post_load) > 0 else 0
# ---- Plots (English) ----
ctrl_label = "MPC" if use_mpc else "PID"
fig, axes = plt.subplots(3, 1, figsize=(12, 9), sharex=True)
fig.suptitle(f'Motor Controller — Step + Load Disturbance ({ctrl_label})',
fontweight='bold', fontsize=13)
axes[0].plot(time_array, logs['target_rpm'], 'k--', lw=1.5, label='Target Speed')
axes[0].plot(time_array, logs['rpm'], 'b-', lw=1.5, label='Actual Speed')
axes[0].axvline(t_load_step, color='orange', linestyle=':', lw=1, alpha=0.7, label='Load Disturbance')
axes[0].set_ylabel('Speed (RPM)')
axes[0].set_title('Speed Tracking')
axes[0].grid(True, linestyle=':'); axes[0].legend()
axes[1].plot(time_array, logs['torque'], 'r-', lw=1.2, label='Motor Torque')
axes[1].plot(time_array, logs['load'], 'k--', lw=1, alpha=0.6, label='Load Torque')
axes[1].set_ylabel('Torque (Nm)')
axes[1].set_title('Torque Response')
axes[1].grid(True, linestyle=':'); axes[1].legend()
axes[2].plot(time_array, logs['p_bus_req'], 'g-', lw=1.2, label='Bus Power Request')
axes[2].plot(time_array, logs['p_shaft'], 'b--', lw=1.2, label='Shaft Power')
axes[2].plot(time_array, logs['p_loss'], 'r:', lw=1.2, label='Loss Power')
axes[2].set_ylabel('Power (kW)')
axes[2].set_xlabel('Time (s)')
axes[2].set_title('Power Distribution')
axes[2].grid(True, linestyle=':'); axes[2].legend()
fig.tight_layout(rect=[0, 0, 1, 0.96])
if use_mpc:
param_str = (f"W_speed={mpc_W_speed:.1f}, W_Δcost={mpc_W_dcost:.2f}, "
f"Overshoot≤{mpc_overshoot_limit*100:.0f}%")
else:
param_str = f"Kp={kp:.3f}, Ki={ki:.3f}, Kd={kd:.3f}"
summary = (
f"### Motor Controller Results ({ctrl_label})\n"
f"- **Controller**: {ctrl_label}{param_str}\n"
f"- **Speed Step**: 500 → {target_rpm:.0f} RPM\n"
f"- **Load Torque**: {load_torque:.0f} Nm → {load_torque*1.5:.0f} Nm\n"
f"- **Steady-State Error**: {ss_error:.1f} RPM\n"
f"- **Overshoot**: {overshoot:.1f}%\n"
f"- **Max Load Dip**: {max_dip:.1f} RPM\n"
f"- **Inertia J**: {J:.2f} kg·m²"
)
return fig, summary
except Exception as e:
import traceback
return None, f"Motor simulation failed: {e}\n```\n{traceback.format_exc()}\n```"
# ============================================================
# 工况配置辅助
# ============================================================
def _profile_points(profile_name):
if profile_name == "高机动阶跃":
2026-04-07 19:23:12 +08:00
return [(0., 1600., 70.), (8., 3200., 220.), (20., 2500., 130.),
(35., 3400., 250.), (50., 1800., 80.)]
if profile_name == "经济巡航":
2026-04-07 19:23:12 +08:00
return [(0., 1500., 60.), (15., 2100., 95.), (35., 2300., 105.),
(55., 2000., 90.)]
return [(0., 1500., 50.), (10., 3000., 200.), (30., 2800., 150.),
(50., 1800., 60.)]
2026-04-07 19:23:12 +08:00
def _target_from_profile(t, points):
rpm, torque = points[0][1], points[0][2]
for p_t, p_rpm, p_torque in points:
if t >= p_t:
rpm, torque = p_rpm, p_torque
else:
break
2026-04-07 19:23:12 +08:00
return rpm, torque
2026-04-07 19:23:12 +08:00
# ============================================================
# 阶段三:能量管理策略设计
# ============================================================
def run_hybrid_demo(sim_time_s, dt, initial_soc_pct, initial_engine_power_kw,
profile_name,
eng_controller_type, eng_kp, eng_ki, eng_kd,
eng_mpc_horizon, eng_mpc_W_power, eng_mpc_W_dcost, eng_mpc_overshoot,
mot_controller_type, mot_kp, mot_ki, mot_kd, mot_J,
mot_mpc_W_speed, mot_mpc_W_dcost, mot_mpc_overshoot,
soc_target_pct, soc_low_pct, soc_high_pct,
p_eng_min, p_eng_max, p_charge, k_soc,
power_reserve_pct, battery_capacity_kwh,
progress=None):
"""混动系统能量管理策略仿真 (规则 + 滞环)"""
try:
2026-04-07 19:23:12 +08:00
import torch
from lightweight_model import EngineNNProxy
from motor_sim import MotorSim
from battery_sim import BatterySim
2026-04-07 19:23:12 +08:00
# 参数裁剪
sim_time_s = float(np.clip(sim_time_s, 10, 240))
dt = float(np.clip(dt, 0.01, 0.2))
2026-04-07 19:23:12 +08:00
initial_soc_pct = float(np.clip(initial_soc_pct, 10, 95))
initial_engine_power_kw = float(np.clip(initial_engine_power_kw, 20, 260))
mot_J = float(np.clip(mot_J, 0.1, 10.0))
2026-04-07 19:23:12 +08:00
soc_target = float(np.clip(soc_target_pct, 20, 80)) / 100.0
soc_low = float(np.clip(soc_low_pct, 10, 60)) / 100.0
soc_high = float(np.clip(soc_high_pct, 50, 95)) / 100.0
if soc_low >= soc_high:
soc_high = soc_low + 0.1
p_eng_min = float(np.clip(p_eng_min, 10, 100))
p_eng_max = float(np.clip(p_eng_max, 100, 350))
if p_eng_min >= p_eng_max:
p_eng_min = p_eng_max * 0.1
p_charge = float(np.clip(p_charge, 50, 300))
k_soc = float(np.clip(k_soc, 0, 500))
power_reserve_pct = float(np.clip(power_reserve_pct, 0, 50))
battery_capacity_kwh = float(np.clip(battery_capacity_kwh, 10, 200))
# ---- 发动机 ----
nn_pth = os.path.join(MODEL_DATA_PATH, "engine_nn_proxy.pth")
if not os.path.exists(nn_pth):
return None, "Error: engine_nn_proxy.pth not found.", []
engine_nn = EngineNNProxy()
engine_nn.load_state_dict(torch.load(nn_pth, map_location='cpu'))
engine_nn.eval()
tau_fuel, K_inertia = 0.15, 100.0
def _bisect(func, a, b, tol=1e-4, maxiter=50):
fa, fb = func(a), func(b)
if fa * fb > 0:
return a if abs(fa) < abs(fb) else b
for _ in range(maxiter):
c = (a + b) / 2.0
fc = func(c)
if abs(fc) < tol or (b - a) / 2 < tol:
return c
if fa * fc < 0:
b, fb = c, fc
else:
a, fa = c, fc
return (a + b) / 2.0
def _solve_rpm(target_p):
def obj(n):
with torch.no_grad():
return engine_nn(torch.tensor([[0.,0.,n]], dtype=torch.float32)).numpy()[0,1] - target_p
return _bisect(obj, 1000, 58000)
eng_N = _solve_rpm(initial_engine_power_kw)
with torch.no_grad():
pred_init = engine_nn(torch.tensor([[0.,0.,eng_N]], dtype=torch.float32)).numpy()
eng_Wf_act = pred_init[0, 0]
eng_Wf_cmd = eng_Wf_act
eng_use_mpc = (eng_controller_type == "MPC")
if eng_use_mpc:
from mpc_controller import TurboShaftMPCController
eng_mpc = TurboShaftMPCController(
tau_fuel=tau_fuel, K_inertia=K_inertia, dt=dt,
horizon=int(np.clip(eng_mpc_horizon, 3, 30)),
overshoot_limit=float(np.clip(eng_mpc_overshoot, 0.01, 0.30))
)
eng_mpc.W_power = float(np.clip(eng_mpc_W_power, 1, 1000))
eng_mpc.W_dcost = float(np.clip(eng_mpc_W_dcost, 0.01, 50))
eng_mpc.reset(initial_output=eng_Wf_cmd, initial_N=eng_N)
else:
from increPID import IncrementalPIDController
eng_kp = float(np.clip(eng_kp, 0.01, 30))
eng_ki = float(np.clip(eng_ki, 0.0, 30))
eng_kd = float(np.clip(eng_kd, 0.0, 10))
eng_pid = IncrementalPIDController(
kp=eng_kp, ki=eng_ki, kd=eng_kd, dt=dt,
output_min=5.0, output_max=400.0,
input_scale=300.0, output_scale=400.0
)
eng_pid.reset(initial_output=eng_Wf_act)
# ---- 电机 ----
mot_use_mpc = (mot_controller_type == "MPC")
if mot_use_mpc:
drive_motor = MotorSim(
P_rate=300e3, w_rate=575.95, J=mot_J,
mpc_W_speed=float(np.clip(mot_mpc_W_speed, 1, 500)),
mpc_W_dcost=float(np.clip(mot_mpc_W_dcost, 0.01, 50)),
mpc_overshoot_limit=float(np.clip(mot_mpc_overshoot, 0.01, 0.30)),
)
else:
drive_motor = MotorSim(
P_rate=300e3, w_rate=575.95, J=mot_J,
mpc_W_speed=0., mpc_W_dcost=0., mpc_overshoot_limit=0.05,
)
mot_kp = float(np.clip(mot_kp, 0.01, 80))
mot_ki = float(np.clip(mot_ki, 0.0, 150))
mot_kd = float(np.clip(mot_kd, 0.0, 10))
from increPID import IncrementalPIDController
w_rate = 575.95; tau_rate = 300e3 / w_rate
mot_pid = IncrementalPIDController(
kp=mot_kp, ki=mot_ki, kd=mot_kd, dt=dt,
output_min=-tau_rate, output_max=tau_rate,
input_scale=w_rate, output_scale=tau_rate
)
mot_pid.reset(0.0)
battery = BatterySim(capacity_kwh=battery_capacity_kwh,
initial_soc=initial_soc_pct / 100.0)
bus_voltage = battery._get_ocv(battery.SOC)
motor_actual_power_kw = 0.0
charge_mode = (initial_soc_pct / 100.0) < soc_low
power_reserve = p_eng_max * power_reserve_pct / 100.0
points = _profile_points(profile_name)
2026-04-07 19:23:12 +08:00
time_array = np.arange(0, sim_time_s, dt)
log = {k: [] for k in [
2026-04-07 19:23:12 +08:00
'soc', 'bus_voltage', 'prop_speed_rpm', 'target_prop_rpm',
'target_engine_pwr', 'p_engine_out_kw', 'p_drive_req_kw',
'p_batt_actual_kw', 'wf_kg_h', 'ems_mode'
]}
2026-04-07 19:23:12 +08:00
n_steps = len(time_array)
for step_i, t in enumerate(time_array):
if progress is not None and step_i % max(1, n_steps // 20) == 0:
progress(step_i / n_steps, desc=f"混动仿真 {step_i}/{n_steps} (t={t:.1f}s)")
target_rpm, load_torque_t = _target_from_profile(t, points)
2026-04-07 19:23:12 +08:00
# Motor step
if not mot_use_mpc:
w_set_rad = target_rpm * 2 * np.pi / 60.0
T_cmd = mot_pid.compute(setpoint=w_set_rad, measurement=drive_motor.w_M)
p_cmd_kw = -T_cmd * max(abs(drive_motor.w_M), 1.0) / 1000.0
motor_state = drive_motor.step(dt=dt, n_setpoint=target_rpm,
p_bus_actual_kw=p_cmd_kw, v_bus=bus_voltage,
t_load=load_torque_t, t_ext=0.0)
else:
motor_state = drive_motor.step(dt=dt, n_setpoint=target_rpm,
p_bus_actual_kw=motor_actual_power_kw, v_bus=bus_voltage,
t_load=load_torque_t, t_ext=0.0)
2026-04-07 19:23:12 +08:00
p_drive_req = motor_state['p_bus_req_kw']
actual_rpm = motor_state['n_rpm']
2026-04-07 19:23:12 +08:00
# EMS
soc = battery.SOC
if soc < soc_low: charge_mode = True
elif soc > soc_high: charge_mode = False
2026-04-07 19:23:12 +08:00
if soc < 0.10:
target_engine_pwr = p_eng_max; ems_mode_str = "Emergency Charge"
elif soc > 0.95:
target_engine_pwr = p_eng_min; ems_mode_str = "Overcharge Prot."
elif charge_mode:
target_engine_pwr = p_charge; ems_mode_str = "Charge Mode"
else:
soc_error = soc_target - soc
target_engine_pwr = p_drive_req + power_reserve + soc_error * k_soc
ems_mode_str = "Power Follow"
target_engine_pwr = float(np.clip(target_engine_pwr, p_eng_min, p_eng_max))
# Engine step
batch_inp = torch.tensor([[0.,0.,eng_N],[0.,0.,eng_N+5.]], dtype=torch.float32)
with torch.no_grad():
bp = engine_nn(batch_inp).numpy()
Wf_req, P_eng_out = bp[0,0], bp[0,1]
if eng_use_mpc:
k_wf = (bp[1,0]-bp[0,0])/5.0; k_p = (bp[1,1]-bp[0,1])/5.0
eng_Wf_cmd = eng_mpc.compute(current_N=eng_N, current_Wfact=eng_Wf_act,
target_power=target_engine_pwr, precalc_params=(Wf_req, P_eng_out, k_wf, k_p))
else:
eng_Wf_cmd = eng_pid.compute(setpoint=target_engine_pwr, measurement=P_eng_out)
eng_Wf_act += (eng_Wf_cmd - eng_Wf_act) / tau_fuel * dt
eng_N += K_inertia * (eng_Wf_act - Wf_req) * dt
# Battery
p_batt_req = p_drive_req - P_eng_out
p_batt_actual, v_bus, i_batt, soc_new = battery.step(dt, p_batt_req)
bus_voltage = v_bus
motor_actual_power_kw = P_eng_out + p_batt_actual
log['soc'].append(soc_new * 100.0)
log['bus_voltage'].append(v_bus)
log['prop_speed_rpm'].append(actual_rpm)
log['target_prop_rpm'].append(target_rpm)
log['target_engine_pwr'].append(target_engine_pwr)
log['p_engine_out_kw'].append(P_eng_out)
log['p_drive_req_kw'].append(p_drive_req)
log['p_batt_actual_kw'].append(p_batt_actual)
log['wf_kg_h'].append(eng_Wf_act)
log['ems_mode'].append(ems_mode_str)
if progress is not None:
progress(1.0, desc="绘图中...")
# ---- Plots (English) ----
soc_arr = np.array(log['soc'])
speed_error = np.array(log['target_prop_rpm']) - np.array(log['prop_speed_rpm'])
fig, axes = plt.subplots(4, 1, figsize=(12, 14), sharex=True)
fig.suptitle('Hybrid EMS Validation (Rule-Based + Hysteresis)',
fontweight='bold', fontsize=13)
mode_colors = {'Power Follow': '#E3F2FD', 'Charge Mode': '#FFEBEE',
'Emergency Charge': '#FFCDD2', 'Overcharge Prot.': '#E8F5E9'}
axes[0].plot(time_array, log['target_prop_rpm'], 'k--', lw=1.5, label='Target')
axes[0].plot(time_array, log['prop_speed_rpm'], 'b-', lw=1.5, label='Actual')
axes[0].set_ylabel('Speed (RPM)'); axes[0].set_title('Propulsion Speed')
axes[0].grid(True, linestyle=':'); axes[0].legend()
modes = log['ems_mode']
i = 0; added = set()
while i < len(modes):
m = modes[i]; j = i
while j < len(modes) and modes[j] == m: j += 1
col = mode_colors.get(m, '#F5F5F5')
lbl = m if m not in added else None
axes[1].axvspan(time_array[i], time_array[min(j-1, len(time_array)-1)],
alpha=0.3, color=col, label=lbl)
if lbl: added.add(m)
i = j
axes[1].plot(time_array, log['p_drive_req_kw'], 'k--', lw=1.2, label='Motor Demand')
axes[1].plot(time_array, log['target_engine_pwr'], color='darkred', ls=':', lw=1, label='Eng Target')
axes[1].plot(time_array, log['p_engine_out_kw'], 'r-', lw=1.2, label='Eng Output')
axes[1].plot(time_array, log['p_batt_actual_kw'], 'g-', lw=1.2, label='Battery')
axes[1].axhline(0, color='gray', lw=0.8)
axes[1].set_ylabel('Power (kW)'); axes[1].set_title('Power Allocation (bg=EMS mode)')
axes[1].grid(True, linestyle=':'); axes[1].legend(ncol=3, fontsize=8, loc='upper right')
axes[2].plot(time_array, log['bus_voltage'], 'm-', lw=1.2, label='Bus Voltage')
axes[2].set_ylabel('Voltage (V)'); axes[2].set_title('Electrical State & SOC')
axes[2].grid(True, linestyle=':'); axes[2].legend(loc='upper left')
ax_soc = axes[2].twinx()
2026-04-07 19:23:12 +08:00
ax_soc.plot(time_array, log['soc'], 'c-', lw=1.6, label='SOC')
ax_soc.axhline(soc_low*100, color='r', ls=':', lw=1, alpha=0.7, label=f'Low ({soc_low*100:.0f}%)')
ax_soc.axhline(soc_high*100, color='g', ls=':', lw=1, alpha=0.7, label=f'High ({soc_high*100:.0f}%)')
ax_soc.axhline(soc_target*100, color='b', ls='-.', lw=1, alpha=0.5, label=f'Target ({soc_target*100:.0f}%)')
ax_soc.set_ylabel('SOC (%)'); ax_soc.legend(loc='upper right', fontsize=8)
2026-04-07 19:23:12 +08:00
axes[3].plot(time_array, log['wf_kg_h'], 'tab:orange', lw=1.2, label='Fuel Flow')
axes[3].set_ylabel('Fuel (kg/h)'); axes[3].set_xlabel('Time (s)')
axes[3].set_title('Fuel Consumption')
axes[3].grid(True, linestyle=':'); axes[3].legend()
fig.tight_layout(rect=[0, 0, 1, 0.96])
mode_times = {}
for m_ in modes: mode_times[m_] = mode_times.get(m_, 0) + dt
mode_str = ', '.join([f'{k}: {v:.1f}s' for k, v in mode_times.items()])
fuel_arr = np.array(log['wf_kg_h'])
engine_pwr_arr = np.array(log['p_engine_out_kw'])
batt_pwr_arr = np.array(log['p_batt_actual_kw'])
summary = (
2026-04-07 19:23:12 +08:00
f"### Hybrid Simulation Summary\n"
f"- **Duration**: {sim_time_s:.0f}s, dt={dt:.3f}s\n"
f"- **Controllers**: Engine={eng_controller_type}, Motor={mot_controller_type}\n"
f"- **Max Speed Error**: {np.max(np.abs(speed_error)):.1f} RPM\n"
f"- **SOC**: {soc_arr[0]:.1f}% → {soc_arr[-1]:.1f}% "
f"(min {np.min(soc_arr):.1f}%, max {np.max(soc_arr):.1f}%)\n"
f"- **Avg Engine Power**: {np.mean(engine_pwr_arr):.1f} kW\n"
f"- **Avg Battery**: {np.mean(batt_pwr_arr):.1f} kW (+discharge/charge)\n"
f"- **Avg Fuel**: {np.mean(fuel_arr):.1f} kg/h\n"
f"- **EMS Modes**: {mode_str}"
)
2026-04-07 19:23:12 +08:00
pick_idx = np.linspace(0, len(time_array)-1, 8, dtype=int)
table_data = [[
round(float(time_array[idx]),2),
round(float(log['target_prop_rpm'][idx]),1),
round(float(log['prop_speed_rpm'][idx]),1),
round(float(log['p_engine_out_kw'][idx]),2),
round(float(log['p_batt_actual_kw'][idx]),2),
round(float(log['soc'][idx]),2),
log['ems_mode'][idx],
] for idx in pick_idx]
return fig, summary, table_data
2026-04-07 19:23:12 +08:00
except Exception as e:
2026-04-07 19:23:12 +08:00
import traceback
return None, f"Hybrid simulation failed: {e}\n```\n{traceback.format_exc()}\n```", []