| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006 |
- """Motor-CAD Parameter Scan Studio - PyQt5 desktop GUI (Chinese UI).
- Modern industrial-style GUI for Motor-CAD torque ripple parameter
- scans. Uses solver.py as the computation core (no GUI dependency).
- Five-layer crash protection, QThread worker, bilingual metric
- extraction, and per-point CSV/log persistence.
- All source is ASCII; Chinese UI strings use \\uXXXX escapes.
- """
- from __future__ import annotations
- import json
- import os
- import sys
- import time
- import traceback
- from datetime import datetime
- from pathlib import Path
- from PyQt5.QtCore import QObject, Qt, QThread, QTimer, pyqtSignal
- from PyQt5.QtGui import QFont, QTextCursor
- from PyQt5.QtWidgets import (
- QApplication,
- QComboBox,
- QDoubleSpinBox,
- QFileDialog,
- QFrame,
- QGridLayout,
- QGroupBox,
- QHBoxLayout,
- QLabel,
- QLineEdit,
- QMainWindow,
- QMessageBox,
- QProgressBar,
- QPushButton,
- QSpinBox,
- QStatusBar,
- QTableWidget,
- QTableWidgetItem,
- QTextEdit,
- QVBoxLayout,
- QWidget,
- )
- # Import solver core.
- from solver import (
- METRIC_KEYS,
- METRIC_LABELS,
- MotorCADSolver,
- git_preflight,
- values_inclusive,
- )
- APP_NAME = "Motor-CAD \u53c2\u6570\u626b\u63cf\u5de5\u4f5c\u5ba4"
- APP_VERSION = "1.0.0"
- PROFILE_FILE = "scan_parameters.json"
- DEFAULT_MODEL_NAME = "MARS-9S8P_SSSR_Halbach_PCB-V1.0.mot"
- # Key metrics shown as large result cards.
- CARD_METRICS = [
- ("ripple_pct", "%"),
- ("ripple_nm", "Nm"),
- ("tavg_nm", "Nm"),
- ("efficiency_pct", "%"),
- ("total_losses_w", "W"),
- ("back_emf_v", "V"),
- ]
- # Chinese labels for metrics (overrides solver.py English labels).
- METRIC_LABELS_ZH = {
- "ripple_pct": "\u8f6c\u77e9\u8109\u52a8 [%]",
- "ripple_nm": "\u8f6c\u77e9\u8109\u52a8 [Nm]",
- "tavg_nm": "\u5e73\u5747\u8f6c\u77e9 [Nm]",
- "efficiency_pct": "\u7cfb\u7edf\u6548\u7387 [%]",
- "back_emf_v": "\u53cd\u7535\u52a8\u52bf LL \u6709\u6548\u503c [V]",
- "back_emf_thd_pct": "\u53cd\u7535\u52a8\u52bf\u8c10\u6ce2 [%]",
- "total_losses_w": "\u603b\u635f\u8017 [W]",
- "copper_loss_w": "\u76f4\u6d41\u94dc\u8017 [W]",
- "magnet_loss_w": "\u78c1\u94a2\u635f\u8017 [W]",
- "iron_loss_w": "\u5b9a\u5b50\u94c1\u8017 [W]",
- "input_power_w": "\u8f93\u5165\u529f\u7387 [W]",
- "output_power_w": "\u8f93\u51fa\u529f\u7387 [W]",
- "em_power_w": "\u7535\u78c1\u529f\u7387 [W]",
- "shaft_speed_rpm": "\u8f74\u8f6c\u901f [rpm]",
- "no_load_speed_rpm": "\u7a7a\u8f7d\u8f6c\u901f [rpm]",
- "shaft_torque_nm": "\u8f74\u8f6c\u77e9 [Nm]",
- }
- # Global reference to keep long-lived objects alive (crash protection layer 5).
- _GLOBAL_SOLVER_REF = None
- _GLOBAL_WORKER_REF = None
- _GLOBAL_THREAD_REF = None
- # ---------------------------------------------------------------------------
- # Crash protection layer 1: global exception hook
- # ---------------------------------------------------------------------------
- def _global_excepthook(exc_type, exc_value, exc_tb):
- """Write uncaught exceptions to a log file and show a dialog."""
- msg = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
- log_path = os.path.join(os.path.expanduser("~"), "motorcad_scan_studio_error.log")
- try:
- with open(log_path, "a", encoding="utf-8") as fh:
- fh.write(f"\n=== {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n{msg}")
- except Exception:
- pass
- try:
- QMessageBox.critical(None, "\u5e94\u7528\u5f02\u5e38", str(exc_value))
- except Exception:
- pass
- # ---------------------------------------------------------------------------
- # QSS stylesheet - modern industrial style
- # ---------------------------------------------------------------------------
- STYLESHEET = """
- QMainWindow, QWidget {
- background-color: #eef1f5;
- color: #1e293b;
- font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
- font-size: 10pt;
- }
- QFrame#paramPanel {
- background-color: #ffffff;
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- }
- QGroupBox {
- background-color: #ffffff;
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- margin-top: 16px;
- padding-top: 14px;
- font-weight: 600;
- font-size: 11pt;
- }
- QGroupBox::title {
- subcontrol-origin: margin;
- left: 14px;
- padding: 0 8px;
- background-color: #ffffff;
- color: #1e293b;
- }
- QPushButton {
- padding: 8px 20px;
- border-radius: 6px;
- background-color: #2563eb;
- color: #ffffff;
- border: 1px solid #2563eb;
- font-weight: 600;
- min-height: 32px;
- }
- QPushButton:hover { background-color: #1d4ed8; }
- QPushButton:disabled {
- background-color: #94a3b8;
- border-color: #94a3b8;
- }
- QPushButton#stopBtn {
- background-color: #dc2626;
- border-color: #dc2626;
- }
- QPushButton#stopBtn:hover { background-color: #b91c1c; }
- QPushButton#stopBtn:disabled {
- background-color: #94a3b8;
- border-color: #94a3b8;
- }
- QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {
- padding: 6px 10px;
- border: 1px solid #cbd5e1;
- border-radius: 6px;
- background-color: #f8fafc;
- min-height: 20px;
- }
- QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {
- border-color: #2563eb;
- background-color: #ffffff;
- }
- QLabel { color: #1e293b; }
- QLabel#sectionLabel {
- font-size: 11pt;
- font-weight: 600;
- color: #1e293b;
- }
- QLabel#hintLabel {
- font-size: 9pt;
- color: #64748b;
- }
- QLabel#totalLabel {
- font-size: 10pt;
- font-weight: 600;
- color: #2563eb;
- }
- QFrame#resultCard {
- background-color: #ffffff;
- border: 1px solid #e2e8f0;
- border-radius: 10px;
- }
- QLabel#cardLabel {
- font-size: 9pt;
- color: #64748b;
- font-weight: 500;
- letter-spacing: 1px;
- }
- QLabel#cardValue {
- font-size: 22pt;
- font-weight: 700;
- color: #1e293b;
- }
- QLabel#cardUnit {
- font-size: 11pt;
- color: #64748b;
- }
- QTextEdit#logView {
- background-color: #f8fafc;
- color: #334155;
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- padding: 10px;
- font-family: Consolas, "Cascadia Code", monospace;
- font-size: 9.5pt;
- }
- QTableWidget {
- background-color: #ffffff;
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- gridline-color: #e2e8f0;
- font-size: 9.5pt;
- }
- QHeaderView::section {
- background-color: #f1f5f9;
- color: #475569;
- font-weight: 600;
- padding: 6px;
- border: none;
- border-bottom: 1px solid #e2e8f0;
- }
- QProgressBar {
- border: 1px solid #e2e8f0;
- border-radius: 6px;
- background-color: #f1f5f9;
- text-align: center;
- height: 20px;
- }
- QProgressBar::chunk {
- background-color: #2563eb;
- border-radius: 5px;
- }
- QStatusBar {
- background-color: #1e293b;
- color: #e2e8f0;
- font-size: 9.5pt;
- }
- QStatusBar QLabel { color: #e2e8f0; }
- QScrollBar:vertical {
- width: 10px;
- background-color: #f1f5f9;
- }
- QScrollBar::handle:vertical {
- background-color: #cbd5e1;
- border-radius: 5px;
- }
- QScrollBar::handle:vertical:hover { background-color: #94a3b8; }
- """
- # ---------------------------------------------------------------------------
- # Result card widget
- # ---------------------------------------------------------------------------
- class ResultCard(QFrame):
- """A white card showing a metric label, large value, and unit."""
- def __init__(self, label: str, unit: str, parent=None):
- super().__init__(parent)
- self.setObjectName("resultCard")
- self.setFixedHeight(90)
- layout = QVBoxLayout(self)
- layout.setContentsMargins(14, 10, 14, 10)
- layout.setSpacing(2)
- self.label_widget = QLabel(label.upper())
- self.label_widget.setObjectName("cardLabel")
- layout.addWidget(self.label_widget)
- value_row = QHBoxLayout()
- value_row.setSpacing(4)
- self.value_widget = QLabel("--")
- self.value_widget.setObjectName("cardValue")
- value_row.addWidget(self.value_widget)
- self.unit_widget = QLabel(unit)
- self.unit_widget.setObjectName("cardUnit")
- value_row.addWidget(self.unit_widget)
- value_row.addStretch()
- layout.addLayout(value_row)
- def set_value(self, value):
- if value is None or value == "":
- self.value_widget.setText("--")
- else:
- try:
- self.value_widget.setText(f"{float(value):.4g}")
- except (ValueError, TypeError):
- self.value_widget.setText(str(value))
- # ---------------------------------------------------------------------------
- # Scan worker (QObject for QThread)
- # ---------------------------------------------------------------------------
- class ScanWorker(QObject):
- """Runs a Motor-CAD parameter scan in a background thread."""
- log = pyqtSignal(str)
- row = pyqtSignal(dict)
- progress = pyqtSignal(int, int)
- finished = pyqtSignal(dict)
- error = pyqtSignal(str)
- def __init__(self, config: dict):
- super().__init__()
- self.config = config
- self._solver = None
- def run(self):
- try:
- self._solver = MotorCADSolver(
- model_path=Path(self.config["model"]),
- log_cb=lambda m: self.log.emit(m),
- progress_cb=lambda i, t: self.progress.emit(i, t),
- row_cb=lambda r: self.row.emit(r),
- )
- self._solver.connect()
- result = self._solver.run_scan(
- points=self.config["points"],
- output_dir=Path(self.config["output_dir"]),
- torque_points=self.config.get("torque_points", 0),
- airgap_mesh=self.config.get("airgap_mesh", 0),
- scan_name=self.config.get("scan_name", "scan"),
- extra_csv_fields=self.config.get("extra_csv_fields", []),
- )
- self.finished.emit(result)
- except Exception as exc:
- self.error.emit(f"{type(exc).__name__}: {exc}\n{traceback.format_exc()}")
- finally:
- if self._solver is not None:
- try:
- self._solver.disconnect()
- except Exception:
- pass
- def cancel(self):
- if self._solver is not None:
- self._solver.cancel()
- # ---------------------------------------------------------------------------
- # Main window
- # ---------------------------------------------------------------------------
- class MainWindow(QMainWindow):
- def __init__(self):
- super().__init__()
- self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
- self.resize(1400, 900)
- self.setMinimumSize(1100, 700)
- self.profiles = self._load_profiles()
- self.worker = None
- self.thread = None
- self.run_start_time = None
- self._build_ui()
- self._apply_profile(next(iter(self.profiles)) if self.profiles else None)
- # -- utility --
- def _app_root(self) -> Path:
- if getattr(sys, "frozen", False):
- return Path(sys.executable).resolve().parent
- return Path(__file__).resolve().parent
- def _load_profiles(self) -> dict:
- path = self._app_root() / PROFILE_FILE
- if path.exists():
- try:
- return json.loads(path.read_text(encoding="ascii"))
- except (json.JSONDecodeError, OSError):
- pass
- return {}
- def _default_model_path(self) -> str:
- for base in (self._app_root(), *self._app_root().parents):
- candidate = base / DEFAULT_MODEL_NAME
- if candidate.exists():
- return str(candidate)
- return str(self._app_root() / DEFAULT_MODEL_NAME)
- # -- UI construction --
- def _build_ui(self):
- central = QWidget()
- self.setCentralWidget(central)
- main_layout = QHBoxLayout(central)
- main_layout.setContentsMargins(8, 8, 8, 4)
- main_layout.setSpacing(8)
- # Left: parameter panel.
- self.param_panel = self._build_param_panel()
- self.param_panel.setObjectName("paramPanel")
- self.param_panel.setFixedWidth(380)
- main_layout.addWidget(self.param_panel)
- # Right: results + log.
- right = QWidget()
- right_layout = QVBoxLayout(right)
- right_layout.setContentsMargins(0, 0, 0, 0)
- right_layout.setSpacing(8)
- right_layout.addWidget(self._build_results_area(), stretch=3)
- right_layout.addWidget(self._build_log_area(), stretch=2)
- main_layout.addWidget(right, stretch=1)
- # Status bar.
- self.status_bar = QStatusBar()
- self.setStatusBar(self.status_bar)
- self.status_label = QLabel("\u5c31\u7eea")
- self.commit_label = QLabel("Git: --")
- self.time_label = QLabel("\u6301\u7eed\u65f6\u95f4: --")
- self.status_bar.addWidget(self.status_label, 1)
- self.status_bar.addPermanentWidget(self.commit_label)
- self.status_bar.addPermanentWidget(self.time_label)
- # Check git on startup.
- QTimer.singleShot(100, self._refresh_git_status)
- def _build_param_panel(self) -> QWidget:
- panel = QFrame()
- layout = QVBoxLayout(panel)
- layout.setContentsMargins(12, 12, 12, 12)
- layout.setSpacing(10)
- # Model group.
- model_group = QGroupBox("\u6a21\u578b")
- model_layout = QVBoxLayout(model_group)
- model_row = QHBoxLayout()
- self.model_edit = QLineEdit(self._default_model_path())
- model_row.addWidget(self.model_edit, 1)
- browse_btn = QPushButton("\u6d4f\u89c8")
- browse_btn.setFixedWidth(70)
- browse_btn.clicked.connect(self._browse_model)
- model_row.addWidget(browse_btn)
- model_layout.addLayout(model_row)
- layout.addWidget(model_group)
- # Profile group.
- profile_group = QGroupBox("\u626b\u63cf\u914d\u7f6e")
- profile_layout = QVBoxLayout(profile_group)
- self.profile_combo = QComboBox()
- self.profile_combo.addItems(list(self.profiles.keys()))
- self.profile_combo.currentTextChanged.connect(self._apply_profile)
- profile_layout.addWidget(self.profile_combo)
- layout.addWidget(profile_group)
- # Parameter 1 group.
- p1_group = QGroupBox("\u53c2\u65701\uff1a\u4e2d\u592e\u78c1\u5757\u5f27\u89d2 [ED]")
- p1_layout = QGridLayout(p1_group)
- p1_layout.addWidget(QLabel("\u8d77\u59cb"), 0, 0)
- self.p1_start = QDoubleSpinBox()
- self.p1_start.setRange(0, 180)
- self.p1_start.setDecimals(1)
- self.p1_start.setSingleStep(1)
- p1_layout.addWidget(self.p1_start, 0, 1)
- p1_layout.addWidget(QLabel("\u7ec8\u6b62"), 0, 2)
- self.p1_stop = QDoubleSpinBox()
- self.p1_stop.setRange(0, 180)
- self.p1_stop.setDecimals(1)
- self.p1_stop.setSingleStep(1)
- p1_layout.addWidget(self.p1_stop, 0, 3)
- p1_layout.addWidget(QLabel("\u6b65\u957f"), 1, 0)
- self.p1_step = QDoubleSpinBox()
- self.p1_step.setRange(0.1, 50)
- self.p1_step.setDecimals(1)
- self.p1_step.setSingleStep(0.5)
- p1_layout.addWidget(self.p1_step, 1, 1)
- for spin in (self.p1_start, self.p1_stop, self.p1_step):
- spin.valueChanged.connect(self._update_total)
- layout.addWidget(p1_group)
- # Parameter 2 group.
- p2_group = QGroupBox("\u53c2\u65702\uff1a\u69fd\u53e3\u5bbd\u5ea6 [mm]")
- p2_layout = QGridLayout(p2_group)
- p2_layout.addWidget(QLabel("\u8d77\u59cb"), 0, 0)
- self.p2_start = QDoubleSpinBox()
- self.p2_start.setRange(0.2, 20)
- self.p2_start.setDecimals(1)
- self.p2_start.setSingleStep(0.2)
- p2_layout.addWidget(self.p2_start, 0, 1)
- p2_layout.addWidget(QLabel("\u7ec8\u6b62"), 0, 2)
- self.p2_stop = QDoubleSpinBox()
- self.p2_stop.setRange(0.2, 20)
- self.p2_stop.setDecimals(1)
- self.p2_stop.setSingleStep(0.2)
- p2_layout.addWidget(self.p2_stop, 0, 3)
- p2_layout.addWidget(QLabel("\u6b65\u957f"), 1, 0)
- self.p2_step = QDoubleSpinBox()
- self.p2_step.setRange(0.1, 10)
- self.p2_step.setDecimals(1)
- self.p2_step.setSingleStep(0.2)
- p2_layout.addWidget(self.p2_step, 1, 1)
- for spin in (self.p2_start, self.p2_stop, self.p2_step):
- spin.valueChanged.connect(self._update_total)
- layout.addWidget(p2_group)
- # Linked copper width group.
- link_group = QGroupBox("\u8054\u52a8\u94dc\u7ebf\u5bbd\u5ea6")
- link_layout = QGridLayout(link_group)
- link_layout.addWidget(QLabel("\u6bcf\u4fa7\u5bfc\u4f53\u6570"), 0, 0)
- self.conductor_count = QSpinBox()
- self.conductor_count.setRange(1, 10)
- link_layout.addWidget(self.conductor_count, 0, 1)
- link_layout.addWidget(QLabel("\u95f4\u9699 [mm]"), 0, 2)
- self.clearance = QDoubleSpinBox()
- self.clearance.setRange(0, 5)
- self.clearance.setDecimals(2)
- self.clearance.setSingleStep(0.1)
- link_layout.addWidget(self.clearance, 0, 3)
- formula_label = QLabel(
- "\u94dc\u7ebf\u5bbd = (\u69fd\u53e3\u5bbd - \u95f4\u9699) / 2 / \u5bfc\u4f53\u6570"
- )
- formula_label.setObjectName("hintLabel")
- link_layout.addWidget(formula_label, 1, 0, 1, 4)
- layout.addWidget(link_group)
- # Solver settings group.
- solver_group = QGroupBox("\u6c42\u89e3\u8bbe\u7f6e")
- solver_layout = QGridLayout(solver_group)
- solver_layout.addWidget(QLabel("\u8f6c\u77e9\u91c7\u6837\u70b9"), 0, 0)
- self.torque_points = QSpinBox()
- self.torque_points.setRange(0, 500)
- self.torque_points.setValue(30)
- self.torque_points.setSpecialValueText("model default")
- solver_layout.addWidget(self.torque_points, 0, 1)
- solver_layout.addWidget(QLabel("\u6c14\u9699\u7f51\u683c"), 0, 2)
- self.airgap_mesh = QSpinBox()
- self.airgap_mesh.setRange(0, 5000)
- self.airgap_mesh.setValue(840)
- self.airgap_mesh.setSpecialValueText("model default")
- solver_layout.addWidget(self.airgap_mesh, 0, 3)
- layout.addWidget(solver_group)
- # Total and buttons.
- self.total_label = QLabel("\u603b\u7ec4\u5408\u6570: 0")
- self.total_label.setObjectName("totalLabel")
- layout.addWidget(self.total_label)
- self.progress_bar = QProgressBar()
- self.progress_bar.setValue(0)
- layout.addWidget(self.progress_bar)
- btn_row = QHBoxLayout()
- self.start_btn = QPushButton("\u5f00\u59cb\u626b\u63cf")
- self.start_btn.clicked.connect(self._start_scan)
- btn_row.addWidget(self.start_btn)
- self.stop_btn = QPushButton("\u5f53\u524d\u70b9\u540e\u505c\u6b62")
- self.stop_btn.setObjectName("stopBtn")
- self.stop_btn.setEnabled(False)
- self.stop_btn.clicked.connect(self._stop_scan)
- btn_row.addWidget(self.stop_btn)
- layout.addLayout(btn_row)
- layout.addStretch()
- return panel
- def _build_results_area(self) -> QWidget:
- container = QWidget()
- layout = QVBoxLayout(container)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(8)
- # Result cards.
- cards_header = QLabel("\u6700\u65b0\u70b9\u7ed3\u679c")
- cards_header.setObjectName("sectionLabel")
- layout.addWidget(cards_header)
- cards_widget = QWidget()
- cards_layout = QGridLayout(cards_widget)
- cards_layout.setContentsMargins(0, 0, 0, 0)
- cards_layout.setSpacing(8)
- self.cards = {}
- for idx, (key, unit) in enumerate(CARD_METRICS):
- label = METRIC_LABELS_ZH.get(key, METRIC_LABELS.get(key, key))
- card = ResultCard(label, unit)
- row, col = divmod(idx, 3)
- cards_layout.addWidget(card, row, col)
- self.cards[key] = card
- cards_layout.setColumnStretch(0, 1)
- cards_layout.setColumnStretch(1, 1)
- cards_layout.setColumnStretch(2, 1)
- layout.addWidget(cards_widget)
- # Full metrics table.
- table_header = QLabel("\u5168\u90e8\u6307\u6807")
- table_header.setObjectName("sectionLabel")
- layout.addWidget(table_header)
- self.metrics_table = QTableWidget()
- self.metrics_table.setColumnCount(2)
- self.metrics_table.setHorizontalHeaderLabels(
- ["\u6307\u6807", "\u6570\u503c"]
- )
- self.metrics_table.verticalHeader().setVisible(False)
- self.metrics_table.setRowCount(len(METRIC_KEYS))
- for row_idx, key in enumerate(METRIC_KEYS):
- label = METRIC_LABELS_ZH.get(key, METRIC_LABELS.get(key, key))
- self.metrics_table.setItem(row_idx, 0, QTableWidgetItem(label))
- self.metrics_table.setItem(row_idx, 1, QTableWidgetItem("--"))
- self.metrics_table.horizontalHeader().setStretchLastSection(True)
- layout.addWidget(self.metrics_table, stretch=1)
- return container
- def _build_log_area(self) -> QWidget:
- container = QWidget()
- layout = QVBoxLayout(container)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(4)
- header = QLabel("\u7a0b\u5e8f\u65e5\u5fd7")
- header.setObjectName("sectionLabel")
- layout.addWidget(header)
- self.log_view = QTextEdit()
- self.log_view.setObjectName("logView")
- self.log_view.setReadOnly(True)
- layout.addWidget(self.log_view)
- return container
- # -- log helpers (HTML colored) --
- def _append_log(self, text: str, color: str = "#334155"):
- try:
- escaped = text.replace("&", "&").replace("<", "<").replace(">", ">")
- html = f'<span style="color:{color};">{escaped}</span>'
- self.log_view.append(html)
- self.log_view.moveCursor(QTextCursor.End)
- except Exception:
- pass
- def _classify_log(self, text: str) -> str:
- lower = text.lower()
- if "error" in lower or "failed" in lower or "traceback" in lower:
- return "#dc2626"
- if "warning" in lower or "cancel" in lower:
- return "#d97706"
- if "complete" in lower or "ok" in lower or "ended" in lower:
- return "#16a34a"
- if "opening" in lower or "starting" in lower or "run directory" in lower:
- return "#475569"
- if "result" in lower or "csv" in lower or ".mot" in lower or "directory" in lower:
- return "#7c3aed"
- return "#334155"
- # -- profile --
- def _apply_profile(self, profile_name: str | None):
- try:
- if not profile_name or profile_name not in self.profiles:
- return
- profile = self.profiles[profile_name]
- p1 = profile.get("parameter_1", {})
- p2 = profile.get("parameter_2", {})
- linked = profile.get("linked_value", {})
- self.p1_start.setValue(float(p1.get("default_start", 90)))
- self.p1_stop.setValue(float(p1.get("default_stop", 110)))
- self.p1_step.setValue(float(p1.get("default_step", 1)))
- self.p2_start.setValue(float(p2.get("default_start", 5.4)))
- self.p2_stop.setValue(float(p2.get("default_stop", 7.4)))
- self.p2_step.setValue(float(p2.get("default_step", 0.2)))
- self.conductor_count.setValue(int(linked.get("default_conductor_count", 1)))
- self.clearance.setValue(float(linked.get("default_clearance", 0.2)))
- self._update_total()
- except Exception as exc:
- self._append_log(f"Profile apply error: {exc}", "#dc2626")
- def _update_total(self):
- try:
- p1_vals = values_inclusive(
- self.p1_start.value(), self.p1_stop.value(), self.p1_step.value()
- )
- p2_vals = values_inclusive(
- self.p2_start.value(), self.p2_stop.value(), self.p2_step.value()
- )
- total = len(p1_vals) * len(p2_vals)
- self.total_label.setText(
- f"\u603b\u7ec4\u5408\u6570: {len(p1_vals)} x {len(p2_vals)} = {total}"
- )
- except ValueError:
- self.total_label.setText("\u603b\u7ec4\u5408\u6570: invalid range")
- # -- actions --
- def _browse_model(self):
- try:
- path, _ = QFileDialog.getOpenFileName(
- self,
- "\u9009\u62e9 Motor-CAD \u6a21\u578b",
- "",
- "Motor-CAD models (*.mot);;All files (*.*)",
- )
- if path:
- self.model_edit.setText(path)
- except Exception as exc:
- self._append_log(f"Browse error: {exc}", "#dc2626")
- def _refresh_git_status(self):
- try:
- ok, commit = git_preflight(self._app_root())
- if ok:
- self.commit_label.setText(f"Git: {commit}")
- else:
- self.commit_label.setText(f"Git: {commit}")
- except Exception:
- self.commit_label.setText("Git: unavailable")
- def _start_scan(self):
- try:
- # Git preflight.
- ok, commit = git_preflight(self._app_root())
- if not ok:
- QMessageBox.warning(self, "Git \u9884\u68c0\u5931\u8d25", commit)
- return
- # Validate model.
- model_path = Path(self.model_edit.text()).resolve()
- if not model_path.exists():
- QMessageBox.warning(
- self,
- "\u6a21\u578b\u672a\u627e\u5230",
- f"\u6a21\u578b\u4e0d\u5b58\u5728\uff1a\n{model_path}",
- )
- return
- # Build points.
- p1_vals = values_inclusive(
- self.p1_start.value(), self.p1_stop.value(), self.p1_step.value()
- )
- p2_vals = values_inclusive(
- self.p2_start.value(), self.p2_stop.value(), self.p2_step.value()
- )
- clearance = self.clearance.value()
- conductors = self.conductor_count.value()
- # Range checks.
- profile_name = self.profile_combo.currentText()
- profile = self.profiles.get(profile_name, {})
- p1_cfg = profile.get("parameter_1", {})
- p2_cfg = profile.get("parameter_2", {})
- if p1_cfg:
- if p1_vals[0] < p1_cfg.get("minimum", 0) or p1_vals[-1] > p1_cfg.get("maximum", 180):
- QMessageBox.warning(
- self, "\u8303\u56f4\u9519\u8bef",
- f"\u53c2\u65701\u5fc5\u987b\u5728\u8303\u56f4\u5185 "
- f"{p1_cfg.get('minimum', 0)}..{p1_cfg.get('maximum', 180)}",
- )
- return
- if p2_cfg:
- if p2_vals[0] < p2_cfg.get("minimum", 0.2) or p2_vals[-1] > p2_cfg.get("maximum", 20):
- QMessageBox.warning(
- self, "\u8303\u56f4\u9519\u8bef",
- f"\u53c2\u65702\u5fc5\u987b\u5728\u8303\u56f4\u5185 "
- f"{p2_cfg.get('minimum', 0.2)}..{p2_cfg.get('maximum', 20)}",
- )
- return
- if clearance >= p2_vals[0]:
- QMessageBox.warning(
- self, "\u8303\u56f4\u9519\u8bef",
- "\u95f4\u9699\u5fc5\u987b\u5c0f\u4e8e\u6bcf\u4e2a\u69fd\u53e3\u5bbd\u5ea6",
- )
- return
- total = len(p1_vals) * len(p2_vals)
- est_hours = total * 180.0 / 3600.0
- confirm = QMessageBox.question(
- self,
- "\u786e\u8ba4\u626b\u63cf",
- f"\u53c2\u65701\u6c34\u5e73\u6570: {len(p1_vals)}\n"
- f"\u53c2\u65702\u6c34\u5e73\u6570: {len(p2_vals)}\n"
- f"\u603b\u4eff\u771f\u6570: {total}\n"
- f"\u9884\u8ba1\u8017\u65f6: {est_hours:.1f} \u5c0f\u65f6\n\n"
- f"\u5f00\u59cb\u626b\u63cf\uff1f",
- QMessageBox.Yes | QMessageBox.No,
- )
- if confirm != QMessageBox.Yes:
- return
- # Build point list.
- points = []
- for slot_opening in p2_vals:
- copper_width = round((slot_opening - clearance) / 2.0 / conductors, 10)
- for magnet_arc in p1_vals:
- points.append({
- "writes": [
- ("Slot_Opening", slot_opening),
- ("Copper_Width", copper_width),
- ("MagnetCentralArc_HalbachRing", magnet_arc),
- ],
- "central_mag_arc_ed": magnet_arc,
- "slot_opening_mm": slot_opening,
- "copper_width_mm": copper_width,
- })
- config = {
- "model": str(model_path),
- "points": points,
- "output_dir": str(self._app_root() / "runs"),
- "torque_points": self.torque_points.value(),
- "airgap_mesh": self.airgap_mesh.value(),
- "scan_name": "two_parameter_scan",
- "extra_csv_fields": ["central_mag_arc_ed", "slot_opening_mm", "copper_width_mm"],
- "git_commit": commit,
- }
- # Reset UI.
- self.log_view.clear()
- self.progress_bar.setMaximum(total)
- self.progress_bar.setValue(0)
- self.start_btn.setEnabled(False)
- self.stop_btn.setEnabled(True)
- self.status_label.setText("\u8fd0\u884c\u4e2d...")
- self.run_start_time = time.time()
- self._append_log(
- f"Starting scan: {total} points, commit {commit}", "#2563eb"
- )
- # Start worker thread.
- self.thread = QThread()
- self.worker = ScanWorker(config)
- self.worker.moveToThread(self.thread)
- self.thread.started.connect(self.worker.run)
- self.worker.log.connect(self._on_worker_log)
- self.worker.row.connect(self._on_worker_row)
- self.worker.progress.connect(self._on_worker_progress)
- self.worker.finished.connect(self._on_worker_finished)
- self.worker.error.connect(self._on_worker_error)
- self.worker.finished.connect(self.thread.quit)
- self.worker.finished.connect(self.worker.deleteLater)
- self.thread.finished.connect(self.thread.deleteLater)
- self.thread.start()
- # Keep global references (crash protection layer 5).
- global _GLOBAL_WORKER_REF, _GLOBAL_THREAD_REF
- _GLOBAL_WORKER_REF = self.worker
- _GLOBAL_THREAD_REF = self.thread
- except Exception as exc:
- self._append_log(
- f"Start error: {exc}\n{traceback.format_exc()}", "#dc2626"
- )
- QMessageBox.critical(self, "\u542f\u52a8\u5931\u8d25", str(exc))
- def _stop_scan(self):
- try:
- if self.worker is not None:
- self.worker.cancel()
- self.status_label.setText(
- "\u505c\u6b62\u8bf7\u6c42\uff1a\u7b49\u5f85\u5f53\u524d\u70b9\u5b8c\u6210"
- )
- self._append_log(
- "Stop requested after current point", "#d97706"
- )
- except Exception as exc:
- self._append_log(f"Stop error: {exc}", "#dc2626")
- # -- worker signal handlers (each wrapped in try-except, layer 4) --
- def _on_worker_log(self, text: str):
- try:
- color = self._classify_log(text)
- self._append_log(text, color)
- except Exception:
- pass
- def _on_worker_row(self, row: dict):
- try:
- # Update cards.
- for key, _ in CARD_METRICS:
- self.cards[key].set_value(row.get(key, ""))
- # Update table.
- for row_idx, key in enumerate(METRIC_KEYS):
- val = row.get(key, "--")
- if val == "" or val is None:
- val = "--"
- else:
- try:
- val = f"{float(val):.6g}"
- except (ValueError, TypeError):
- val = str(val)
- self.metrics_table.item(row_idx, 1).setText(val)
- except Exception as exc:
- self._append_log(f"Row update error: {exc}", "#dc2626")
- def _on_worker_progress(self, current: int, total: int):
- try:
- self.progress_bar.setValue(current)
- elapsed = time.time() - self.run_start_time if self.run_start_time else 0
- self.time_label.setText(
- f"\u6301\u7eed\u65f6\u95f4: {elapsed:.0f}s | {current}/{total}"
- )
- self.status_label.setText(f"\u8fd0\u884c\u4e2d {current}/{total}")
- except Exception:
- pass
- def _on_worker_finished(self, result: dict):
- try:
- summary = result.get("summary", {})
- ok = summary.get("ok", 0)
- failed = summary.get("failed", 0)
- elapsed = time.time() - self.run_start_time if self.run_start_time else 0
- self.status_label.setText(
- f"\u5b8c\u6210: {ok} OK, {failed} \u5931\u8d25"
- )
- self.time_label.setText(f"\u6301\u7eed\u65f6\u95f4: {elapsed:.0f}s")
- self._append_log(
- f"Scan complete: {ok} OK, {failed} failed. "
- f"Results: {result.get('csv_path', '')}",
- "#16a34a",
- )
- QMessageBox.information(
- self,
- "\u626b\u63cf\u5b8c\u6210",
- f"\u7ed3\u679c\u4fdd\u5b58\u5728\uff1a\n{result.get('run_dir', '')}",
- )
- except Exception as exc:
- self._append_log(f"Finish error: {exc}", "#dc2626")
- finally:
- self._reset_controls()
- self._cleanup_thread()
- def _on_worker_error(self, message: str):
- try:
- self.status_label.setText("\u5931\u8d25")
- self._append_log(message, "#dc2626")
- QMessageBox.critical(self, "\u626b\u63cf\u5931\u8d25", message)
- except Exception:
- pass
- finally:
- self._reset_controls()
- self._cleanup_thread()
- def _reset_controls(self):
- try:
- self.start_btn.setEnabled(True)
- self.stop_btn.setEnabled(False)
- except Exception:
- pass
- def _cleanup_thread(self):
- # Delayed cleanup to avoid race with deleteLater (crash protection).
- try:
- QTimer.singleShot(200, self._do_cleanup)
- except Exception:
- pass
- def _do_cleanup(self):
- try:
- global _GLOBAL_WORKER_REF, _GLOBAL_THREAD_REF
- _GLOBAL_WORKER_REF = None
- _GLOBAL_THREAD_REF = None
- self.worker = None
- self.thread = None
- except Exception:
- pass
- # ---------------------------------------------------------------------------
- # Entry point
- # ---------------------------------------------------------------------------
- def main() -> int:
- # Crash protection layer 1.
- sys.excepthook = _global_excepthook
- app = QApplication(sys.argv)
- app.setStyle("Fusion")
- app.setStyleSheet(STYLESHEET)
- # Crash protection layer 2: don't quit when last window closes.
- app.setQuitOnLastWindowClosed(False)
- # Default font.
- font = QFont("Segoe UI", 10)
- app.setFont(font)
- window = MainWindow()
- window.show()
- return app.exec_()
- if __name__ == "__main__":
- raise SystemExit(main())
|