import os import sys # ===== OpenMP冲突兼容设置,避免PyTorch初始化报错 ===== os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE") import numpy as np import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt # 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) MODEL_DATA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Model", "data") # ============================================================ # 阶段零:模型蒸馏演示 (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 == "高机动阶跃": return [(0., 1600., 70.), (8., 3200., 220.), (20., 2500., 130.), (35., 3400., 250.), (50., 1800., 80.)] if profile_name == "经济巡航": 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.)] 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 return rpm, torque # ============================================================ # 阶段三:能量管理策略设计 # ============================================================ 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: import torch from lightweight_model import EngineNNProxy from motor_sim import MotorSim from battery_sim import BatterySim # 参数裁剪 sim_time_s = float(np.clip(sim_time_s, 10, 240)) dt = float(np.clip(dt, 0.01, 0.2)) 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)) 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) time_array = np.arange(0, sim_time_s, dt) log = {k: [] for k in [ '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' ]} 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) # 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) p_drive_req = motor_state['p_bus_req_kw'] actual_rpm = motor_state['n_rpm'] # EMS soc = battery.SOC if soc < soc_low: charge_mode = True elif soc > soc_high: charge_mode = False 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() 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) 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 = ( 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}" ) 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 except Exception as e: import traceback return None, f"Hybrid simulation failed: {e}\n```\n{traceback.format_exc()}\n```", []