# -*- coding: utf-8 -*-
"""
MARS 轴向磁拉力仿真工具 — PyQt5 GUI (现代工业风 v2)
====================================================
双击运行 (或 python main.py), 调整参数后点击运行, 无需 AI token。
"""
import os
import sys
import json
import time
import traceback
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QObject, QSize
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QGridLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QCheckBox,
QTabWidget, QTextEdit, QGroupBox, QDoubleSpinBox, QSpinBox,
QTableWidget, QTableWidgetItem, QHeaderView, QMessageBox, QSplitter,
QFrame, QSizePolicy, QStatusBar, QScrollArea
)
from PyQt5.QtGui import QFont, QColor, QPalette
BASE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_MOT = os.path.normpath(
os.path.join(BASE, "..", "MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot"))
DEFAULT_OUT = os.path.normpath(os.path.join(BASE, "..", "output_motorcad"))
DEFAULT_MC_EXE = r"D:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe"
# ============================================================
# 现代工业风 QSS — 浅灰蓝底 + 白色卡片 + 工业蓝强调
# ============================================================
STYLE = """
* { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 10pt; }
QMainWindow { background: #eef1f5; }
/* --- 卡片式分组 --- */
QGroupBox {
font-weight: 600; font-size: 11pt; color: #1e293b;
border: 1px solid #e2e8f0; border-radius: 8px;
margin-top: 16px; padding-top: 14px; padding-left: 10px; padding-right: 10px;
background: #ffffff;
}
QGroupBox::title {
subcontrol-origin: margin; left: 14px; padding: 0 8px;
background: #ffffff; color: #1e293b;
}
/* --- 标签 --- */
QLabel { color: #334155; font-size: 10pt; }
QLabel#cardValue { font-size: 22pt; font-weight: 700; color: #1e293b; }
QLabel#cardUnit { font-size: 11pt; color: #64748b; font-weight: 400; }
QLabel#cardLabel { font-size: 9pt; color: #94a3b8; font-weight: 500; letter-spacing: 1px; }
QLabel#sectionTitle { font-size: 11pt; font-weight: 600; color: #1e293b; padding-bottom: 4px; }
/* --- 输入框 --- */
QLineEdit, QDoubleSpinBox, QSpinBox {
padding: 6px 10px; border: 1px solid #cbd5e1; border-radius: 6px;
background: #f8fafc; color: #1e293b; font-size: 10pt; min-height: 22px;
selection-background-color: #2563eb; selection-color: white;
}
QLineEdit:focus, QDoubleSpinBox:focus, QSpinBox:focus {
border: 1px solid #2563eb; background: #ffffff;
}
QDoubleSpinBox::up-button, QSpinBox::up-button,
QDoubleSpinBox::down-button, QSpinBox::down-button { width: 18px; }
/* --- 按钮 --- */
QPushButton {
padding: 8px 20px; border-radius: 6px; font-size: 10pt; font-weight: 600;
border: 1px solid #2563eb; background: #2563eb; color: white;
}
QPushButton:hover { background: #1d4ed8; border-color: #1d4ed8; }
QPushButton:pressed { background: #1e40af; }
QPushButton:disabled { background: #94a3b8; border-color: #94a3b8; color: #e2e8f0; }
QPushButton#secondary { background: #ffffff; color: #2563eb; border: 1px solid #cbd5e1; }
QPushButton#secondary:hover { background: #f1f5f9; border-color: #2563eb; }
QPushButton#secondary:disabled { color: #94a3b8; background: #f1f5f9; border-color: #e2e8f0; }
/* --- 日志区 (浅色终端风) --- */
QTextEdit#logView {
background: #f8fafc; color: #334155;
font-family: "Consolas", "Cascadia Code", "Courier New", monospace;
font-size: 9.5pt; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 10px; line-height: 1.5;
}
/* --- Tab --- */
QTabWidget::pane { border: 1px solid #e2e8f0; border-radius: 8px; top: -1px; background: #ffffff; }
QTabBar::tab {
padding: 8px 24px; font-size: 10pt; font-weight: 600; color: #64748b;
background: #f1f5f9; border: 1px solid #e2e8f0; border-bottom: none;
border-top-left-radius: 6px; border-top-right-radius: 6px; margin-right: 4px;
}
QTabBar::tab:selected { background: #ffffff; color: #2563eb; border-bottom: 2px solid #2563eb; }
QTabBar::tab:hover:!selected { background: #e2e8f0; color: #334155; }
/* --- 表格 --- */
QTableWidget {
gridline-color: #f1f5f9; font-size: 10pt; color: #334155;
border: 1px solid #e2e8f0; border-radius: 6px; background: #ffffff;
alternate-background-color: #f8fafc;
}
QTableWidget::item { padding: 6px; }
QHeaderView::section {
background: #1e293b; color: #f1f5f9; padding: 8px;
font-weight: 600; font-size: 9.5pt; border: none;
}
/* --- 状态栏 --- */
QStatusBar { background: #1e293b; color: #e2e8f0; font-size: 9.5pt; }
QStatusBar QLabel { color: #e2e8f0; font-size: 9.5pt; padding: 2px 8px; }
/* --- 滚动条 --- */
QScrollBar:vertical { width: 10px; background: #f1f5f9; border-radius: 5px; }
QScrollBar::handle:vertical { background: #cbd5e1; border-radius: 5px; min-height: 30px; }
QScrollBar::handle:vertical:hover { background: #94a3b8; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar:horizontal { height: 10px; background: #f1f5f9; border-radius: 5px; }
QScrollBar::handle:horizontal { background: #cbd5e1; border-radius: 5px; min-width: 30px; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
/* --- 复选框 --- */
QCheckBox { spacing: 8px; color: #334155; font-size: 10pt; }
QCheckBox::indicator { width: 18px; height: 18px; border: 2px solid #cbd5e1; border-radius: 4px; background: #ffffff; }
QCheckBox::indicator:checked { background: #2563eb; border-color: #2563eb; }
"""
class SolverWorker(QObject):
"""后台仿真线程, 通过信号与 GUI 通信。"""
log = pyqtSignal(str)
result_single = pyqtSignal(dict)
result_sweep = pyqtSignal(dict)
finished = pyqtSignal()
error = pyqtSignal(str)
def __init__(self, mode, params):
super().__init__()
self.mode = mode
self.params = params
def run(self):
try:
from solver import Solver
p = self.params
solver = Solver(
mot_path=p["mot_path"], out_dir=p["out_dir"],
rms_current=p["rms_current"], speed_rpm=p["speed_rpm"],
airgap_mm=p["airgap_mm"], magnet_temp_c=p["magnet_temp_c"],
sec_radii_mm=p.get("sec_radii_mm", [28.25, 34.75]),
motorcad_exe=p.get("motorcad_exe") or None,
keep_open=p.get("keep_open", True),
log_cb=lambda msg: self.log.emit(msg),
)
if self.mode == "single":
results = solver.run_single()
self.result_single.emit(results)
else:
results = solver.run_sweep(p["sweep_gaps"])
self.result_sweep.emit(results)
except Exception as e:
tb = traceback.format_exc()
self.error.emit("%s\n%s" % (e, tb))
finally:
self.finished.emit()
class ResultCard(QFrame):
"""结果数值卡片: 大数字 + 标签 + 单位"""
def __init__(self, label, unit="N"):
super().__init__()
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet("""
ResultCard { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 10px; }
""")
lay = QVBoxLayout(self)
lay.setContentsMargins(16, 14, 16, 14)
lay.setSpacing(2)
self.lbl_label = QLabel(label)
self.lbl_label.setObjectName("cardLabel")
self.lbl_value = QLabel("—")
self.lbl_value.setObjectName("cardValue")
self.lbl_value.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.lbl_unit = QLabel(unit)
self.lbl_unit.setObjectName("cardUnit")
val_row = QHBoxLayout()
val_row.setSpacing(4)
val_row.addWidget(self.lbl_value)
val_row.addWidget(self.lbl_unit)
val_row.addStretch()
lay.addWidget(self.lbl_label)
lay.addLayout(val_row)
def set_value(self, val, fmt="%.1f"):
self.lbl_value.setText(fmt % val if val is not None else "—")
def set_color(self, color):
self.lbl_value.setStyleSheet("color: %s;" % color)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("MARS 轴向磁拉力仿真工具")
self.resize(1200, 780)
self.setMinimumSize(1024, 680)
self.worker = None
self.thread = None
self.last_results = None
self.last_mode = None
self._build_ui()
self._set_status("就绪", "#94a3b8")
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root = QHBoxLayout(central)
root.setContentsMargins(12, 12, 12, 8)
root.setSpacing(12)
# ============ 左侧参数面板 ============
left = QVBoxLayout()
left.setSpacing(10)
# 模型与路径
gb_file = QGroupBox("模型与路径")
gl = QGridLayout(gb_file)
gl.setSpacing(8)
gl.setVerticalSpacing(10)
self.ed_mot = QLineEdit(DEFAULT_MOT)
btn_mot = QPushButton("浏览")
btn_mot.setObjectName("secondary")
btn_mot.setFixedWidth(64)
btn_mot.clicked.connect(self._browse_mot)
gl.addWidget(QLabel("模型文件"), 0, 0)
gl.addWidget(self.ed_mot, 0, 1)
gl.addWidget(btn_mot, 0, 2)
self.ed_out = QLineEdit(DEFAULT_OUT)
btn_out = QPushButton("浏览")
btn_out.setObjectName("secondary")
btn_out.setFixedWidth(64)
btn_out.clicked.connect(self._browse_out)
gl.addWidget(QLabel("输出目录"), 1, 0)
gl.addWidget(self.ed_out, 1, 1)
gl.addWidget(btn_out, 1, 2)
self.ed_mc = QLineEdit(DEFAULT_MC_EXE)
gl.addWidget(QLabel("MC exe 路径"), 2, 0)
gl.addWidget(self.ed_mc, 2, 1, 1, 2)
left.addWidget(gb_file)
# 工况参数
gb_param = QGroupBox("工况参数")
gp = QGridLayout(gb_param)
gp.setSpacing(8)
gp.setVerticalSpacing(10)
self.sp_rms = QDoubleSpinBox(); self.sp_rms.setRange(0, 200); self.sp_rms.setValue(21.0); self.sp_rms.setSuffix(" A")
self.sp_speed = QSpinBox(); self.sp_speed.setRange(100, 100000); self.sp_speed.setValue(5000); self.sp_speed.setSuffix(" rpm")
self.sp_airgap = QDoubleSpinBox(); self.sp_airgap.setRange(0.1, 10); self.sp_airgap.setSingleStep(0.1); self.sp_airgap.setValue(1.0); self.sp_airgap.setSuffix(" mm")
self.sp_temp = QDoubleSpinBox(); self.sp_temp.setRange(-40, 200); self.sp_temp.setValue(100.0); self.sp_temp.setSuffix(" °C")
gp.addWidget(QLabel("RMS 电流"), 0, 0); gp.addWidget(self.sp_rms, 0, 1)
gp.addWidget(QLabel("转速"), 0, 2); gp.addWidget(self.sp_speed, 0, 3)
gp.addWidget(QLabel("气隙"), 1, 0); gp.addWidget(self.sp_airgap, 1, 1)
gp.addWidget(QLabel("磁钢温度"), 1, 2); gp.addWidget(self.sp_temp, 1, 3)
self.cb_keep = QCheckBox("运行后保持 Motor-CAD 打开")
self.cb_keep.setChecked(True)
gp.addWidget(self.cb_keep, 2, 0, 1, 4)
left.addWidget(gb_param)
# 运行模式 Tab
self.tabs = QTabWidget()
# 单点
tab_single = QWidget()
ts = QVBoxLayout(tab_single)
ts.setContentsMargins(8, 12, 8, 8)
self.btn_run_single = QPushButton("▶ 运行单点仿真")
self.btn_run_single.setMinimumHeight(42)
self.btn_run_single.setStyleSheet("font-size: 11pt;")
self.btn_run_single.clicked.connect(lambda: self._start_run("single"))
ts.addWidget(self.btn_run_single)
ts.addStretch()
self.tabs.addTab(tab_single, " 单点仿真 ")
# 气隙扫描
tab_sweep = QWidget()
sw = QGridLayout(tab_sweep)
sw.setContentsMargins(8, 12, 8, 8)
sw.setSpacing(8)
self.sp_gap_start = QDoubleSpinBox(); self.sp_gap_start.setRange(0.1, 10); self.sp_gap_start.setSingleStep(0.1); self.sp_gap_start.setValue(0.6); self.sp_gap_start.setSuffix(" mm")
self.sp_gap_end = QDoubleSpinBox(); self.sp_gap_end.setRange(0.1, 10); self.sp_gap_end.setSingleStep(0.1); self.sp_gap_end.setValue(1.5); self.sp_gap_end.setSuffix(" mm")
self.sp_gap_step = QDoubleSpinBox(); self.sp_gap_step.setRange(0.05, 5); self.sp_gap_step.setSingleStep(0.05); self.sp_gap_step.setValue(0.2); self.sp_gap_step.setSuffix(" mm")
sw.addWidget(QLabel("起始气隙"), 0, 0); sw.addWidget(self.sp_gap_start, 0, 1)
sw.addWidget(QLabel("结束气隙"), 0, 2); sw.addWidget(self.sp_gap_end, 0, 3)
sw.addWidget(QLabel("步长"), 1, 0); sw.addWidget(self.sp_gap_step, 1, 1)
self.lbl_sweep_preview = QLabel("")
self.lbl_sweep_preview.setStyleSheet("color: #64748b; font-size: 9pt; padding: 4px 0;")
self.lbl_sweep_preview.setWordWrap(True)
sw.addWidget(self.lbl_sweep_preview, 1, 2, 1, 2)
self.btn_run_sweep = QPushButton("▶ 运行气隙扫描")
self.btn_run_sweep.setMinimumHeight(42)
self.btn_run_sweep.setStyleSheet("font-size: 11pt;")
self.btn_run_sweep.clicked.connect(lambda: self._start_run("sweep"))
sw.addWidget(self.btn_run_sweep, 2, 0, 1, 4)
sw.setRowStretch(3, 1)
for sp in (self.sp_gap_start, self.sp_gap_end, self.sp_gap_step):
sp.valueChanged.connect(self._update_sweep_preview)
self._update_sweep_preview()
self.tabs.addTab(tab_sweep, " 气隙扫描 ")
left.addWidget(self.tabs)
left.addStretch()
left_w = QWidget()
left_w.setLayout(left)
left_w.setFixedWidth(400)
# ============ 右侧: 结果 + 日志 ============
right = QVBoxLayout()
right.setSpacing(10)
# --- 结果面板 (卡片式) ---
gb_result = QGroupBox("运行结果")
res_lay = QVBoxLayout(gb_result)
res_lay.setSpacing(10)
# 卡片行
card_row = QHBoxLayout()
card_row.setSpacing(10)
self.card_oc = ResultCard("空载净轴向力", "N")
self.card_ol = ResultCard("负载净轴向力", "N")
self.card_ripple = ResultCard("纹波峰峰", "N")
self.card_kneg = ResultCard("磁负刚度", "N/mm")
self.card_kneg.setVisible(False)
for c in (self.card_oc, self.card_ol, self.card_ripple, self.card_kneg):
c.setMinimumHeight(88)
card_row.addWidget(c)
res_lay.addLayout(card_row)
# 判据 + 摘要
self.lbl_checks = QLabel("尚未运行仿真。")
self.lbl_checks.setWordWrap(True)
self.lbl_checks.setStyleSheet("""
QLabel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;
padding: 10px 14px; color: #64748b; font-size: 9.5pt; }
""")
res_lay.addWidget(self.lbl_checks)
# 结果表格
self.table_result = QTableWidget(0, 4)
self.table_result.setHorizontalHeaderLabels(["工况", "净轴向力 (N)", "纹波峰峰 (N)", "备注"])
self.table_result.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table_result.verticalHeader().setVisible(False)
self.table_result.setAlternatingRowColors(True)
self.table_result.setVisible(False)
self.table_result.setMaximumHeight(160)
res_lay.addWidget(self.table_result)
# 导出按钮
btn_row = QHBoxLayout()
self.btn_export_json = QPushButton("导出 JSON")
self.btn_export_json.setObjectName("secondary")
self.btn_export_json.setEnabled(False)
self.btn_export_json.clicked.connect(self._export_json)
self.btn_export_csv = QPushButton("导出 CSV")
self.btn_export_csv.setObjectName("secondary")
self.btn_export_csv.setEnabled(False)
self.btn_export_csv.clicked.connect(self._export_csv)
btn_row.addWidget(self.btn_export_json)
btn_row.addWidget(self.btn_export_csv)
btn_row.addStretch()
res_lay.addLayout(btn_row)
right.addWidget(gb_result, 4)
# --- 日志区 ---
gb_log = QGroupBox("运行日志")
log_lay = QVBoxLayout(gb_log)
log_lay.setContentsMargins(10, 14, 10, 10)
self.txt_log = QTextEdit()
self.txt_log.setObjectName("logView")
self.txt_log.setReadOnly(True)
log_lay.addWidget(self.txt_log)
right.addWidget(gb_log, 5)
right_w = QWidget()
right_w.setLayout(right)
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(left_w)
splitter.addWidget(right_w)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setHandleWidth(2)
root.addWidget(splitter)
# 状态栏
self.statusBar = QStatusBar()
self.setStatusBar(self.statusBar)
self.lbl_status = QLabel("就绪")
self.lbl_runtime = QLabel("")
self.statusBar.addWidget(self.lbl_status, 1)
self.statusBar.addPermanentWidget(self.lbl_runtime)
# ================================================================
# 交互
# ================================================================
def _set_status(self, text, color="#e2e8f0"):
self.lbl_status.setText(text)
self.lbl_status.setStyleSheet("color: %s;" % color)
def _browse_mot(self):
path, _ = QFileDialog.getOpenFileName(self, "选择 Motor-CAD 模型", self.ed_mot.text(), "Motor-CAD (*.mot)")
if path:
self.ed_mot.setText(path)
def _browse_out(self):
path = QFileDialog.getExistingDirectory(self, "选择输出目录", self.ed_out.text())
if path:
self.ed_out.setText(path)
def _update_sweep_preview(self):
gaps = self._calc_sweep_gaps()
if gaps:
est = len(gaps) * 2.5
self.lbl_sweep_preview.setText("将求解 %d 个气隙点 (%.1f~%.1fmm), 预计约 %.0f 分钟"
% (len(gaps), gaps[0], gaps[-1], est))
else:
self.lbl_sweep_preview.setText("参数无效 (起始应 < 结束, 步长 > 0)")
def _calc_sweep_gaps(self):
s, e, step = self.sp_gap_start.value(), self.sp_gap_end.value(), self.sp_gap_step.value()
if s >= e or step <= 0:
return []
gaps = []
g = s
while g <= e + 1e-9:
gaps.append(round(g, 4))
g += step
return gaps
def _collect_params(self):
return {
"mot_path": self.ed_mot.text().strip(),
"out_dir": self.ed_out.text().strip(),
"rms_current": self.sp_rms.value(),
"speed_rpm": self.sp_speed.value(),
"airgap_mm": self.sp_airgap.value(),
"magnet_temp_c": self.sp_temp.value(),
"motorcad_exe": self.ed_mc.text().strip(),
"keep_open": self.cb_keep.isChecked(),
"sec_radii_mm": [28.25, 34.75],
}
def _start_run(self, mode):
if self.worker and self.thread and self.thread.isRunning():
QMessageBox.warning(self, "正在运行", "仿真正在进行中, 请等待完成。")
return
p = self._collect_params()
if not os.path.isfile(p["mot_path"]):
QMessageBox.critical(self, "错误", "模型文件不存在:\n%s" % p["mot_path"])
return
if mode == "sweep":
gaps = self._calc_sweep_gaps()
if not gaps:
QMessageBox.critical(self, "错误", "气隙扫描参数无效。")
return
p["sweep_gaps"] = gaps
if len(gaps) > 10:
reply = QMessageBox.question(self, "确认",
"将求解 %d 个气隙点, 预计约 %d 分钟, 确认?" % (len(gaps), len(gaps) * 3))
if reply != QMessageBox.Yes:
return
# 重置界面
self.last_results = None
self.txt_log.clear()
self.table_result.setVisible(False)
self.card_oc.set_value(None); self.card_oc.set_color("#1e293b")
self.card_ol.set_value(None); self.card_ol.set_color("#1e293b")
self.card_ripple.set_value(None); self.card_ripple.set_color("#1e293b")
self.card_kneg.setVisible(False)
self.lbl_checks.setText("仿真运行中...")
self.lbl_checks.setStyleSheet("""
QLabel { background: #fffbeb; border: 1px solid #fbbf24; border-radius: 6px;
padding: 10px 14px; color: #92400e; font-size: 9.5pt; }
""")
self.btn_export_json.setEnabled(False)
self.btn_export_csv.setEnabled(False)
self._set_running(True)
self._set_status("● 运行中... Motor-CAD 求解中", "#fbbf24")
self.lbl_runtime.setText("")
# 启动线程
self.thread = QThread()
self.worker = SolverWorker(mode, p)
self.worker.moveToThread(self.thread)
self.thread.started.connect(self.worker.run)
self.worker.log.connect(self._append_log)
self.worker.result_single.connect(self._on_single_result)
self.worker.result_sweep.connect(self._on_sweep_result)
self.worker.error.connect(self._on_error)
self.worker.finished.connect(self._on_finished)
self.worker.finished.connect(self.thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.thread.finished.connect(self.thread.deleteLater)
self.last_mode = mode
self._run_start_time = __import__("time").time()
self.thread.start()
def _set_running(self, running):
for btn in (self.btn_run_single, self.btn_run_sweep):
btn.setEnabled(not running)
if running:
self.btn_run_single.setText("⏳ 运行中...")
self.btn_run_sweep.setText("⏳ 运行中...")
else:
self.btn_run_single.setText("▶ 运行单点仿真")
self.btn_run_sweep.setText("▶ 运行气隙扫描")
def _append_log(self, msg):
"""彩色日志: 根据内容前缀着色"""
msg = msg.rstrip()
if not msg:
return
# 判断颜色
if msg.startswith("[结论]") or "✓" in msg:
color = "#16a34a" # 绿
weight = "600"
elif msg.startswith("[错误]") or msg.startswith("[ERROR]") or "Traceback" in msg:
color = "#dc2626" # 红
weight = "600"
elif msg.startswith("[警告]"):
color = "#d97706" # 橙
weight = "600"
elif msg.startswith("[提示]"):
color = "#2563eb" # 蓝
weight = "500"
elif msg.startswith("== ") or msg.startswith("启动") or "求解" in msg[:6]:
color = "#475569" # 深灰 (阶段标题)
weight = "600"
elif "RESULTS:" in msg or "结果文件:" in msg or "扫描结果:" in msg:
color = "#7c3aed" # 紫 (文件路径)
weight = "500"
else:
color = "#334155" # 默认
weight = "400"
import html
escaped = html.escape(msg)
self.txt_log.append('%s' % (color, weight, escaped))
sb = self.txt_log.verticalScrollBar()
sb.setValue(sb.maximum())
def _on_single_result(self, results):
try:
self.last_results = results
forces = results.get("axial_forces", {}) or {}
checks = results.get("checks", {}) or {}
# 安全访问: forces[graph] 可能为 None
oc_data = forces.get("Fr_Rotor_OC_Lumped") or {}
ol_data = forces.get("Fr_Rotor_OL_Lumped") or {}
oc = oc_data.get("stats", {}) or {}
ol = ol_data.get("stats", {}) or {}
self.card_oc.set_value(oc.get("mean"))
self.card_ol.set_value(ol.get("mean"))
self.card_ripple.set_value(oc.get("pk2pk"))
self.card_oc.set_color("#16a34a")
self.card_ol.set_color("#2563eb")
self.card_ripple.set_color("#64748b")
# 判据文本
parts = []
ar_ol = (checks.get("action_reaction_OL") or {}).get("imbalance_pct")
ar_oc = (checks.get("action_reaction_OC") or {}).get("imbalance_pct")
if ar_ol is not None:
ok = ar_ol < 5
parts.append(("%s 作用反作用 OL=%.1f%%" % ("✓" if ok else "✗", ar_ol),
"#16a34a" if ok else "#dc2626"))
if ar_oc is not None:
ok = ar_oc < 5
parts.append(("%s OC=%.1f%%" % ("✓" if ok else "✗", ar_oc),
"#16a34a" if ok else "#dc2626"))
tq = checks.get("torque_crosscheck") or {}
if tq.get("sum_Ft_x_r_Nm_t0") and tq.get("torque_graph_mean_Nm"):
diff = abs(tq["sum_Ft_x_r_Nm_t0"] - tq["torque_graph_mean_Nm"]) \
/ max(abs(tq["torque_graph_mean_Nm"]), 1e-9) * 100
ok = diff < 10
parts.append(("%s 转矩交叉=%.1f%% (%.3f vs %.3f Nm)"
% ("✓" if ok else "✗", diff,
tq["sum_Ft_x_r_Nm_t0"], tq["torque_graph_mean_Nm"]),
"#16a34a" if ok else "#dc2626"))
an = checks.get("analytic") or {}
if an.get("F_est_N"):
parts.append(("解析量级=%.0fN (同量级)" % an["F_est_N"], "#7c3aed"))
html_parts = " ".join(
'%s' % (c, t) for t, c in parts
)
self.lbl_checks.setText(html_parts or "无判据数据")
self.lbl_checks.setStyleSheet("""
QLabel { background: #f0fdf4; border: 1px solid #86efac; border-radius: 6px;
padding: 10px 14px; font-size: 9.5pt; }
""")
# 表格
rows = []
rms = (results.get("params") or {}).get("rms_current", 0)
for case, label in (("OC", "空载 (开路)"), ("OL", "负载 (RMS %.0fA)" % rms)):
v = forces.get("Fr_Rotor_%s_Lumped" % case)
if v and v.get("stats"):
s = v["stats"]
rows.append((label, "%.1f" % s["mean"], "%.2f" % s["pk2pk"], "指向定子"))
self._fill_table(rows)
self.btn_export_json.setEnabled(True)
self.btn_export_csv.setEnabled(True)
except Exception as e:
self._append_log("[错误] 结果显示异常: %s" % e)
traceback.print_exc()
def _on_sweep_result(self, results):
try:
self.last_results = results
cases = results.get("cases", []) or []
kneg = results.get("kneg_N_per_mm", {}) or {}
# 卡片: 用第一个和最后一个气隙的力
if cases:
first = cases[0] or {}
last = cases[-1] or {}
foc_first = (first.get("F_OC") or {}).get("mean")
foc_last = (last.get("F_OC") or {}).get("mean")
self.card_oc.set_value(foc_first)
self.card_oc.lbl_label.setText(
"起始气隙 %.2fmm 空载力" % (first.get("airgap_mm", 0) or 0))
self.card_ol.set_value(foc_last)
self.card_ol.lbl_label.setText(
"结束气隙 %.2fmm 空载力" % (last.get("airgap_mm", 0) or 0))
# 平均纹波
ripples = [(c.get("F_OC") or {}).get("pk2pk", 0) for c in cases
if (c.get("F_OC") or {}).get("pk2pk") is not None]
if ripples:
self.card_ripple.set_value(sum(ripples) / len(ripples))
self.card_oc.set_color("#16a34a")
self.card_ol.set_color("#2563eb")
self.card_ripple.set_color("#64748b")
# 磁负刚度卡片
if kneg:
central = kneg.get("central")
if central is not None:
self.card_kneg.set_value(central)
self.card_kneg.set_color("#7c3aed")
self.card_kneg.setVisible(True)
kneg_txt = " | ".join("%s=%.0f" % (k, v) for k, v in kneg.items()) if kneg else "—"
self.lbl_checks.setText(
'✓ 扫描完成 '
'磁负刚度 (N/mm): %s' % kneg_txt)
self.lbl_checks.setStyleSheet("""
QLabel { background: #f0fdf4; border: 1px solid #86efac; border-radius: 6px;
padding: 10px 14px; font-size: 9.5pt; }
""")
rows = []
for c in cases:
if not c:
continue
g = c.get("airgap_mm", 0)
foc = (c.get("F_OC") or {}).get("mean", 0)
fol = (c.get("F_OL") or {}).get("mean", 0)
rip = (c.get("F_OC") or {}).get("pk2pk", 0)
rows.append(("气隙 %.2fmm" % g, "OC=%.1f / OL=%.1f" % (foc, fol),
"%.2f" % rip, "耗时%.0fs" % (c.get("solve_seconds", 0) or 0)))
self._fill_table(rows)
self.btn_export_json.setEnabled(True)
self.btn_export_csv.setEnabled(False)
except Exception as e:
self._append_log("[错误] 扫描结果显示异常: %s" % e)
traceback.print_exc()
def _fill_table(self, rows):
self.table_result.setVisible(True)
self.table_result.setRowCount(len(rows))
for i, (a, b, c, d) in enumerate(rows):
for j, val in enumerate((a, b, c, d)):
item = QTableWidgetItem(str(val))
item.setTextAlignment(Qt.AlignCenter)
self.table_result.setItem(i, j, item)
def _on_error(self, msg):
try:
self.lbl_checks.setText('✗ 运行出错, 详见日志')
self.lbl_checks.setStyleSheet("""
QLabel { background: #fef2f2; border: 1px solid #fca5a5; border-radius: 6px;
padding: 10px 14px; font-size: 9.5pt; }
""")
self._append_log("[错误] %s" % msg)
except Exception:
traceback.print_exc()
def _on_finished(self):
try:
elapsed = time.time() - getattr(self, "_run_start_time", time.time())
self._set_running(False)
# 检查是否有结果 (成功) 还是出错
if self.last_results is not None:
self._set_status("✓ 运行完成 — 结果已显示在上方", "#86efac")
self.lbl_runtime.setText("总耗时 %.0fs" % elapsed)
else:
self._set_status("✗ 运行出错 — 详见日志", "#fca5a5")
self.lbl_runtime.setText("")
except Exception as e:
traceback.print_exc()
finally:
# 延迟清理线程引用, 避免与 deleteLater 竞态
from PyQt5.QtCore import QTimer
QTimer.singleShot(100, self._cleanup_thread)
def _cleanup_thread(self):
self.worker = None
self.thread = None
def _export_json(self):
if not self.last_results:
return
path, _ = QFileDialog.getSaveFileName(self, "导出 JSON", "axialforce_result.json", "JSON (*.json)")
if path:
with open(path, "w", encoding="utf-8") as f:
json.dump(self.last_results, f, ensure_ascii=False, indent=2)
self._append_log("[提示] JSON 已导出 -> %s" % path)
def _export_csv(self):
if not self.last_results or self.last_mode != "single":
return
path, _ = QFileDialog.getSaveFileName(self, "导出 CSV", "axial_force.csv", "CSV (*.csv)")
if path:
forces = self.last_results.get("axial_forces", {})
graphs = ["Fr_Rotor_OL_Lumped", "Fr_Stator_OL_Lumped",
"Fr_Rotor_OC_Lumped", "Fr_Stator_OC_Lumped"]
nmax = max(len(v["series_N"]) if v else 0 for v in forces.values())
with open(path, "w", encoding="utf-8") as f:
f.write("tstep," + ",".join(graphs) + "\n")
for i in range(nmax):
row = [str(i)]
for g in graphs:
v = forces.get(g)
row.append("%.4f" % v["series_N"][i] if v and i < len(v["series_N"]) else "")
f.write(",".join(row) + "\n")
self._append_log("[提示] CSV 已导出 -> %s" % path)
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
app.setStyleSheet(STYLE)
# 关键: 防止子窗口/对话框关闭时触发整个应用退出
app.setQuitOnLastWindowClosed(False)
# 全局默认字体
font = QFont("Microsoft YaHei", 10)
app.setFont(font)
# 全局异常钩子: 捕获未处理异常, 防止 --windowed 模式下静默闪退
def _excepthook(exc_type, exc_value, exc_tb):
import traceback
msg = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
try:
with open(os.path.join(os.path.expanduser("~"), "axial_force_gui_error.log"),
"a", encoding="utf-8") as f:
f.write("\n=== %s ===\n%s" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg))
except Exception:
pass
# 尝试在 GUI 中显示错误
try:
QMessageBox.critical(None, "程序异常",
"发生未捕获异常:\n%s\n\n详情已写入 ~/axial_force_gui_error.log"
% str(exc_value))
except Exception:
pass
sys.excepthook = _excepthook
w = MainWindow()
w.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()