"""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'{escaped}' 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())