main.py 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006
  1. """Motor-CAD Parameter Scan Studio - PyQt5 desktop GUI (Chinese UI).
  2. Modern industrial-style GUI for Motor-CAD torque ripple parameter
  3. scans. Uses solver.py as the computation core (no GUI dependency).
  4. Five-layer crash protection, QThread worker, bilingual metric
  5. extraction, and per-point CSV/log persistence.
  6. All source is ASCII; Chinese UI strings use \\uXXXX escapes.
  7. """
  8. from __future__ import annotations
  9. import json
  10. import os
  11. import sys
  12. import time
  13. import traceback
  14. from datetime import datetime
  15. from pathlib import Path
  16. from PyQt5.QtCore import QObject, Qt, QThread, QTimer, pyqtSignal
  17. from PyQt5.QtGui import QFont, QTextCursor
  18. from PyQt5.QtWidgets import (
  19. QApplication,
  20. QComboBox,
  21. QDoubleSpinBox,
  22. QFileDialog,
  23. QFrame,
  24. QGridLayout,
  25. QGroupBox,
  26. QHBoxLayout,
  27. QLabel,
  28. QLineEdit,
  29. QMainWindow,
  30. QMessageBox,
  31. QProgressBar,
  32. QPushButton,
  33. QSpinBox,
  34. QStatusBar,
  35. QTableWidget,
  36. QTableWidgetItem,
  37. QTextEdit,
  38. QVBoxLayout,
  39. QWidget,
  40. )
  41. # Import solver core.
  42. from solver import (
  43. METRIC_KEYS,
  44. METRIC_LABELS,
  45. MotorCADSolver,
  46. git_preflight,
  47. values_inclusive,
  48. )
  49. APP_NAME = "Motor-CAD \u53c2\u6570\u626b\u63cf\u5de5\u4f5c\u5ba4"
  50. APP_VERSION = "1.0.0"
  51. PROFILE_FILE = "scan_parameters.json"
  52. DEFAULT_MODEL_NAME = "MARS-9S8P_SSSR_Halbach_PCB-V1.0.mot"
  53. # Key metrics shown as large result cards.
  54. CARD_METRICS = [
  55. ("ripple_pct", "%"),
  56. ("ripple_nm", "Nm"),
  57. ("tavg_nm", "Nm"),
  58. ("efficiency_pct", "%"),
  59. ("total_losses_w", "W"),
  60. ("back_emf_v", "V"),
  61. ]
  62. # Chinese labels for metrics (overrides solver.py English labels).
  63. METRIC_LABELS_ZH = {
  64. "ripple_pct": "\u8f6c\u77e9\u8109\u52a8 [%]",
  65. "ripple_nm": "\u8f6c\u77e9\u8109\u52a8 [Nm]",
  66. "tavg_nm": "\u5e73\u5747\u8f6c\u77e9 [Nm]",
  67. "efficiency_pct": "\u7cfb\u7edf\u6548\u7387 [%]",
  68. "back_emf_v": "\u53cd\u7535\u52a8\u52bf LL \u6709\u6548\u503c [V]",
  69. "back_emf_thd_pct": "\u53cd\u7535\u52a8\u52bf\u8c10\u6ce2 [%]",
  70. "total_losses_w": "\u603b\u635f\u8017 [W]",
  71. "copper_loss_w": "\u76f4\u6d41\u94dc\u8017 [W]",
  72. "magnet_loss_w": "\u78c1\u94a2\u635f\u8017 [W]",
  73. "iron_loss_w": "\u5b9a\u5b50\u94c1\u8017 [W]",
  74. "input_power_w": "\u8f93\u5165\u529f\u7387 [W]",
  75. "output_power_w": "\u8f93\u51fa\u529f\u7387 [W]",
  76. "em_power_w": "\u7535\u78c1\u529f\u7387 [W]",
  77. "shaft_speed_rpm": "\u8f74\u8f6c\u901f [rpm]",
  78. "no_load_speed_rpm": "\u7a7a\u8f7d\u8f6c\u901f [rpm]",
  79. "shaft_torque_nm": "\u8f74\u8f6c\u77e9 [Nm]",
  80. }
  81. # Global reference to keep long-lived objects alive (crash protection layer 5).
  82. _GLOBAL_SOLVER_REF = None
  83. _GLOBAL_WORKER_REF = None
  84. _GLOBAL_THREAD_REF = None
  85. # ---------------------------------------------------------------------------
  86. # Crash protection layer 1: global exception hook
  87. # ---------------------------------------------------------------------------
  88. def _global_excepthook(exc_type, exc_value, exc_tb):
  89. """Write uncaught exceptions to a log file and show a dialog."""
  90. msg = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
  91. log_path = os.path.join(os.path.expanduser("~"), "motorcad_scan_studio_error.log")
  92. try:
  93. with open(log_path, "a", encoding="utf-8") as fh:
  94. fh.write(f"\n=== {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n{msg}")
  95. except Exception:
  96. pass
  97. try:
  98. QMessageBox.critical(None, "\u5e94\u7528\u5f02\u5e38", str(exc_value))
  99. except Exception:
  100. pass
  101. # ---------------------------------------------------------------------------
  102. # QSS stylesheet - modern industrial style
  103. # ---------------------------------------------------------------------------
  104. STYLESHEET = """
  105. QMainWindow, QWidget {
  106. background-color: #eef1f5;
  107. color: #1e293b;
  108. font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
  109. font-size: 10pt;
  110. }
  111. QFrame#paramPanel {
  112. background-color: #ffffff;
  113. border: 1px solid #e2e8f0;
  114. border-radius: 8px;
  115. }
  116. QGroupBox {
  117. background-color: #ffffff;
  118. border: 1px solid #e2e8f0;
  119. border-radius: 8px;
  120. margin-top: 16px;
  121. padding-top: 14px;
  122. font-weight: 600;
  123. font-size: 11pt;
  124. }
  125. QGroupBox::title {
  126. subcontrol-origin: margin;
  127. left: 14px;
  128. padding: 0 8px;
  129. background-color: #ffffff;
  130. color: #1e293b;
  131. }
  132. QPushButton {
  133. padding: 8px 20px;
  134. border-radius: 6px;
  135. background-color: #2563eb;
  136. color: #ffffff;
  137. border: 1px solid #2563eb;
  138. font-weight: 600;
  139. min-height: 32px;
  140. }
  141. QPushButton:hover { background-color: #1d4ed8; }
  142. QPushButton:disabled {
  143. background-color: #94a3b8;
  144. border-color: #94a3b8;
  145. }
  146. QPushButton#stopBtn {
  147. background-color: #dc2626;
  148. border-color: #dc2626;
  149. }
  150. QPushButton#stopBtn:hover { background-color: #b91c1c; }
  151. QPushButton#stopBtn:disabled {
  152. background-color: #94a3b8;
  153. border-color: #94a3b8;
  154. }
  155. QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {
  156. padding: 6px 10px;
  157. border: 1px solid #cbd5e1;
  158. border-radius: 6px;
  159. background-color: #f8fafc;
  160. min-height: 20px;
  161. }
  162. QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {
  163. border-color: #2563eb;
  164. background-color: #ffffff;
  165. }
  166. QLabel { color: #1e293b; }
  167. QLabel#sectionLabel {
  168. font-size: 11pt;
  169. font-weight: 600;
  170. color: #1e293b;
  171. }
  172. QLabel#hintLabel {
  173. font-size: 9pt;
  174. color: #64748b;
  175. }
  176. QLabel#totalLabel {
  177. font-size: 10pt;
  178. font-weight: 600;
  179. color: #2563eb;
  180. }
  181. QFrame#resultCard {
  182. background-color: #ffffff;
  183. border: 1px solid #e2e8f0;
  184. border-radius: 10px;
  185. }
  186. QLabel#cardLabel {
  187. font-size: 9pt;
  188. color: #64748b;
  189. font-weight: 500;
  190. letter-spacing: 1px;
  191. }
  192. QLabel#cardValue {
  193. font-size: 22pt;
  194. font-weight: 700;
  195. color: #1e293b;
  196. }
  197. QLabel#cardUnit {
  198. font-size: 11pt;
  199. color: #64748b;
  200. }
  201. QTextEdit#logView {
  202. background-color: #f8fafc;
  203. color: #334155;
  204. border: 1px solid #e2e8f0;
  205. border-radius: 8px;
  206. padding: 10px;
  207. font-family: Consolas, "Cascadia Code", monospace;
  208. font-size: 9.5pt;
  209. }
  210. QTableWidget {
  211. background-color: #ffffff;
  212. border: 1px solid #e2e8f0;
  213. border-radius: 8px;
  214. gridline-color: #e2e8f0;
  215. font-size: 9.5pt;
  216. }
  217. QHeaderView::section {
  218. background-color: #f1f5f9;
  219. color: #475569;
  220. font-weight: 600;
  221. padding: 6px;
  222. border: none;
  223. border-bottom: 1px solid #e2e8f0;
  224. }
  225. QProgressBar {
  226. border: 1px solid #e2e8f0;
  227. border-radius: 6px;
  228. background-color: #f1f5f9;
  229. text-align: center;
  230. height: 20px;
  231. }
  232. QProgressBar::chunk {
  233. background-color: #2563eb;
  234. border-radius: 5px;
  235. }
  236. QStatusBar {
  237. background-color: #1e293b;
  238. color: #e2e8f0;
  239. font-size: 9.5pt;
  240. }
  241. QStatusBar QLabel { color: #e2e8f0; }
  242. QScrollBar:vertical {
  243. width: 10px;
  244. background-color: #f1f5f9;
  245. }
  246. QScrollBar::handle:vertical {
  247. background-color: #cbd5e1;
  248. border-radius: 5px;
  249. }
  250. QScrollBar::handle:vertical:hover { background-color: #94a3b8; }
  251. """
  252. # ---------------------------------------------------------------------------
  253. # Result card widget
  254. # ---------------------------------------------------------------------------
  255. class ResultCard(QFrame):
  256. """A white card showing a metric label, large value, and unit."""
  257. def __init__(self, label: str, unit: str, parent=None):
  258. super().__init__(parent)
  259. self.setObjectName("resultCard")
  260. self.setFixedHeight(90)
  261. layout = QVBoxLayout(self)
  262. layout.setContentsMargins(14, 10, 14, 10)
  263. layout.setSpacing(2)
  264. self.label_widget = QLabel(label.upper())
  265. self.label_widget.setObjectName("cardLabel")
  266. layout.addWidget(self.label_widget)
  267. value_row = QHBoxLayout()
  268. value_row.setSpacing(4)
  269. self.value_widget = QLabel("--")
  270. self.value_widget.setObjectName("cardValue")
  271. value_row.addWidget(self.value_widget)
  272. self.unit_widget = QLabel(unit)
  273. self.unit_widget.setObjectName("cardUnit")
  274. value_row.addWidget(self.unit_widget)
  275. value_row.addStretch()
  276. layout.addLayout(value_row)
  277. def set_value(self, value):
  278. if value is None or value == "":
  279. self.value_widget.setText("--")
  280. else:
  281. try:
  282. self.value_widget.setText(f"{float(value):.4g}")
  283. except (ValueError, TypeError):
  284. self.value_widget.setText(str(value))
  285. # ---------------------------------------------------------------------------
  286. # Scan worker (QObject for QThread)
  287. # ---------------------------------------------------------------------------
  288. class ScanWorker(QObject):
  289. """Runs a Motor-CAD parameter scan in a background thread."""
  290. log = pyqtSignal(str)
  291. row = pyqtSignal(dict)
  292. progress = pyqtSignal(int, int)
  293. finished = pyqtSignal(dict)
  294. error = pyqtSignal(str)
  295. def __init__(self, config: dict):
  296. super().__init__()
  297. self.config = config
  298. self._solver = None
  299. def run(self):
  300. try:
  301. self._solver = MotorCADSolver(
  302. model_path=Path(self.config["model"]),
  303. log_cb=lambda m: self.log.emit(m),
  304. progress_cb=lambda i, t: self.progress.emit(i, t),
  305. row_cb=lambda r: self.row.emit(r),
  306. )
  307. self._solver.connect()
  308. result = self._solver.run_scan(
  309. points=self.config["points"],
  310. output_dir=Path(self.config["output_dir"]),
  311. torque_points=self.config.get("torque_points", 0),
  312. airgap_mesh=self.config.get("airgap_mesh", 0),
  313. scan_name=self.config.get("scan_name", "scan"),
  314. extra_csv_fields=self.config.get("extra_csv_fields", []),
  315. )
  316. self.finished.emit(result)
  317. except Exception as exc:
  318. self.error.emit(f"{type(exc).__name__}: {exc}\n{traceback.format_exc()}")
  319. finally:
  320. if self._solver is not None:
  321. try:
  322. self._solver.disconnect()
  323. except Exception:
  324. pass
  325. def cancel(self):
  326. if self._solver is not None:
  327. self._solver.cancel()
  328. # ---------------------------------------------------------------------------
  329. # Main window
  330. # ---------------------------------------------------------------------------
  331. class MainWindow(QMainWindow):
  332. def __init__(self):
  333. super().__init__()
  334. self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
  335. self.resize(1400, 900)
  336. self.setMinimumSize(1100, 700)
  337. self.profiles = self._load_profiles()
  338. self.worker = None
  339. self.thread = None
  340. self.run_start_time = None
  341. self._build_ui()
  342. self._apply_profile(next(iter(self.profiles)) if self.profiles else None)
  343. # -- utility --
  344. def _app_root(self) -> Path:
  345. if getattr(sys, "frozen", False):
  346. return Path(sys.executable).resolve().parent
  347. return Path(__file__).resolve().parent
  348. def _load_profiles(self) -> dict:
  349. path = self._app_root() / PROFILE_FILE
  350. if path.exists():
  351. try:
  352. return json.loads(path.read_text(encoding="ascii"))
  353. except (json.JSONDecodeError, OSError):
  354. pass
  355. return {}
  356. def _default_model_path(self) -> str:
  357. for base in (self._app_root(), *self._app_root().parents):
  358. candidate = base / DEFAULT_MODEL_NAME
  359. if candidate.exists():
  360. return str(candidate)
  361. return str(self._app_root() / DEFAULT_MODEL_NAME)
  362. # -- UI construction --
  363. def _build_ui(self):
  364. central = QWidget()
  365. self.setCentralWidget(central)
  366. main_layout = QHBoxLayout(central)
  367. main_layout.setContentsMargins(8, 8, 8, 4)
  368. main_layout.setSpacing(8)
  369. # Left: parameter panel.
  370. self.param_panel = self._build_param_panel()
  371. self.param_panel.setObjectName("paramPanel")
  372. self.param_panel.setFixedWidth(380)
  373. main_layout.addWidget(self.param_panel)
  374. # Right: results + log.
  375. right = QWidget()
  376. right_layout = QVBoxLayout(right)
  377. right_layout.setContentsMargins(0, 0, 0, 0)
  378. right_layout.setSpacing(8)
  379. right_layout.addWidget(self._build_results_area(), stretch=3)
  380. right_layout.addWidget(self._build_log_area(), stretch=2)
  381. main_layout.addWidget(right, stretch=1)
  382. # Status bar.
  383. self.status_bar = QStatusBar()
  384. self.setStatusBar(self.status_bar)
  385. self.status_label = QLabel("\u5c31\u7eea")
  386. self.commit_label = QLabel("Git: --")
  387. self.time_label = QLabel("\u6301\u7eed\u65f6\u95f4: --")
  388. self.status_bar.addWidget(self.status_label, 1)
  389. self.status_bar.addPermanentWidget(self.commit_label)
  390. self.status_bar.addPermanentWidget(self.time_label)
  391. # Check git on startup.
  392. QTimer.singleShot(100, self._refresh_git_status)
  393. def _build_param_panel(self) -> QWidget:
  394. panel = QFrame()
  395. layout = QVBoxLayout(panel)
  396. layout.setContentsMargins(12, 12, 12, 12)
  397. layout.setSpacing(10)
  398. # Model group.
  399. model_group = QGroupBox("\u6a21\u578b")
  400. model_layout = QVBoxLayout(model_group)
  401. model_row = QHBoxLayout()
  402. self.model_edit = QLineEdit(self._default_model_path())
  403. model_row.addWidget(self.model_edit, 1)
  404. browse_btn = QPushButton("\u6d4f\u89c8")
  405. browse_btn.setFixedWidth(70)
  406. browse_btn.clicked.connect(self._browse_model)
  407. model_row.addWidget(browse_btn)
  408. model_layout.addLayout(model_row)
  409. layout.addWidget(model_group)
  410. # Profile group.
  411. profile_group = QGroupBox("\u626b\u63cf\u914d\u7f6e")
  412. profile_layout = QVBoxLayout(profile_group)
  413. self.profile_combo = QComboBox()
  414. self.profile_combo.addItems(list(self.profiles.keys()))
  415. self.profile_combo.currentTextChanged.connect(self._apply_profile)
  416. profile_layout.addWidget(self.profile_combo)
  417. layout.addWidget(profile_group)
  418. # Parameter 1 group.
  419. p1_group = QGroupBox("\u53c2\u65701\uff1a\u4e2d\u592e\u78c1\u5757\u5f27\u89d2 [ED]")
  420. p1_layout = QGridLayout(p1_group)
  421. p1_layout.addWidget(QLabel("\u8d77\u59cb"), 0, 0)
  422. self.p1_start = QDoubleSpinBox()
  423. self.p1_start.setRange(0, 180)
  424. self.p1_start.setDecimals(1)
  425. self.p1_start.setSingleStep(1)
  426. p1_layout.addWidget(self.p1_start, 0, 1)
  427. p1_layout.addWidget(QLabel("\u7ec8\u6b62"), 0, 2)
  428. self.p1_stop = QDoubleSpinBox()
  429. self.p1_stop.setRange(0, 180)
  430. self.p1_stop.setDecimals(1)
  431. self.p1_stop.setSingleStep(1)
  432. p1_layout.addWidget(self.p1_stop, 0, 3)
  433. p1_layout.addWidget(QLabel("\u6b65\u957f"), 1, 0)
  434. self.p1_step = QDoubleSpinBox()
  435. self.p1_step.setRange(0.1, 50)
  436. self.p1_step.setDecimals(1)
  437. self.p1_step.setSingleStep(0.5)
  438. p1_layout.addWidget(self.p1_step, 1, 1)
  439. for spin in (self.p1_start, self.p1_stop, self.p1_step):
  440. spin.valueChanged.connect(self._update_total)
  441. layout.addWidget(p1_group)
  442. # Parameter 2 group.
  443. p2_group = QGroupBox("\u53c2\u65702\uff1a\u69fd\u53e3\u5bbd\u5ea6 [mm]")
  444. p2_layout = QGridLayout(p2_group)
  445. p2_layout.addWidget(QLabel("\u8d77\u59cb"), 0, 0)
  446. self.p2_start = QDoubleSpinBox()
  447. self.p2_start.setRange(0.2, 20)
  448. self.p2_start.setDecimals(1)
  449. self.p2_start.setSingleStep(0.2)
  450. p2_layout.addWidget(self.p2_start, 0, 1)
  451. p2_layout.addWidget(QLabel("\u7ec8\u6b62"), 0, 2)
  452. self.p2_stop = QDoubleSpinBox()
  453. self.p2_stop.setRange(0.2, 20)
  454. self.p2_stop.setDecimals(1)
  455. self.p2_stop.setSingleStep(0.2)
  456. p2_layout.addWidget(self.p2_stop, 0, 3)
  457. p2_layout.addWidget(QLabel("\u6b65\u957f"), 1, 0)
  458. self.p2_step = QDoubleSpinBox()
  459. self.p2_step.setRange(0.1, 10)
  460. self.p2_step.setDecimals(1)
  461. self.p2_step.setSingleStep(0.2)
  462. p2_layout.addWidget(self.p2_step, 1, 1)
  463. for spin in (self.p2_start, self.p2_stop, self.p2_step):
  464. spin.valueChanged.connect(self._update_total)
  465. layout.addWidget(p2_group)
  466. # Linked copper width group.
  467. link_group = QGroupBox("\u8054\u52a8\u94dc\u7ebf\u5bbd\u5ea6")
  468. link_layout = QGridLayout(link_group)
  469. link_layout.addWidget(QLabel("\u6bcf\u4fa7\u5bfc\u4f53\u6570"), 0, 0)
  470. self.conductor_count = QSpinBox()
  471. self.conductor_count.setRange(1, 10)
  472. link_layout.addWidget(self.conductor_count, 0, 1)
  473. link_layout.addWidget(QLabel("\u95f4\u9699 [mm]"), 0, 2)
  474. self.clearance = QDoubleSpinBox()
  475. self.clearance.setRange(0, 5)
  476. self.clearance.setDecimals(2)
  477. self.clearance.setSingleStep(0.1)
  478. link_layout.addWidget(self.clearance, 0, 3)
  479. formula_label = QLabel(
  480. "\u94dc\u7ebf\u5bbd = (\u69fd\u53e3\u5bbd - \u95f4\u9699) / 2 / \u5bfc\u4f53\u6570"
  481. )
  482. formula_label.setObjectName("hintLabel")
  483. link_layout.addWidget(formula_label, 1, 0, 1, 4)
  484. layout.addWidget(link_group)
  485. # Solver settings group.
  486. solver_group = QGroupBox("\u6c42\u89e3\u8bbe\u7f6e")
  487. solver_layout = QGridLayout(solver_group)
  488. solver_layout.addWidget(QLabel("\u8f6c\u77e9\u91c7\u6837\u70b9"), 0, 0)
  489. self.torque_points = QSpinBox()
  490. self.torque_points.setRange(0, 500)
  491. self.torque_points.setValue(30)
  492. self.torque_points.setSpecialValueText("model default")
  493. solver_layout.addWidget(self.torque_points, 0, 1)
  494. solver_layout.addWidget(QLabel("\u6c14\u9699\u7f51\u683c"), 0, 2)
  495. self.airgap_mesh = QSpinBox()
  496. self.airgap_mesh.setRange(0, 5000)
  497. self.airgap_mesh.setValue(840)
  498. self.airgap_mesh.setSpecialValueText("model default")
  499. solver_layout.addWidget(self.airgap_mesh, 0, 3)
  500. layout.addWidget(solver_group)
  501. # Total and buttons.
  502. self.total_label = QLabel("\u603b\u7ec4\u5408\u6570: 0")
  503. self.total_label.setObjectName("totalLabel")
  504. layout.addWidget(self.total_label)
  505. self.progress_bar = QProgressBar()
  506. self.progress_bar.setValue(0)
  507. layout.addWidget(self.progress_bar)
  508. btn_row = QHBoxLayout()
  509. self.start_btn = QPushButton("\u5f00\u59cb\u626b\u63cf")
  510. self.start_btn.clicked.connect(self._start_scan)
  511. btn_row.addWidget(self.start_btn)
  512. self.stop_btn = QPushButton("\u5f53\u524d\u70b9\u540e\u505c\u6b62")
  513. self.stop_btn.setObjectName("stopBtn")
  514. self.stop_btn.setEnabled(False)
  515. self.stop_btn.clicked.connect(self._stop_scan)
  516. btn_row.addWidget(self.stop_btn)
  517. layout.addLayout(btn_row)
  518. layout.addStretch()
  519. return panel
  520. def _build_results_area(self) -> QWidget:
  521. container = QWidget()
  522. layout = QVBoxLayout(container)
  523. layout.setContentsMargins(0, 0, 0, 0)
  524. layout.setSpacing(8)
  525. # Result cards.
  526. cards_header = QLabel("\u6700\u65b0\u70b9\u7ed3\u679c")
  527. cards_header.setObjectName("sectionLabel")
  528. layout.addWidget(cards_header)
  529. cards_widget = QWidget()
  530. cards_layout = QGridLayout(cards_widget)
  531. cards_layout.setContentsMargins(0, 0, 0, 0)
  532. cards_layout.setSpacing(8)
  533. self.cards = {}
  534. for idx, (key, unit) in enumerate(CARD_METRICS):
  535. label = METRIC_LABELS_ZH.get(key, METRIC_LABELS.get(key, key))
  536. card = ResultCard(label, unit)
  537. row, col = divmod(idx, 3)
  538. cards_layout.addWidget(card, row, col)
  539. self.cards[key] = card
  540. cards_layout.setColumnStretch(0, 1)
  541. cards_layout.setColumnStretch(1, 1)
  542. cards_layout.setColumnStretch(2, 1)
  543. layout.addWidget(cards_widget)
  544. # Full metrics table.
  545. table_header = QLabel("\u5168\u90e8\u6307\u6807")
  546. table_header.setObjectName("sectionLabel")
  547. layout.addWidget(table_header)
  548. self.metrics_table = QTableWidget()
  549. self.metrics_table.setColumnCount(2)
  550. self.metrics_table.setHorizontalHeaderLabels(
  551. ["\u6307\u6807", "\u6570\u503c"]
  552. )
  553. self.metrics_table.verticalHeader().setVisible(False)
  554. self.metrics_table.setRowCount(len(METRIC_KEYS))
  555. for row_idx, key in enumerate(METRIC_KEYS):
  556. label = METRIC_LABELS_ZH.get(key, METRIC_LABELS.get(key, key))
  557. self.metrics_table.setItem(row_idx, 0, QTableWidgetItem(label))
  558. self.metrics_table.setItem(row_idx, 1, QTableWidgetItem("--"))
  559. self.metrics_table.horizontalHeader().setStretchLastSection(True)
  560. layout.addWidget(self.metrics_table, stretch=1)
  561. return container
  562. def _build_log_area(self) -> QWidget:
  563. container = QWidget()
  564. layout = QVBoxLayout(container)
  565. layout.setContentsMargins(0, 0, 0, 0)
  566. layout.setSpacing(4)
  567. header = QLabel("\u7a0b\u5e8f\u65e5\u5fd7")
  568. header.setObjectName("sectionLabel")
  569. layout.addWidget(header)
  570. self.log_view = QTextEdit()
  571. self.log_view.setObjectName("logView")
  572. self.log_view.setReadOnly(True)
  573. layout.addWidget(self.log_view)
  574. return container
  575. # -- log helpers (HTML colored) --
  576. def _append_log(self, text: str, color: str = "#334155"):
  577. try:
  578. escaped = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
  579. html = f'<span style="color:{color};">{escaped}</span>'
  580. self.log_view.append(html)
  581. self.log_view.moveCursor(QTextCursor.End)
  582. except Exception:
  583. pass
  584. def _classify_log(self, text: str) -> str:
  585. lower = text.lower()
  586. if "error" in lower or "failed" in lower or "traceback" in lower:
  587. return "#dc2626"
  588. if "warning" in lower or "cancel" in lower:
  589. return "#d97706"
  590. if "complete" in lower or "ok" in lower or "ended" in lower:
  591. return "#16a34a"
  592. if "opening" in lower or "starting" in lower or "run directory" in lower:
  593. return "#475569"
  594. if "result" in lower or "csv" in lower or ".mot" in lower or "directory" in lower:
  595. return "#7c3aed"
  596. return "#334155"
  597. # -- profile --
  598. def _apply_profile(self, profile_name: str | None):
  599. try:
  600. if not profile_name or profile_name not in self.profiles:
  601. return
  602. profile = self.profiles[profile_name]
  603. p1 = profile.get("parameter_1", {})
  604. p2 = profile.get("parameter_2", {})
  605. linked = profile.get("linked_value", {})
  606. self.p1_start.setValue(float(p1.get("default_start", 90)))
  607. self.p1_stop.setValue(float(p1.get("default_stop", 110)))
  608. self.p1_step.setValue(float(p1.get("default_step", 1)))
  609. self.p2_start.setValue(float(p2.get("default_start", 5.4)))
  610. self.p2_stop.setValue(float(p2.get("default_stop", 7.4)))
  611. self.p2_step.setValue(float(p2.get("default_step", 0.2)))
  612. self.conductor_count.setValue(int(linked.get("default_conductor_count", 1)))
  613. self.clearance.setValue(float(linked.get("default_clearance", 0.2)))
  614. self._update_total()
  615. except Exception as exc:
  616. self._append_log(f"Profile apply error: {exc}", "#dc2626")
  617. def _update_total(self):
  618. try:
  619. p1_vals = values_inclusive(
  620. self.p1_start.value(), self.p1_stop.value(), self.p1_step.value()
  621. )
  622. p2_vals = values_inclusive(
  623. self.p2_start.value(), self.p2_stop.value(), self.p2_step.value()
  624. )
  625. total = len(p1_vals) * len(p2_vals)
  626. self.total_label.setText(
  627. f"\u603b\u7ec4\u5408\u6570: {len(p1_vals)} x {len(p2_vals)} = {total}"
  628. )
  629. except ValueError:
  630. self.total_label.setText("\u603b\u7ec4\u5408\u6570: invalid range")
  631. # -- actions --
  632. def _browse_model(self):
  633. try:
  634. path, _ = QFileDialog.getOpenFileName(
  635. self,
  636. "\u9009\u62e9 Motor-CAD \u6a21\u578b",
  637. "",
  638. "Motor-CAD models (*.mot);;All files (*.*)",
  639. )
  640. if path:
  641. self.model_edit.setText(path)
  642. except Exception as exc:
  643. self._append_log(f"Browse error: {exc}", "#dc2626")
  644. def _refresh_git_status(self):
  645. try:
  646. ok, commit = git_preflight(self._app_root())
  647. if ok:
  648. self.commit_label.setText(f"Git: {commit}")
  649. else:
  650. self.commit_label.setText(f"Git: {commit}")
  651. except Exception:
  652. self.commit_label.setText("Git: unavailable")
  653. def _start_scan(self):
  654. try:
  655. # Git preflight.
  656. ok, commit = git_preflight(self._app_root())
  657. if not ok:
  658. QMessageBox.warning(self, "Git \u9884\u68c0\u5931\u8d25", commit)
  659. return
  660. # Validate model.
  661. model_path = Path(self.model_edit.text()).resolve()
  662. if not model_path.exists():
  663. QMessageBox.warning(
  664. self,
  665. "\u6a21\u578b\u672a\u627e\u5230",
  666. f"\u6a21\u578b\u4e0d\u5b58\u5728\uff1a\n{model_path}",
  667. )
  668. return
  669. # Build points.
  670. p1_vals = values_inclusive(
  671. self.p1_start.value(), self.p1_stop.value(), self.p1_step.value()
  672. )
  673. p2_vals = values_inclusive(
  674. self.p2_start.value(), self.p2_stop.value(), self.p2_step.value()
  675. )
  676. clearance = self.clearance.value()
  677. conductors = self.conductor_count.value()
  678. # Range checks.
  679. profile_name = self.profile_combo.currentText()
  680. profile = self.profiles.get(profile_name, {})
  681. p1_cfg = profile.get("parameter_1", {})
  682. p2_cfg = profile.get("parameter_2", {})
  683. if p1_cfg:
  684. if p1_vals[0] < p1_cfg.get("minimum", 0) or p1_vals[-1] > p1_cfg.get("maximum", 180):
  685. QMessageBox.warning(
  686. self, "\u8303\u56f4\u9519\u8bef",
  687. f"\u53c2\u65701\u5fc5\u987b\u5728\u8303\u56f4\u5185 "
  688. f"{p1_cfg.get('minimum', 0)}..{p1_cfg.get('maximum', 180)}",
  689. )
  690. return
  691. if p2_cfg:
  692. if p2_vals[0] < p2_cfg.get("minimum", 0.2) or p2_vals[-1] > p2_cfg.get("maximum", 20):
  693. QMessageBox.warning(
  694. self, "\u8303\u56f4\u9519\u8bef",
  695. f"\u53c2\u65702\u5fc5\u987b\u5728\u8303\u56f4\u5185 "
  696. f"{p2_cfg.get('minimum', 0.2)}..{p2_cfg.get('maximum', 20)}",
  697. )
  698. return
  699. if clearance >= p2_vals[0]:
  700. QMessageBox.warning(
  701. self, "\u8303\u56f4\u9519\u8bef",
  702. "\u95f4\u9699\u5fc5\u987b\u5c0f\u4e8e\u6bcf\u4e2a\u69fd\u53e3\u5bbd\u5ea6",
  703. )
  704. return
  705. total = len(p1_vals) * len(p2_vals)
  706. est_hours = total * 180.0 / 3600.0
  707. confirm = QMessageBox.question(
  708. self,
  709. "\u786e\u8ba4\u626b\u63cf",
  710. f"\u53c2\u65701\u6c34\u5e73\u6570: {len(p1_vals)}\n"
  711. f"\u53c2\u65702\u6c34\u5e73\u6570: {len(p2_vals)}\n"
  712. f"\u603b\u4eff\u771f\u6570: {total}\n"
  713. f"\u9884\u8ba1\u8017\u65f6: {est_hours:.1f} \u5c0f\u65f6\n\n"
  714. f"\u5f00\u59cb\u626b\u63cf\uff1f",
  715. QMessageBox.Yes | QMessageBox.No,
  716. )
  717. if confirm != QMessageBox.Yes:
  718. return
  719. # Build point list.
  720. points = []
  721. for slot_opening in p2_vals:
  722. copper_width = round((slot_opening - clearance) / 2.0 / conductors, 10)
  723. for magnet_arc in p1_vals:
  724. points.append({
  725. "writes": [
  726. ("Slot_Opening", slot_opening),
  727. ("Copper_Width", copper_width),
  728. ("MagnetCentralArc_HalbachRing", magnet_arc),
  729. ],
  730. "central_mag_arc_ed": magnet_arc,
  731. "slot_opening_mm": slot_opening,
  732. "copper_width_mm": copper_width,
  733. })
  734. config = {
  735. "model": str(model_path),
  736. "points": points,
  737. "output_dir": str(self._app_root() / "runs"),
  738. "torque_points": self.torque_points.value(),
  739. "airgap_mesh": self.airgap_mesh.value(),
  740. "scan_name": "two_parameter_scan",
  741. "extra_csv_fields": ["central_mag_arc_ed", "slot_opening_mm", "copper_width_mm"],
  742. "git_commit": commit,
  743. }
  744. # Reset UI.
  745. self.log_view.clear()
  746. self.progress_bar.setMaximum(total)
  747. self.progress_bar.setValue(0)
  748. self.start_btn.setEnabled(False)
  749. self.stop_btn.setEnabled(True)
  750. self.status_label.setText("\u8fd0\u884c\u4e2d...")
  751. self.run_start_time = time.time()
  752. self._append_log(
  753. f"Starting scan: {total} points, commit {commit}", "#2563eb"
  754. )
  755. # Start worker thread.
  756. self.thread = QThread()
  757. self.worker = ScanWorker(config)
  758. self.worker.moveToThread(self.thread)
  759. self.thread.started.connect(self.worker.run)
  760. self.worker.log.connect(self._on_worker_log)
  761. self.worker.row.connect(self._on_worker_row)
  762. self.worker.progress.connect(self._on_worker_progress)
  763. self.worker.finished.connect(self._on_worker_finished)
  764. self.worker.error.connect(self._on_worker_error)
  765. self.worker.finished.connect(self.thread.quit)
  766. self.worker.finished.connect(self.worker.deleteLater)
  767. self.thread.finished.connect(self.thread.deleteLater)
  768. self.thread.start()
  769. # Keep global references (crash protection layer 5).
  770. global _GLOBAL_WORKER_REF, _GLOBAL_THREAD_REF
  771. _GLOBAL_WORKER_REF = self.worker
  772. _GLOBAL_THREAD_REF = self.thread
  773. except Exception as exc:
  774. self._append_log(
  775. f"Start error: {exc}\n{traceback.format_exc()}", "#dc2626"
  776. )
  777. QMessageBox.critical(self, "\u542f\u52a8\u5931\u8d25", str(exc))
  778. def _stop_scan(self):
  779. try:
  780. if self.worker is not None:
  781. self.worker.cancel()
  782. self.status_label.setText(
  783. "\u505c\u6b62\u8bf7\u6c42\uff1a\u7b49\u5f85\u5f53\u524d\u70b9\u5b8c\u6210"
  784. )
  785. self._append_log(
  786. "Stop requested after current point", "#d97706"
  787. )
  788. except Exception as exc:
  789. self._append_log(f"Stop error: {exc}", "#dc2626")
  790. # -- worker signal handlers (each wrapped in try-except, layer 4) --
  791. def _on_worker_log(self, text: str):
  792. try:
  793. color = self._classify_log(text)
  794. self._append_log(text, color)
  795. except Exception:
  796. pass
  797. def _on_worker_row(self, row: dict):
  798. try:
  799. # Update cards.
  800. for key, _ in CARD_METRICS:
  801. self.cards[key].set_value(row.get(key, ""))
  802. # Update table.
  803. for row_idx, key in enumerate(METRIC_KEYS):
  804. val = row.get(key, "--")
  805. if val == "" or val is None:
  806. val = "--"
  807. else:
  808. try:
  809. val = f"{float(val):.6g}"
  810. except (ValueError, TypeError):
  811. val = str(val)
  812. self.metrics_table.item(row_idx, 1).setText(val)
  813. except Exception as exc:
  814. self._append_log(f"Row update error: {exc}", "#dc2626")
  815. def _on_worker_progress(self, current: int, total: int):
  816. try:
  817. self.progress_bar.setValue(current)
  818. elapsed = time.time() - self.run_start_time if self.run_start_time else 0
  819. self.time_label.setText(
  820. f"\u6301\u7eed\u65f6\u95f4: {elapsed:.0f}s | {current}/{total}"
  821. )
  822. self.status_label.setText(f"\u8fd0\u884c\u4e2d {current}/{total}")
  823. except Exception:
  824. pass
  825. def _on_worker_finished(self, result: dict):
  826. try:
  827. summary = result.get("summary", {})
  828. ok = summary.get("ok", 0)
  829. failed = summary.get("failed", 0)
  830. elapsed = time.time() - self.run_start_time if self.run_start_time else 0
  831. self.status_label.setText(
  832. f"\u5b8c\u6210: {ok} OK, {failed} \u5931\u8d25"
  833. )
  834. self.time_label.setText(f"\u6301\u7eed\u65f6\u95f4: {elapsed:.0f}s")
  835. self._append_log(
  836. f"Scan complete: {ok} OK, {failed} failed. "
  837. f"Results: {result.get('csv_path', '')}",
  838. "#16a34a",
  839. )
  840. QMessageBox.information(
  841. self,
  842. "\u626b\u63cf\u5b8c\u6210",
  843. f"\u7ed3\u679c\u4fdd\u5b58\u5728\uff1a\n{result.get('run_dir', '')}",
  844. )
  845. except Exception as exc:
  846. self._append_log(f"Finish error: {exc}", "#dc2626")
  847. finally:
  848. self._reset_controls()
  849. self._cleanup_thread()
  850. def _on_worker_error(self, message: str):
  851. try:
  852. self.status_label.setText("\u5931\u8d25")
  853. self._append_log(message, "#dc2626")
  854. QMessageBox.critical(self, "\u626b\u63cf\u5931\u8d25", message)
  855. except Exception:
  856. pass
  857. finally:
  858. self._reset_controls()
  859. self._cleanup_thread()
  860. def _reset_controls(self):
  861. try:
  862. self.start_btn.setEnabled(True)
  863. self.stop_btn.setEnabled(False)
  864. except Exception:
  865. pass
  866. def _cleanup_thread(self):
  867. # Delayed cleanup to avoid race with deleteLater (crash protection).
  868. try:
  869. QTimer.singleShot(200, self._do_cleanup)
  870. except Exception:
  871. pass
  872. def _do_cleanup(self):
  873. try:
  874. global _GLOBAL_WORKER_REF, _GLOBAL_THREAD_REF
  875. _GLOBAL_WORKER_REF = None
  876. _GLOBAL_THREAD_REF = None
  877. self.worker = None
  878. self.thread = None
  879. except Exception:
  880. pass
  881. # ---------------------------------------------------------------------------
  882. # Entry point
  883. # ---------------------------------------------------------------------------
  884. def main() -> int:
  885. # Crash protection layer 1.
  886. sys.excepthook = _global_excepthook
  887. app = QApplication(sys.argv)
  888. app.setStyle("Fusion")
  889. app.setStyleSheet(STYLESHEET)
  890. # Crash protection layer 2: don't quit when last window closes.
  891. app.setQuitOnLastWindowClosed(False)
  892. # Default font.
  893. font = QFont("Segoe UI", 10)
  894. app.setFont(font)
  895. window = MainWindow()
  896. window.show()
  897. return app.exec_()
  898. if __name__ == "__main__":
  899. raise SystemExit(main())