main.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790
  1. # -*- coding: utf-8 -*-
  2. """
  3. MARS 轴向磁拉力仿真工具 — PyQt5 GUI (现代工业风 v2)
  4. ====================================================
  5. 双击运行 (或 python main.py), 调整参数后点击运行, 无需 AI token。
  6. """
  7. import os
  8. import sys
  9. import json
  10. import time
  11. import traceback
  12. from PyQt5.QtCore import Qt, QThread, pyqtSignal, QObject, QSize
  13. from PyQt5.QtWidgets import (
  14. QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
  15. QGridLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QCheckBox,
  16. QTabWidget, QTextEdit, QGroupBox, QDoubleSpinBox, QSpinBox,
  17. QTableWidget, QTableWidgetItem, QHeaderView, QMessageBox, QSplitter,
  18. QFrame, QSizePolicy, QStatusBar, QScrollArea
  19. )
  20. from PyQt5.QtGui import QFont, QColor, QPalette
  21. BASE = os.path.dirname(os.path.abspath(__file__))
  22. DEFAULT_MOT = os.path.normpath(
  23. os.path.join(BASE, "..", "MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot"))
  24. DEFAULT_OUT = os.path.normpath(os.path.join(BASE, "..", "output_motorcad"))
  25. DEFAULT_MC_EXE = r"D:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe"
  26. # ============================================================
  27. # 现代工业风 QSS — 浅灰蓝底 + 白色卡片 + 工业蓝强调
  28. # ============================================================
  29. STYLE = """
  30. * { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 10pt; }
  31. QMainWindow { background: #eef1f5; }
  32. /* --- 卡片式分组 --- */
  33. QGroupBox {
  34. font-weight: 600; font-size: 11pt; color: #1e293b;
  35. border: 1px solid #e2e8f0; border-radius: 8px;
  36. margin-top: 16px; padding-top: 14px; padding-left: 10px; padding-right: 10px;
  37. background: #ffffff;
  38. }
  39. QGroupBox::title {
  40. subcontrol-origin: margin; left: 14px; padding: 0 8px;
  41. background: #ffffff; color: #1e293b;
  42. }
  43. /* --- 标签 --- */
  44. QLabel { color: #334155; font-size: 10pt; }
  45. QLabel#cardValue { font-size: 22pt; font-weight: 700; color: #1e293b; }
  46. QLabel#cardUnit { font-size: 11pt; color: #64748b; font-weight: 400; }
  47. QLabel#cardLabel { font-size: 9pt; color: #94a3b8; font-weight: 500; letter-spacing: 1px; }
  48. QLabel#sectionTitle { font-size: 11pt; font-weight: 600; color: #1e293b; padding-bottom: 4px; }
  49. /* --- 输入框 --- */
  50. QLineEdit, QDoubleSpinBox, QSpinBox {
  51. padding: 6px 10px; border: 1px solid #cbd5e1; border-radius: 6px;
  52. background: #f8fafc; color: #1e293b; font-size: 10pt; min-height: 22px;
  53. selection-background-color: #2563eb; selection-color: white;
  54. }
  55. QLineEdit:focus, QDoubleSpinBox:focus, QSpinBox:focus {
  56. border: 1px solid #2563eb; background: #ffffff;
  57. }
  58. QDoubleSpinBox::up-button, QSpinBox::up-button,
  59. QDoubleSpinBox::down-button, QSpinBox::down-button { width: 18px; }
  60. /* --- 按钮 --- */
  61. QPushButton {
  62. padding: 8px 20px; border-radius: 6px; font-size: 10pt; font-weight: 600;
  63. border: 1px solid #2563eb; background: #2563eb; color: white;
  64. }
  65. QPushButton:hover { background: #1d4ed8; border-color: #1d4ed8; }
  66. QPushButton:pressed { background: #1e40af; }
  67. QPushButton:disabled { background: #94a3b8; border-color: #94a3b8; color: #e2e8f0; }
  68. QPushButton#secondary { background: #ffffff; color: #2563eb; border: 1px solid #cbd5e1; }
  69. QPushButton#secondary:hover { background: #f1f5f9; border-color: #2563eb; }
  70. QPushButton#secondary:disabled { color: #94a3b8; background: #f1f5f9; border-color: #e2e8f0; }
  71. /* --- 日志区 (浅色终端风) --- */
  72. QTextEdit#logView {
  73. background: #f8fafc; color: #334155;
  74. font-family: "Consolas", "Cascadia Code", "Courier New", monospace;
  75. font-size: 9.5pt; border: 1px solid #e2e8f0; border-radius: 8px;
  76. padding: 10px; line-height: 1.5;
  77. }
  78. /* --- Tab --- */
  79. QTabWidget::pane { border: 1px solid #e2e8f0; border-radius: 8px; top: -1px; background: #ffffff; }
  80. QTabBar::tab {
  81. padding: 8px 24px; font-size: 10pt; font-weight: 600; color: #64748b;
  82. background: #f1f5f9; border: 1px solid #e2e8f0; border-bottom: none;
  83. border-top-left-radius: 6px; border-top-right-radius: 6px; margin-right: 4px;
  84. }
  85. QTabBar::tab:selected { background: #ffffff; color: #2563eb; border-bottom: 2px solid #2563eb; }
  86. QTabBar::tab:hover:!selected { background: #e2e8f0; color: #334155; }
  87. /* --- 表格 --- */
  88. QTableWidget {
  89. gridline-color: #f1f5f9; font-size: 10pt; color: #334155;
  90. border: 1px solid #e2e8f0; border-radius: 6px; background: #ffffff;
  91. alternate-background-color: #f8fafc;
  92. }
  93. QTableWidget::item { padding: 6px; }
  94. QHeaderView::section {
  95. background: #1e293b; color: #f1f5f9; padding: 8px;
  96. font-weight: 600; font-size: 9.5pt; border: none;
  97. }
  98. /* --- 状态栏 --- */
  99. QStatusBar { background: #1e293b; color: #e2e8f0; font-size: 9.5pt; }
  100. QStatusBar QLabel { color: #e2e8f0; font-size: 9.5pt; padding: 2px 8px; }
  101. /* --- 滚动条 --- */
  102. QScrollBar:vertical { width: 10px; background: #f1f5f9; border-radius: 5px; }
  103. QScrollBar::handle:vertical { background: #cbd5e1; border-radius: 5px; min-height: 30px; }
  104. QScrollBar::handle:vertical:hover { background: #94a3b8; }
  105. QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
  106. QScrollBar:horizontal { height: 10px; background: #f1f5f9; border-radius: 5px; }
  107. QScrollBar::handle:horizontal { background: #cbd5e1; border-radius: 5px; min-width: 30px; }
  108. QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
  109. /* --- 复选框 --- */
  110. QCheckBox { spacing: 8px; color: #334155; font-size: 10pt; }
  111. QCheckBox::indicator { width: 18px; height: 18px; border: 2px solid #cbd5e1; border-radius: 4px; background: #ffffff; }
  112. QCheckBox::indicator:checked { background: #2563eb; border-color: #2563eb; }
  113. """
  114. class SolverWorker(QObject):
  115. """后台仿真线程, 通过信号与 GUI 通信。"""
  116. log = pyqtSignal(str)
  117. result_single = pyqtSignal(dict)
  118. result_sweep = pyqtSignal(dict)
  119. finished = pyqtSignal()
  120. error = pyqtSignal(str)
  121. def __init__(self, mode, params):
  122. super().__init__()
  123. self.mode = mode
  124. self.params = params
  125. def run(self):
  126. try:
  127. from solver import Solver
  128. p = self.params
  129. solver = Solver(
  130. mot_path=p["mot_path"], out_dir=p["out_dir"],
  131. rms_current=p["rms_current"], speed_rpm=p["speed_rpm"],
  132. airgap_mm=p["airgap_mm"], magnet_temp_c=p["magnet_temp_c"],
  133. sec_radii_mm=p.get("sec_radii_mm", [28.25, 34.75]),
  134. motorcad_exe=p.get("motorcad_exe") or None,
  135. keep_open=p.get("keep_open", True),
  136. log_cb=lambda msg: self.log.emit(msg),
  137. )
  138. if self.mode == "single":
  139. results = solver.run_single()
  140. self.result_single.emit(results)
  141. else:
  142. results = solver.run_sweep(p["sweep_gaps"])
  143. self.result_sweep.emit(results)
  144. except Exception as e:
  145. tb = traceback.format_exc()
  146. self.error.emit("%s\n%s" % (e, tb))
  147. finally:
  148. self.finished.emit()
  149. class ResultCard(QFrame):
  150. """结果数值卡片: 大数字 + 标签 + 单位"""
  151. def __init__(self, label, unit="N"):
  152. super().__init__()
  153. self.setFrameShape(QFrame.StyledPanel)
  154. self.setStyleSheet("""
  155. ResultCard { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 10px; }
  156. """)
  157. lay = QVBoxLayout(self)
  158. lay.setContentsMargins(16, 14, 16, 14)
  159. lay.setSpacing(2)
  160. self.lbl_label = QLabel(label)
  161. self.lbl_label.setObjectName("cardLabel")
  162. self.lbl_value = QLabel("—")
  163. self.lbl_value.setObjectName("cardValue")
  164. self.lbl_value.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
  165. self.lbl_unit = QLabel(unit)
  166. self.lbl_unit.setObjectName("cardUnit")
  167. val_row = QHBoxLayout()
  168. val_row.setSpacing(4)
  169. val_row.addWidget(self.lbl_value)
  170. val_row.addWidget(self.lbl_unit)
  171. val_row.addStretch()
  172. lay.addWidget(self.lbl_label)
  173. lay.addLayout(val_row)
  174. def set_value(self, val, fmt="%.1f"):
  175. self.lbl_value.setText(fmt % val if val is not None else "—")
  176. def set_color(self, color):
  177. self.lbl_value.setStyleSheet("color: %s;" % color)
  178. class MainWindow(QMainWindow):
  179. def __init__(self):
  180. super().__init__()
  181. self.setWindowTitle("MARS 轴向磁拉力仿真工具")
  182. self.resize(1200, 780)
  183. self.setMinimumSize(1024, 680)
  184. self.worker = None
  185. self.thread = None
  186. self.last_results = None
  187. self.last_mode = None
  188. self._build_ui()
  189. self._set_status("就绪", "#94a3b8")
  190. def _build_ui(self):
  191. central = QWidget()
  192. self.setCentralWidget(central)
  193. root = QHBoxLayout(central)
  194. root.setContentsMargins(12, 12, 12, 8)
  195. root.setSpacing(12)
  196. # ============ 左侧参数面板 ============
  197. left = QVBoxLayout()
  198. left.setSpacing(10)
  199. # 模型与路径
  200. gb_file = QGroupBox("模型与路径")
  201. gl = QGridLayout(gb_file)
  202. gl.setSpacing(8)
  203. gl.setVerticalSpacing(10)
  204. self.ed_mot = QLineEdit(DEFAULT_MOT)
  205. btn_mot = QPushButton("浏览")
  206. btn_mot.setObjectName("secondary")
  207. btn_mot.setFixedWidth(64)
  208. btn_mot.clicked.connect(self._browse_mot)
  209. gl.addWidget(QLabel("模型文件"), 0, 0)
  210. gl.addWidget(self.ed_mot, 0, 1)
  211. gl.addWidget(btn_mot, 0, 2)
  212. self.ed_out = QLineEdit(DEFAULT_OUT)
  213. btn_out = QPushButton("浏览")
  214. btn_out.setObjectName("secondary")
  215. btn_out.setFixedWidth(64)
  216. btn_out.clicked.connect(self._browse_out)
  217. gl.addWidget(QLabel("输出目录"), 1, 0)
  218. gl.addWidget(self.ed_out, 1, 1)
  219. gl.addWidget(btn_out, 1, 2)
  220. self.ed_mc = QLineEdit(DEFAULT_MC_EXE)
  221. gl.addWidget(QLabel("MC exe 路径"), 2, 0)
  222. gl.addWidget(self.ed_mc, 2, 1, 1, 2)
  223. left.addWidget(gb_file)
  224. # 工况参数
  225. gb_param = QGroupBox("工况参数")
  226. gp = QGridLayout(gb_param)
  227. gp.setSpacing(8)
  228. gp.setVerticalSpacing(10)
  229. self.sp_rms = QDoubleSpinBox(); self.sp_rms.setRange(0, 200); self.sp_rms.setValue(21.0); self.sp_rms.setSuffix(" A")
  230. self.sp_speed = QSpinBox(); self.sp_speed.setRange(100, 100000); self.sp_speed.setValue(5000); self.sp_speed.setSuffix(" rpm")
  231. 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")
  232. self.sp_temp = QDoubleSpinBox(); self.sp_temp.setRange(-40, 200); self.sp_temp.setValue(100.0); self.sp_temp.setSuffix(" °C")
  233. gp.addWidget(QLabel("RMS 电流"), 0, 0); gp.addWidget(self.sp_rms, 0, 1)
  234. gp.addWidget(QLabel("转速"), 0, 2); gp.addWidget(self.sp_speed, 0, 3)
  235. gp.addWidget(QLabel("气隙"), 1, 0); gp.addWidget(self.sp_airgap, 1, 1)
  236. gp.addWidget(QLabel("磁钢温度"), 1, 2); gp.addWidget(self.sp_temp, 1, 3)
  237. self.cb_keep = QCheckBox("运行后保持 Motor-CAD 打开")
  238. self.cb_keep.setChecked(True)
  239. gp.addWidget(self.cb_keep, 2, 0, 1, 4)
  240. left.addWidget(gb_param)
  241. # 运行模式 Tab
  242. self.tabs = QTabWidget()
  243. # 单点
  244. tab_single = QWidget()
  245. ts = QVBoxLayout(tab_single)
  246. ts.setContentsMargins(8, 12, 8, 8)
  247. self.btn_run_single = QPushButton("▶ 运行单点仿真")
  248. self.btn_run_single.setMinimumHeight(42)
  249. self.btn_run_single.setStyleSheet("font-size: 11pt;")
  250. self.btn_run_single.clicked.connect(lambda: self._start_run("single"))
  251. ts.addWidget(self.btn_run_single)
  252. ts.addStretch()
  253. self.tabs.addTab(tab_single, " 单点仿真 ")
  254. # 气隙扫描
  255. tab_sweep = QWidget()
  256. sw = QGridLayout(tab_sweep)
  257. sw.setContentsMargins(8, 12, 8, 8)
  258. sw.setSpacing(8)
  259. 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")
  260. 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")
  261. 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")
  262. sw.addWidget(QLabel("起始气隙"), 0, 0); sw.addWidget(self.sp_gap_start, 0, 1)
  263. sw.addWidget(QLabel("结束气隙"), 0, 2); sw.addWidget(self.sp_gap_end, 0, 3)
  264. sw.addWidget(QLabel("步长"), 1, 0); sw.addWidget(self.sp_gap_step, 1, 1)
  265. self.lbl_sweep_preview = QLabel("")
  266. self.lbl_sweep_preview.setStyleSheet("color: #64748b; font-size: 9pt; padding: 4px 0;")
  267. self.lbl_sweep_preview.setWordWrap(True)
  268. sw.addWidget(self.lbl_sweep_preview, 1, 2, 1, 2)
  269. self.btn_run_sweep = QPushButton("▶ 运行气隙扫描")
  270. self.btn_run_sweep.setMinimumHeight(42)
  271. self.btn_run_sweep.setStyleSheet("font-size: 11pt;")
  272. self.btn_run_sweep.clicked.connect(lambda: self._start_run("sweep"))
  273. sw.addWidget(self.btn_run_sweep, 2, 0, 1, 4)
  274. sw.setRowStretch(3, 1)
  275. for sp in (self.sp_gap_start, self.sp_gap_end, self.sp_gap_step):
  276. sp.valueChanged.connect(self._update_sweep_preview)
  277. self._update_sweep_preview()
  278. self.tabs.addTab(tab_sweep, " 气隙扫描 ")
  279. left.addWidget(self.tabs)
  280. left.addStretch()
  281. left_w = QWidget()
  282. left_w.setLayout(left)
  283. left_w.setFixedWidth(400)
  284. # ============ 右侧: 结果 + 日志 ============
  285. right = QVBoxLayout()
  286. right.setSpacing(10)
  287. # --- 结果面板 (卡片式) ---
  288. gb_result = QGroupBox("运行结果")
  289. res_lay = QVBoxLayout(gb_result)
  290. res_lay.setSpacing(10)
  291. # 卡片行
  292. card_row = QHBoxLayout()
  293. card_row.setSpacing(10)
  294. self.card_oc = ResultCard("空载净轴向力", "N")
  295. self.card_ol = ResultCard("负载净轴向力", "N")
  296. self.card_ripple = ResultCard("纹波峰峰", "N")
  297. self.card_kneg = ResultCard("磁负刚度", "N/mm")
  298. self.card_kneg.setVisible(False)
  299. for c in (self.card_oc, self.card_ol, self.card_ripple, self.card_kneg):
  300. c.setMinimumHeight(88)
  301. card_row.addWidget(c)
  302. res_lay.addLayout(card_row)
  303. # 判据 + 摘要
  304. self.lbl_checks = QLabel("尚未运行仿真。")
  305. self.lbl_checks.setWordWrap(True)
  306. self.lbl_checks.setStyleSheet("""
  307. QLabel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;
  308. padding: 10px 14px; color: #64748b; font-size: 9.5pt; }
  309. """)
  310. res_lay.addWidget(self.lbl_checks)
  311. # 结果表格
  312. self.table_result = QTableWidget(0, 4)
  313. self.table_result.setHorizontalHeaderLabels(["工况", "净轴向力 (N)", "纹波峰峰 (N)", "备注"])
  314. self.table_result.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
  315. self.table_result.verticalHeader().setVisible(False)
  316. self.table_result.setAlternatingRowColors(True)
  317. self.table_result.setVisible(False)
  318. self.table_result.setMaximumHeight(160)
  319. res_lay.addWidget(self.table_result)
  320. # 导出按钮
  321. btn_row = QHBoxLayout()
  322. self.btn_export_json = QPushButton("导出 JSON")
  323. self.btn_export_json.setObjectName("secondary")
  324. self.btn_export_json.setEnabled(False)
  325. self.btn_export_json.clicked.connect(self._export_json)
  326. self.btn_export_csv = QPushButton("导出 CSV")
  327. self.btn_export_csv.setObjectName("secondary")
  328. self.btn_export_csv.setEnabled(False)
  329. self.btn_export_csv.clicked.connect(self._export_csv)
  330. btn_row.addWidget(self.btn_export_json)
  331. btn_row.addWidget(self.btn_export_csv)
  332. btn_row.addStretch()
  333. res_lay.addLayout(btn_row)
  334. right.addWidget(gb_result, 4)
  335. # --- 日志区 ---
  336. gb_log = QGroupBox("运行日志")
  337. log_lay = QVBoxLayout(gb_log)
  338. log_lay.setContentsMargins(10, 14, 10, 10)
  339. self.txt_log = QTextEdit()
  340. self.txt_log.setObjectName("logView")
  341. self.txt_log.setReadOnly(True)
  342. log_lay.addWidget(self.txt_log)
  343. right.addWidget(gb_log, 5)
  344. right_w = QWidget()
  345. right_w.setLayout(right)
  346. splitter = QSplitter(Qt.Horizontal)
  347. splitter.addWidget(left_w)
  348. splitter.addWidget(right_w)
  349. splitter.setStretchFactor(0, 0)
  350. splitter.setStretchFactor(1, 1)
  351. splitter.setHandleWidth(2)
  352. root.addWidget(splitter)
  353. # 状态栏
  354. self.statusBar = QStatusBar()
  355. self.setStatusBar(self.statusBar)
  356. self.lbl_status = QLabel("就绪")
  357. self.lbl_runtime = QLabel("")
  358. self.statusBar.addWidget(self.lbl_status, 1)
  359. self.statusBar.addPermanentWidget(self.lbl_runtime)
  360. # ================================================================
  361. # 交互
  362. # ================================================================
  363. def _set_status(self, text, color="#e2e8f0"):
  364. self.lbl_status.setText(text)
  365. self.lbl_status.setStyleSheet("color: %s;" % color)
  366. def _browse_mot(self):
  367. path, _ = QFileDialog.getOpenFileName(self, "选择 Motor-CAD 模型", self.ed_mot.text(), "Motor-CAD (*.mot)")
  368. if path:
  369. self.ed_mot.setText(path)
  370. def _browse_out(self):
  371. path = QFileDialog.getExistingDirectory(self, "选择输出目录", self.ed_out.text())
  372. if path:
  373. self.ed_out.setText(path)
  374. def _update_sweep_preview(self):
  375. gaps = self._calc_sweep_gaps()
  376. if gaps:
  377. est = len(gaps) * 2.5
  378. self.lbl_sweep_preview.setText("将求解 %d 个气隙点 (%.1f~%.1fmm), 预计约 %.0f 分钟"
  379. % (len(gaps), gaps[0], gaps[-1], est))
  380. else:
  381. self.lbl_sweep_preview.setText("参数无效 (起始应 < 结束, 步长 > 0)")
  382. def _calc_sweep_gaps(self):
  383. s, e, step = self.sp_gap_start.value(), self.sp_gap_end.value(), self.sp_gap_step.value()
  384. if s >= e or step <= 0:
  385. return []
  386. gaps = []
  387. g = s
  388. while g <= e + 1e-9:
  389. gaps.append(round(g, 4))
  390. g += step
  391. return gaps
  392. def _collect_params(self):
  393. return {
  394. "mot_path": self.ed_mot.text().strip(),
  395. "out_dir": self.ed_out.text().strip(),
  396. "rms_current": self.sp_rms.value(),
  397. "speed_rpm": self.sp_speed.value(),
  398. "airgap_mm": self.sp_airgap.value(),
  399. "magnet_temp_c": self.sp_temp.value(),
  400. "motorcad_exe": self.ed_mc.text().strip(),
  401. "keep_open": self.cb_keep.isChecked(),
  402. "sec_radii_mm": [28.25, 34.75],
  403. }
  404. def _start_run(self, mode):
  405. if self.worker and self.thread and self.thread.isRunning():
  406. QMessageBox.warning(self, "正在运行", "仿真正在进行中, 请等待完成。")
  407. return
  408. p = self._collect_params()
  409. if not os.path.isfile(p["mot_path"]):
  410. QMessageBox.critical(self, "错误", "模型文件不存在:\n%s" % p["mot_path"])
  411. return
  412. if mode == "sweep":
  413. gaps = self._calc_sweep_gaps()
  414. if not gaps:
  415. QMessageBox.critical(self, "错误", "气隙扫描参数无效。")
  416. return
  417. p["sweep_gaps"] = gaps
  418. if len(gaps) > 10:
  419. reply = QMessageBox.question(self, "确认",
  420. "将求解 %d 个气隙点, 预计约 %d 分钟, 确认?" % (len(gaps), len(gaps) * 3))
  421. if reply != QMessageBox.Yes:
  422. return
  423. # 重置界面
  424. self.last_results = None
  425. self.txt_log.clear()
  426. self.table_result.setVisible(False)
  427. self.card_oc.set_value(None); self.card_oc.set_color("#1e293b")
  428. self.card_ol.set_value(None); self.card_ol.set_color("#1e293b")
  429. self.card_ripple.set_value(None); self.card_ripple.set_color("#1e293b")
  430. self.card_kneg.setVisible(False)
  431. self.lbl_checks.setText("仿真运行中...")
  432. self.lbl_checks.setStyleSheet("""
  433. QLabel { background: #fffbeb; border: 1px solid #fbbf24; border-radius: 6px;
  434. padding: 10px 14px; color: #92400e; font-size: 9.5pt; }
  435. """)
  436. self.btn_export_json.setEnabled(False)
  437. self.btn_export_csv.setEnabled(False)
  438. self._set_running(True)
  439. self._set_status("● 运行中... Motor-CAD 求解中", "#fbbf24")
  440. self.lbl_runtime.setText("")
  441. # 启动线程
  442. self.thread = QThread()
  443. self.worker = SolverWorker(mode, p)
  444. self.worker.moveToThread(self.thread)
  445. self.thread.started.connect(self.worker.run)
  446. self.worker.log.connect(self._append_log)
  447. self.worker.result_single.connect(self._on_single_result)
  448. self.worker.result_sweep.connect(self._on_sweep_result)
  449. self.worker.error.connect(self._on_error)
  450. self.worker.finished.connect(self._on_finished)
  451. self.worker.finished.connect(self.thread.quit)
  452. self.worker.finished.connect(self.worker.deleteLater)
  453. self.thread.finished.connect(self.thread.deleteLater)
  454. self.last_mode = mode
  455. self._run_start_time = __import__("time").time()
  456. self.thread.start()
  457. def _set_running(self, running):
  458. for btn in (self.btn_run_single, self.btn_run_sweep):
  459. btn.setEnabled(not running)
  460. if running:
  461. self.btn_run_single.setText("⏳ 运行中...")
  462. self.btn_run_sweep.setText("⏳ 运行中...")
  463. else:
  464. self.btn_run_single.setText("▶ 运行单点仿真")
  465. self.btn_run_sweep.setText("▶ 运行气隙扫描")
  466. def _append_log(self, msg):
  467. """彩色日志: 根据内容前缀着色"""
  468. msg = msg.rstrip()
  469. if not msg:
  470. return
  471. # 判断颜色
  472. if msg.startswith("[结论]") or "✓" in msg:
  473. color = "#16a34a" # 绿
  474. weight = "600"
  475. elif msg.startswith("[错误]") or msg.startswith("[ERROR]") or "Traceback" in msg:
  476. color = "#dc2626" # 红
  477. weight = "600"
  478. elif msg.startswith("[警告]"):
  479. color = "#d97706" # 橙
  480. weight = "600"
  481. elif msg.startswith("[提示]"):
  482. color = "#2563eb" # 蓝
  483. weight = "500"
  484. elif msg.startswith("== ") or msg.startswith("启动") or "求解" in msg[:6]:
  485. color = "#475569" # 深灰 (阶段标题)
  486. weight = "600"
  487. elif "RESULTS:" in msg or "结果文件:" in msg or "扫描结果:" in msg:
  488. color = "#7c3aed" # 紫 (文件路径)
  489. weight = "500"
  490. else:
  491. color = "#334155" # 默认
  492. weight = "400"
  493. import html
  494. escaped = html.escape(msg)
  495. self.txt_log.append('<span style="color:%s;font-weight:%s;">%s</span>' % (color, weight, escaped))
  496. sb = self.txt_log.verticalScrollBar()
  497. sb.setValue(sb.maximum())
  498. def _on_single_result(self, results):
  499. try:
  500. self.last_results = results
  501. forces = results.get("axial_forces", {}) or {}
  502. checks = results.get("checks", {}) or {}
  503. # 安全访问: forces[graph] 可能为 None
  504. oc_data = forces.get("Fr_Rotor_OC_Lumped") or {}
  505. ol_data = forces.get("Fr_Rotor_OL_Lumped") or {}
  506. oc = oc_data.get("stats", {}) or {}
  507. ol = ol_data.get("stats", {}) or {}
  508. self.card_oc.set_value(oc.get("mean"))
  509. self.card_ol.set_value(ol.get("mean"))
  510. self.card_ripple.set_value(oc.get("pk2pk"))
  511. self.card_oc.set_color("#16a34a")
  512. self.card_ol.set_color("#2563eb")
  513. self.card_ripple.set_color("#64748b")
  514. # 判据文本
  515. parts = []
  516. ar_ol = (checks.get("action_reaction_OL") or {}).get("imbalance_pct")
  517. ar_oc = (checks.get("action_reaction_OC") or {}).get("imbalance_pct")
  518. if ar_ol is not None:
  519. ok = ar_ol < 5
  520. parts.append(("%s 作用反作用 OL=%.1f%%" % ("✓" if ok else "✗", ar_ol),
  521. "#16a34a" if ok else "#dc2626"))
  522. if ar_oc is not None:
  523. ok = ar_oc < 5
  524. parts.append(("%s OC=%.1f%%" % ("✓" if ok else "✗", ar_oc),
  525. "#16a34a" if ok else "#dc2626"))
  526. tq = checks.get("torque_crosscheck") or {}
  527. if tq.get("sum_Ft_x_r_Nm_t0") and tq.get("torque_graph_mean_Nm"):
  528. diff = abs(tq["sum_Ft_x_r_Nm_t0"] - tq["torque_graph_mean_Nm"]) \
  529. / max(abs(tq["torque_graph_mean_Nm"]), 1e-9) * 100
  530. ok = diff < 10
  531. parts.append(("%s 转矩交叉=%.1f%% (%.3f vs %.3f Nm)"
  532. % ("✓" if ok else "✗", diff,
  533. tq["sum_Ft_x_r_Nm_t0"], tq["torque_graph_mean_Nm"]),
  534. "#16a34a" if ok else "#dc2626"))
  535. an = checks.get("analytic") or {}
  536. if an.get("F_est_N"):
  537. parts.append(("解析量级=%.0fN (同量级)" % an["F_est_N"], "#7c3aed"))
  538. html_parts = " &nbsp;&nbsp; ".join(
  539. '<span style="color:%s;font-weight:600;">%s</span>' % (c, t) for t, c in parts
  540. )
  541. self.lbl_checks.setText(html_parts or "无判据数据")
  542. self.lbl_checks.setStyleSheet("""
  543. QLabel { background: #f0fdf4; border: 1px solid #86efac; border-radius: 6px;
  544. padding: 10px 14px; font-size: 9.5pt; }
  545. """)
  546. # 表格
  547. rows = []
  548. rms = (results.get("params") or {}).get("rms_current", 0)
  549. for case, label in (("OC", "空载 (开路)"), ("OL", "负载 (RMS %.0fA)" % rms)):
  550. v = forces.get("Fr_Rotor_%s_Lumped" % case)
  551. if v and v.get("stats"):
  552. s = v["stats"]
  553. rows.append((label, "%.1f" % s["mean"], "%.2f" % s["pk2pk"], "指向定子"))
  554. self._fill_table(rows)
  555. self.btn_export_json.setEnabled(True)
  556. self.btn_export_csv.setEnabled(True)
  557. except Exception as e:
  558. self._append_log("[错误] 结果显示异常: %s" % e)
  559. traceback.print_exc()
  560. def _on_sweep_result(self, results):
  561. try:
  562. self.last_results = results
  563. cases = results.get("cases", []) or []
  564. kneg = results.get("kneg_N_per_mm", {}) or {}
  565. # 卡片: 用第一个和最后一个气隙的力
  566. if cases:
  567. first = cases[0] or {}
  568. last = cases[-1] or {}
  569. foc_first = (first.get("F_OC") or {}).get("mean")
  570. foc_last = (last.get("F_OC") or {}).get("mean")
  571. self.card_oc.set_value(foc_first)
  572. self.card_oc.lbl_label.setText(
  573. "起始气隙 %.2fmm 空载力" % (first.get("airgap_mm", 0) or 0))
  574. self.card_ol.set_value(foc_last)
  575. self.card_ol.lbl_label.setText(
  576. "结束气隙 %.2fmm 空载力" % (last.get("airgap_mm", 0) or 0))
  577. # 平均纹波
  578. ripples = [(c.get("F_OC") or {}).get("pk2pk", 0) for c in cases
  579. if (c.get("F_OC") or {}).get("pk2pk") is not None]
  580. if ripples:
  581. self.card_ripple.set_value(sum(ripples) / len(ripples))
  582. self.card_oc.set_color("#16a34a")
  583. self.card_ol.set_color("#2563eb")
  584. self.card_ripple.set_color("#64748b")
  585. # 磁负刚度卡片
  586. if kneg:
  587. central = kneg.get("central")
  588. if central is not None:
  589. self.card_kneg.set_value(central)
  590. self.card_kneg.set_color("#7c3aed")
  591. self.card_kneg.setVisible(True)
  592. kneg_txt = " | ".join("%s=%.0f" % (k, v) for k, v in kneg.items()) if kneg else "—"
  593. self.lbl_checks.setText(
  594. '<span style="color:#16a34a;font-weight:600;">✓ 扫描完成</span> &nbsp;&nbsp; '
  595. '<span style="color:#7c3aed;">磁负刚度 (N/mm): %s</span>' % kneg_txt)
  596. self.lbl_checks.setStyleSheet("""
  597. QLabel { background: #f0fdf4; border: 1px solid #86efac; border-radius: 6px;
  598. padding: 10px 14px; font-size: 9.5pt; }
  599. """)
  600. rows = []
  601. for c in cases:
  602. if not c:
  603. continue
  604. g = c.get("airgap_mm", 0)
  605. foc = (c.get("F_OC") or {}).get("mean", 0)
  606. fol = (c.get("F_OL") or {}).get("mean", 0)
  607. rip = (c.get("F_OC") or {}).get("pk2pk", 0)
  608. rows.append(("气隙 %.2fmm" % g, "OC=%.1f / OL=%.1f" % (foc, fol),
  609. "%.2f" % rip, "耗时%.0fs" % (c.get("solve_seconds", 0) or 0)))
  610. self._fill_table(rows)
  611. self.btn_export_json.setEnabled(True)
  612. self.btn_export_csv.setEnabled(False)
  613. except Exception as e:
  614. self._append_log("[错误] 扫描结果显示异常: %s" % e)
  615. traceback.print_exc()
  616. def _fill_table(self, rows):
  617. self.table_result.setVisible(True)
  618. self.table_result.setRowCount(len(rows))
  619. for i, (a, b, c, d) in enumerate(rows):
  620. for j, val in enumerate((a, b, c, d)):
  621. item = QTableWidgetItem(str(val))
  622. item.setTextAlignment(Qt.AlignCenter)
  623. self.table_result.setItem(i, j, item)
  624. def _on_error(self, msg):
  625. try:
  626. self.lbl_checks.setText('<span style="color:#dc2626;font-weight:600;">✗ 运行出错, 详见日志</span>')
  627. self.lbl_checks.setStyleSheet("""
  628. QLabel { background: #fef2f2; border: 1px solid #fca5a5; border-radius: 6px;
  629. padding: 10px 14px; font-size: 9.5pt; }
  630. """)
  631. self._append_log("[错误] %s" % msg)
  632. except Exception:
  633. traceback.print_exc()
  634. def _on_finished(self):
  635. try:
  636. elapsed = time.time() - getattr(self, "_run_start_time", time.time())
  637. self._set_running(False)
  638. # 检查是否有结果 (成功) 还是出错
  639. if self.last_results is not None:
  640. self._set_status("✓ 运行完成 — 结果已显示在上方", "#86efac")
  641. self.lbl_runtime.setText("总耗时 %.0fs" % elapsed)
  642. else:
  643. self._set_status("✗ 运行出错 — 详见日志", "#fca5a5")
  644. self.lbl_runtime.setText("")
  645. except Exception as e:
  646. traceback.print_exc()
  647. finally:
  648. # 延迟清理线程引用, 避免与 deleteLater 竞态
  649. from PyQt5.QtCore import QTimer
  650. QTimer.singleShot(100, self._cleanup_thread)
  651. def _cleanup_thread(self):
  652. self.worker = None
  653. self.thread = None
  654. def _export_json(self):
  655. if not self.last_results:
  656. return
  657. path, _ = QFileDialog.getSaveFileName(self, "导出 JSON", "axialforce_result.json", "JSON (*.json)")
  658. if path:
  659. with open(path, "w", encoding="utf-8") as f:
  660. json.dump(self.last_results, f, ensure_ascii=False, indent=2)
  661. self._append_log("[提示] JSON 已导出 -> %s" % path)
  662. def _export_csv(self):
  663. if not self.last_results or self.last_mode != "single":
  664. return
  665. path, _ = QFileDialog.getSaveFileName(self, "导出 CSV", "axial_force.csv", "CSV (*.csv)")
  666. if path:
  667. forces = self.last_results.get("axial_forces", {})
  668. graphs = ["Fr_Rotor_OL_Lumped", "Fr_Stator_OL_Lumped",
  669. "Fr_Rotor_OC_Lumped", "Fr_Stator_OC_Lumped"]
  670. nmax = max(len(v["series_N"]) if v else 0 for v in forces.values())
  671. with open(path, "w", encoding="utf-8") as f:
  672. f.write("tstep," + ",".join(graphs) + "\n")
  673. for i in range(nmax):
  674. row = [str(i)]
  675. for g in graphs:
  676. v = forces.get(g)
  677. row.append("%.4f" % v["series_N"][i] if v and i < len(v["series_N"]) else "")
  678. f.write(",".join(row) + "\n")
  679. self._append_log("[提示] CSV 已导出 -> %s" % path)
  680. def main():
  681. app = QApplication(sys.argv)
  682. app.setStyle("Fusion")
  683. app.setStyleSheet(STYLE)
  684. # 关键: 防止子窗口/对话框关闭时触发整个应用退出
  685. app.setQuitOnLastWindowClosed(False)
  686. # 全局默认字体
  687. font = QFont("Microsoft YaHei", 10)
  688. app.setFont(font)
  689. # 全局异常钩子: 捕获未处理异常, 防止 --windowed 模式下静默闪退
  690. def _excepthook(exc_type, exc_value, exc_tb):
  691. import traceback
  692. msg = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
  693. try:
  694. with open(os.path.join(os.path.expanduser("~"), "axial_force_gui_error.log"),
  695. "a", encoding="utf-8") as f:
  696. f.write("\n=== %s ===\n%s" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg))
  697. except Exception:
  698. pass
  699. # 尝试在 GUI 中显示错误
  700. try:
  701. QMessageBox.critical(None, "程序异常",
  702. "发生未捕获异常:\n%s\n\n详情已写入 ~/axial_force_gui_error.log"
  703. % str(exc_value))
  704. except Exception:
  705. pass
  706. sys.excepthook = _excepthook
  707. w = MainWindow()
  708. w.show()
  709. sys.exit(app.exec_())
  710. if __name__ == "__main__":
  711. main()