| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834 |
- """Robust Motor-CAD simulation core (P4-M3 + reference doc enhancement).
- Integrates all robustness practices from reference projects and
- official Motor-CAD automation reference documentation:
- Connection & Lifecycle:
- - open_new_instance=True + set_visible(True) (never connect to existing)
- - BlackBox headless mode support for server batch execution
- - Internal/external scripting context detection (is_running_in_internal_scripting)
- - Environment variable fallback (MOTORCAD_ACTIVEX, ANSYSLMD_LICENSE_FILE)
- Error Handling:
- - MotorCADError first-catch (PyMotorCAD throws on failure, no silent success)
- - Per-point timeout + retry (max 3 attempts) + auto-reconnect
- - Instance crash detection and auto-restart
- Batch Safety:
- - MessageDisplayState=2 popup suppression with try/finally restore
- - Parameter write-back verification (set then get, mismatch = FAILED)
- - Per-point baseline reload (load_from_file before and after each point)
- - Sampling point / mesh compatibility check (avoid 120pt+840mesh popup)
- - Slot opening / PCB copper width linkage formula
- Data Integrity:
- - Result export parsing: semicolon CSV, bilingual field aliases, E-Magnetics priority
- - Per-point dual write (CSV + JSON) with flush + fsync
- - Graph data reading with "out-of-bounds = end" idiom (while + try/except MotorCADError)
- Preflight Self-Check (5 layers):
- - Connection layer: multi-version, Automation registration, port/firewall, Hide command Window
- - Permission layer: admin rights, default install path, post-install reboot
- - License layer: License Manager service, port, validity, concurrency
- - Model layer: region closure (is_closed), duplicate regions, adaptive geometry reset
- - Script layer: MotorCADError handling, variable name version mapping, popup state
- Variable Name Version Mapping:
- - Configurable mapping table (not hardcoded) for version-specific name changes
- - e.g. MagWindingType -> MagneticWindingType across versions
- All source is ASCII only; Chinese field names use \\uXXXX escapes.
- """
- from __future__ import annotations
- import csv
- import json
- import math
- import os
- import platform
- import socket
- import sys
- import time
- import traceback
- from datetime import datetime
- from pathlib import Path
- from typing import Any, Dict, List, Optional, Tuple
- # MotorCADError may not be available if pymotorcad is not installed
- try:
- from ansys.motorcad.core import MotorCADError
- HAS_MOTORCAD_ERROR = True
- except ImportError:
- MotorCADError = Exception # type: ignore
- HAS_MOTORCAD_ERROR = False
- # ---------------------------------------------------------------------------
- # Platform core import (single source of truth for metrics / parsing).
- # This replaces the historical per-file METRIC_DEFINITIONS copies, fixing the
- # drift bug (three inconsistent metric lists) and the tavg_nm / ripple_pct
- # parsing bug (normalized matching handles full-width chars in exports).
- # ---------------------------------------------------------------------------
- _ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
- _SRC_DIR = os.path.join(_ROOT_DIR, "src")
- if _SRC_DIR not in sys.path:
- sys.path.insert(0, _SRC_DIR)
- from afmcore.metrics import ( # noqa: E402
- METRIC_DEFINITIONS,
- METRIC_KEYS,
- METRIC_LABELS,
- REQUIRED_METRICS,
- extract_all_metrics as _platform_extract_all_metrics,
- parse_export as _platform_parse_export,
- )
- # Known incompatible sampling point / mesh combinations that cause popups
- INCOMPATIBLE_SAMPLING_MESH = [
- (120, 840), # Motor-CAD warns mesh/time step mismatch, blocks batch
- ]
- # Recommended compatible combinations
- RECOMMENDED_SAMPLING_MESH = [
- (30, 840), # Fast trend scan
- (120, 960), # Medium confidence
- (180, 1680), # High confidence final
- ]
- # ---------------------------------------------------------------------------
- # Variable name version mapping (not hardcoded, configurable).
- # Reference: GitHub Issue #319 - parameter names change across versions
- # e.g. MagWindingType -> MagneticWindingType
- # ---------------------------------------------------------------------------
- VARIABLE_NAME_MAP: Dict[str, Dict[str, str]] = {
- # canonical_name: {version_range: actual_variable_name}
- # Business alias -> Motor-CAD actual variable name.
- # L0 / plan_schema use airgap_mm; Motor-CAD calls it Airgap.
- # Confirmed by variable probing (TEST-002, KNOWLEDGE_BASE).
- "airgap_mm": {
- "default": "Airgap",
- },
- "MagneticWindingType": {
- "default": "MagneticWindingType",
- "legacy": "MagWindingType", # pre-2023 versions
- },
- "TorquePointsPerCycle": {
- "default": "TorquePointsPerCycle",
- },
- "AirgapMeshPoints_mesh": {
- "default": "AirgapMeshPoints_mesh",
- },
- "AirgapMeshPoints_layers": {
- "default": "AirgapMeshPoints_layers",
- },
- "Slot_Opening": {
- "default": "Slot_Opening",
- },
- "Slot_Width": {
- "default": "Slot_Width",
- },
- "Copper_Width": {
- "default": "Copper_Width",
- },
- "MagnetCentralArc_HalbachRing": {
- "default": "MagnetCentralArc_HalbachRing",
- },
- "Magnet_Arc_[ED]": {
- "default": "Magnet_Arc_[ED]",
- },
- "MessageDisplayState": {
- "default": "MessageDisplayState",
- },
- }
- def resolve_variable_name(canonical_name: str, motorcad_version: Optional[str] = None) -> str:
- """Resolve canonical variable name to version-specific actual name.
- Args:
- canonical_name: Canonical parameter name (key in VARIABLE_NAME_MAP)
- motorcad_version: Motor-CAD version string, e.g. "2024.2.3"
- Returns:
- Actual variable name for this Motor-CAD version
- """
- mapping = VARIABLE_NAME_MAP.get(canonical_name, {})
- if not mapping:
- return canonical_name
- # For now, use default. Version-specific logic can be added here.
- return mapping.get("default", canonical_name)
- def ensure_environment() -> None:
- """Ensure Motor-CAD environment variables are set (non-login shell trap).
- Reference: AGENTS.md environment variable traps.
- Non-login shell may not inherit machine-level env vars:
- - MOTORCAD_ACTIVEX empty -> pymotorcad cannot find Motor-CAD
- - ANSYSLMD_LICENSE_FILE empty -> Motor-CAD silently exits after ~30s
- """
- if not os.environ.get("MOTORCAD_ACTIVEX"):
- try:
- from ansys.motorcad.core import set_motorcad_exe
- candidate = r"D:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe"
- if os.path.exists(candidate):
- set_motorcad_exe(candidate)
- except Exception:
- pass
- if not os.environ.get("ANSYSLMD_LICENSE_FILE"):
- os.environ["ANSYSLMD_LICENSE_FILE"] = "1055@localhost"
- def check_sampling_mesh_compatibility(torque_points: int, airgap_mesh: int) -> Tuple[bool, str]:
- """Check if sampling point / mesh combination is compatible.
- Returns (compatible, message). Incompatible combinations cause
- Motor-CAD popups that block unattended batch execution.
- Reference: MOTORCAD_SCAN_KNOWLEDGE_BASE.md section 6.2
- """
- for pts, mesh in INCOMPATIBLE_SAMPLING_MESH:
- if torque_points == pts and airgap_mesh == mesh:
- return False, (
- f"TorquePoints={torque_points} + AirgapMesh={airgap_mesh} "
- f"causes Motor-CAD popup. Use {RECOMMENDED_SAMPLING_MESH[1]} instead."
- )
- return True, "OK"
- def compute_copper_width(slot_opening_mm: float, clearance_mm: float = 0.2,
- conductor_count: int = 1) -> float:
- """Compute PCB copper width from slot opening (linkage formula).
- Copper_Width = (Slot_Opening - clearance) / 2 / conductor_count
- Reference: MOTORCAD_SCAN_KNOWLEDGE_BASE.md section 4.3
- """
- return round((slot_opening_mm - clearance_mm) / 2.0 / conductor_count, 3)
- def is_running_as_admin() -> bool:
- """Check if running with administrator privileges (Windows).
- Reference: Fault case #5 - "Unable to run FE module" solved by
- running as administrator.
- """
- try:
- if platform.system() == "Windows":
- import ctypes
- return ctypes.windll.shell32.IsUserAnAdmin() != 0
- return os.geteuid() == 0 # type: ignore
- except Exception:
- return False
- def check_license_server(host: str = "localhost", port: int = 1055, timeout: float = 3.0) -> Tuple[bool, str]:
- """Check if Ansys License Manager server is reachable.
- Reference: Fault case #8 - cannot get license.
- """
- try:
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.settimeout(timeout)
- result = sock.connect_ex((host, port))
- sock.close()
- if result == 0:
- return True, f"License server {host}:{port} reachable"
- return False, f"License server {host}:{port} not reachable (error code {result})"
- except Exception as e:
- return False, f"License server check failed: {e}"
- class PreflightResult:
- """Result of 5-layer preflight self-check."""
- def __init__(self):
- self.layers: Dict[str, Dict[str, Any]] = {
- "connection": {"passed": True, "checks": [], "warnings": []},
- "permission": {"passed": True, "checks": [], "warnings": []},
- "license": {"passed": True, "checks": [], "warnings": []},
- "model": {"passed": True, "checks": [], "warnings": []},
- "script": {"passed": True, "checks": [], "warnings": []},
- }
- def add_check(self, layer: str, name: str, passed: bool, message: str = "") -> None:
- if layer in self.layers:
- self.layers[layer]["checks"].append({"name": name, "passed": passed, "message": message})
- if not passed:
- self.layers[layer]["passed"] = False
- def add_warning(self, layer: str, message: str) -> None:
- if layer in self.layers:
- self.layers[layer]["warnings"].append(message)
- @property
- def all_passed(self) -> bool:
- return all(layer["passed"] for layer in self.layers.values())
- def to_dict(self) -> Dict[str, Any]:
- return {"all_passed": self.all_passed, "layers": self.layers}
- def summary(self) -> str:
- lines = ["Preflight Self-Check Summary:"]
- for layer_name, layer in self.layers.items():
- status = "PASS" if layer["passed"] else "FAIL"
- lines.append(f" [{status}] {layer_name} layer")
- for check in layer["checks"]:
- cs = "OK" if check["passed"] else "FAIL"
- lines.append(f" [{cs}] {check['name']}: {check['message']}")
- for warning in layer["warnings"]:
- lines.append(f" [WARN] {warning}")
- return "\n".join(lines)
- class RobustMotorCADSolver:
- """Robust Motor-CAD simulation solver with all best practices.
- Usage:
- solver = RobustMotorCADSolver(model_path="base.mot")
- solver.connect()
- preflight = solver.run_preflight()
- if preflight.all_passed:
- for params in parameter_list:
- result = solver.run_single_point(params, point_index=0)
- solver.disconnect()
- """
- def __init__(self, model_path: str, output_dir: Optional[str] = None,
- point_timeout: int = 300, max_retries: int = 3,
- headless: bool = False, motorcad_version: Optional[str] = None,
- enable_thermal: bool = False):
- self.model_path = model_path
- # P5-M6: optional thermal solve (requires model with thermal
- # network configured; OFF by default to preserve EM-only behavior)
- self.enable_thermal = bool(enable_thermal)
- self.output_dir = output_dir or os.path.join(
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
- "output", f"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
- )
- self.raw_dir = os.path.join(self.output_dir, "raw")
- os.makedirs(self.raw_dir, exist_ok=True)
- self.point_timeout = point_timeout
- self.max_retries = max_retries
- self.headless = headless
- self.motorcad_version = motorcad_version
- self.mc = None
- self._popup_suppressed = False
- self._csv_path = os.path.join(self.output_dir, "scan_results.csv")
- self._json_path = os.path.join(self.output_dir, "scan_results.json")
- self._log_path = os.path.join(self.output_dir, "program_log.log")
- self._all_results: List[Dict[str, Any]] = []
- self._csv_header_written = False
- def connect(self) -> None:
- """Connect to a new Motor-CAD instance (never connect to existing).
- Supports:
- - Internal/external scripting context detection
- - BlackBox headless mode for server batch execution
- - set_visible(True) for /SCRIPTING mode (default hidden)
- Reference: Official doc section 2.1 connection modes.
- """
- ensure_environment()
- try:
- from ansys.motorcad.core import MotorCAD, is_running_in_internal_scripting
- # Detect internal vs external scripting context
- if is_running_in_internal_scripting():
- self.mc = MotorCAD(open_new_instance=False)
- self._log("Connected in internal scripting mode")
- else:
- # External script: always open new instance
- if self.headless:
- # BlackBox mode: no GUI, suitable for server batch
- self.mc = MotorCAD(open_new_instance=True, keep_instance_open=False)
- self._log("Connected in BlackBox headless mode")
- else:
- self.mc = MotorCAD(open_new_instance=True, keep_instance_open=False)
- self.mc.set_visible(True)
- self._log("Connected to new visible Motor-CAD instance")
- time.sleep(2) # Wait for instance to fully initialize
- # Health check: verify connection is responsive
- _ = self.mc.get_variable("Motor_Type")
- self._log("Connection health check passed")
- except MotorCADError as e:
- self._log(f"MotorCADError during connection: {e}")
- raise
- except Exception as e:
- self._log(f"Connection failed: {e}")
- raise
- def disconnect(self) -> None:
- """Disconnect from Motor-CAD instance.
- Always restores popup state before quitting.
- """
- if self.mc:
- # Restore popup state (critical: MessageDisplayState must be restored)
- self._restore_popup_state()
- try:
- # Reload baseline to leave clean state
- self.mc.load_from_file(self.model_path)
- except Exception:
- pass
- try:
- self.mc.quit()
- except Exception:
- pass
- self.mc = None
- self._log("Disconnected from Motor-CAD")
- def _suppress_popups(self) -> None:
- """Suppress Motor-CAD popups for batch execution.
- MessageDisplayState=2: messages go to independent window, no popups.
- Reference: Official doc section 2.3 popup control.
- WARNING: This disables critical dialogs (save prompts, overwrite
- confirmations). Must be restored with _restore_popup_state().
- """
- if self.mc and not self._popup_suppressed:
- try:
- var_name = resolve_variable_name("MessageDisplayState", self.motorcad_version)
- self.mc.set_variable(var_name, 2)
- self._popup_suppressed = True
- self._log("Popup suppression enabled (MessageDisplayState=2)")
- except MotorCADError as e:
- self._log(f"Failed to suppress popups: {e}")
- except Exception as e:
- self._log(f"Failed to suppress popups: {e}")
- def _restore_popup_state(self) -> None:
- """Restore popup state to default (0).
- Must be called in finally blocks to ensure restoration even on error.
- Reference: Official doc section 2.3 - "script must restore before exit".
- """
- if self.mc and self._popup_suppressed:
- try:
- var_name = resolve_variable_name("MessageDisplayState", self.motorcad_version)
- self.mc.set_variable(var_name, 0)
- self._popup_suppressed = False
- self._log("Popup state restored (MessageDisplayState=0)")
- except MotorCADError as e:
- self._log(f"Failed to restore popup state: {e}")
- except Exception as e:
- self._log(f"Failed to restore popup state: {e}")
- def _write_and_verify(self, variable: str, value: float,
- rel_tol: float = 1e-8, abs_tol: float = 1e-7) -> float:
- """Write variable and verify with get_variable. Mismatch raises.
- Reference: AGENTS.md constraint #4 - parameter must be read-back verified.
- Motor-CAD sometimes silently accepts inapplicable parameters.
- """
- # Resolve version-specific variable name
- actual_var = resolve_variable_name(variable, self.motorcad_version)
- try:
- self.mc.set_variable(actual_var, value)
- applied = float(self.mc.get_variable(actual_var))
- except MotorCADError as e:
- raise RuntimeError(f"MotorCADError writing {actual_var}: {e}")
- if not math.isclose(applied, value, rel_tol=rel_tol, abs_tol=abs_tol):
- raise RuntimeError(
- f"Variable {actual_var} write mismatch: applied={applied}, expected={value}"
- )
- return applied
- def run_preflight(self) -> PreflightResult:
- """Run 5-layer preflight self-check before simulation.
- Layers (reference: official doc section 3.3 troubleshooting checklist):
- 1. Connection: multi-version, Automation registration, port/firewall, Hide command Window
- 2. Permission: admin rights, default install path, post-install reboot
- 3. License: License Manager service, port, validity, concurrency
- 4. Model: region closure, duplicate regions, adaptive geometry reset
- 5. Script: MotorCADError handling, variable name mapping, popup state
- Returns:
- PreflightResult with all layer checks
- """
- result = PreflightResult()
- self._log("Starting 5-layer preflight self-check...")
- # Layer 1: Connection
- result.add_check("connection", "Motor-CAD instance connected",
- self.mc is not None, "Instance is active" if self.mc else "No instance")
- result.add_check("connection", "Connection responsive",
- self._check_connection_responsive(),
- "Instance responds to get_variable" if self._check_connection_responsive() else "Instance not responding")
- # Check for common "Hide command Window" issue (GitHub Issue #140)
- result.add_warning("connection",
- "If connection fails, check Motor-CAD Settings -> 'Hide command Window' is unchecked (known bug #140)")
- # Layer 2: Permission
- admin = is_running_as_admin()
- result.add_check("permission", "Running as administrator",
- admin, "Admin privileges active" if admin else "Not running as admin (may cause FE module errors)")
- if not admin:
- result.add_warning("permission",
- "Fault case #5: 'Unable to run FE module' may be solved by running as administrator")
- # Check default install path
- default_path = r"C:\ANSYS_Motor-CAD"
- has_default = os.path.exists(default_path)
- result.add_check("permission", "Default install path exists",
- has_default, f"Path {default_path} exists" if has_default else f"Default path {default_path} not found (non-default install may cause issues)")
- # Layer 3: License
- license_ok, license_msg = check_license_server()
- result.add_check("license", "License server reachable", license_ok, license_msg)
- if not license_ok:
- result.add_warning("license",
- "Fault case #8: Check Ansys License Manager service, port 1055, license file validity, and concurrency count")
- # Layer 4: Model
- model_exists = os.path.exists(self.model_path)
- result.add_check("model", "Baseline model file exists",
- model_exists, f"Model at {self.model_path}" if model_exists else f"Model not found at {self.model_path}")
- if model_exists and self.mc:
- try:
- self.mc.load_from_file(self.model_path)
- result.add_check("model", "Model loads successfully", True, "Model loaded without error")
- except MotorCADError as e:
- result.add_check("model", "Model loads successfully", False, f"MotorCADError: {e}")
- except Exception as e:
- result.add_check("model", "Model loads successfully", False, str(e))
- result.add_warning("model",
- "If using adaptive geometry, call reset_adaptive_geometry() before modifications; ensure regions are closed (is_closed()) and counter-clockwise")
- # Layer 5: Script
- result.add_check("script", "MotorCADError import available",
- HAS_MOTORCAD_ERROR,
- "ansys.motorcad.core.MotorCADError imported" if HAS_MOTORCAD_ERROR else "MotorCADError not available (using generic Exception fallback)")
- result.add_check("script", "Variable name mapping configured",
- len(VARIABLE_NAME_MAP) > 0,
- f"{len(VARIABLE_NAME_MAP)} variables in mapping table")
- result.add_check("script", "Popup state will be restored on disconnect",
- True, "try/finally pattern ensures MessageDisplayState restoration")
- self._log(result.summary())
- return result
- def _check_connection_responsive(self) -> bool:
- """Check if Motor-CAD instance is responsive."""
- if not self.mc:
- return False
- try:
- _ = self.mc.get_variable("Motor_Type")
- return True
- except Exception:
- return False
- def run_single_point(self, params: Dict[str, Any], point_index: int = 0,
- point_label: str = "",
- enable_thermal: Optional[bool] = None) -> Dict[str, Any]:
- """Run a single simulation point with full robustness protocol.
- Protocol:
- 1. Suppress popups (MessageDisplayState=2)
- 2. Reload baseline model
- 3. Check sampling/mesh compatibility
- 4. Write all parameters with write-back verification (version-resolved names)
- 5. Handle linked parameters (slot opening -> copper width)
- 6. Run magnetic calculation
- 7. Export and parse results
- 8. Write results to CSV and JSON (flush immediately)
- 9. Reload baseline again
- 10. Restore popup state (in finally)
- All Motor-CAD calls wrapped in try/except MotorCADError.
- """
- start_time = time.time()
- result = {
- "point_index": point_index,
- "point_label": point_label,
- "params": params,
- "status": "pending",
- "metrics": {},
- "error": None,
- "duration_s": 0,
- }
- # Suppress popups for batch execution
- self._suppress_popups()
- try:
- for attempt in range(self.max_retries):
- try:
- # Step 1: Reload baseline
- try:
- self.mc.load_from_file(self.model_path)
- except MotorCADError as e:
- raise RuntimeError(f"Baseline reload failed: {e}")
- # Step 2: Check sampling/mesh compatibility if present
- if "TorquePointsPerCycle" in params and "AirgapMeshPoints_mesh" in params:
- compatible, msg = check_sampling_mesh_compatibility(
- int(params["TorquePointsPerCycle"]),
- int(params["AirgapMeshPoints_mesh"])
- )
- if not compatible:
- self._log(f"WARNING: {msg}")
- result["error"] = msg
- result["status"] = "FAILED"
- # B5 fix: break instead of return so the point
- # is appended to results and written to disk
- break
- # Step 3: Write all numeric parameters with verification.
- # Non-numeric params (materials, grades, strings) are
- # skipped because set_variable expects a number. (C1 fix)
- for var, val in params.items():
- if var in ("point_index", "point_label", "point_id"):
- continue
- try:
- num = float(val)
- except (TypeError, ValueError):
- self._log(
- f"Skipping non-numeric param {var}={val!r}"
- )
- continue
- self._write_and_verify(var, num)
- # Step 4: Handle linked parameters
- if "Slot_Opening" in params and "Copper_Width" not in params:
- copper_w = compute_copper_width(float(params["Slot_Opening"]))
- self._write_and_verify("Copper_Width", copper_w)
- # Step 5: Run magnetic calculation
- try:
- self.mc.do_magnetic_calculation()
- except MotorCADError as e:
- raise RuntimeError(f"Magnetic calculation failed: {e}")
- # Step 5b: Optional thermal calculation (P5-M6)
- # Best-effort: thermal solve requires a model with thermal
- # network configured; failures are warnings, EM results
- # remain valid. enable_thermal param overrides instance default.
- _thermal_on = (
- enable_thermal if enable_thermal is not None
- else self.enable_thermal
- )
- if _thermal_on:
- try:
- # P5-M6 fix (2026-09-04): pymotorcad has NO
- # do_thermal_calculation() method. The steady-state
- # thermal solve is do_steady_state_analysis().
- # Verified against ansys.motorcad.core sources.
- self.mc.do_steady_state_analysis()
- self._log("Steady-state thermal calculation completed")
- except Exception as _therr: # noqa: BLE001
- self._log(
- f"WARNING: thermal calculation failed "
- f"(model may lack thermal network): {_therr}"
- )
- # Step 6: Export and parse
- raw_file = os.path.join(
- self.raw_dir,
- f"result_{point_index:04d}_{point_label or 'point'}_{datetime.now().strftime('%H%M%S')}.csv"
- )
- try:
- self.mc.export_results("EMagnetic", raw_file)
- except MotorCADError as e:
- raise RuntimeError(f"Results export failed: {e}")
- # Verify export file actually exists
- if not os.path.exists(raw_file):
- raise RuntimeError(f"Export file not created: {raw_file}")
- metrics = self._parse_export(raw_file)
- # Step 6b: Optional thermal export and metric merge (P5-M6)
- # Best-effort: thermal export section name may vary by
- # Motor-CAD version; failures do not invalidate EM metrics.
- if _thermal_on:
- try:
- _thermal_file = raw_file.replace(".csv", "_thermal.csv")
- # solution_type is "SteadyState" (NOT "Thermal").
- # Valid values: EMagnetic / Lab / SteadyState / Transient.
- self.mc.export_results("SteadyState", _thermal_file)
- if os.path.exists(_thermal_file):
- _thermal_metrics = self._parse_export(_thermal_file)
- metrics.update(_thermal_metrics)
- self._log(
- "Thermal metrics merged: %s"
- % sorted(_thermal_metrics.keys())
- )
- except Exception as _texerr: # noqa: BLE001
- self._log(
- f"WARNING: thermal export/merge failed: {_texerr}"
- )
- result["metrics"] = metrics
- result["status"] = "OK"
- break
- except MotorCADError as e:
- result["error"] = f"MotorCADError: {e}"
- self._log(f"Point {point_index} attempt {attempt+1} MotorCADError: {e}")
- if attempt < self.max_retries - 1:
- self._log(f"Retrying point {point_index}...")
- time.sleep(2)
- self._reconnect_if_needed()
- else:
- result["status"] = "FAILED"
- except Exception as e:
- result["error"] = f"{type(e).__name__}: {e}"
- self._log(f"Point {point_index} attempt {attempt+1} failed: {e}")
- if attempt < self.max_retries - 1:
- self._log(f"Retrying point {point_index}...")
- time.sleep(2)
- self._reconnect_if_needed()
- else:
- result["status"] = "FAILED"
- finally:
- # Restore popup state (CRITICAL: must happen even on error)
- self._restore_popup_state()
- # Reload baseline to leave clean state
- try:
- if self.mc:
- self.mc.load_from_file(self.model_path)
- except Exception:
- pass
- result["duration_s"] = round(time.time() - start_time, 2)
- self._all_results.append(result)
- self._write_result_to_disk(result)
- return result
- def _reconnect_if_needed(self) -> None:
- """Check if instance is responsive, reconnect if not."""
- if not self._check_connection_responsive():
- self._log("Instance unresponsive, reconnecting...")
- try:
- self.disconnect()
- except Exception:
- pass
- try:
- self.connect()
- self._suppress_popups()
- except Exception as e:
- self._log(f"Reconnection failed: {e}")
- def read_graph_data(self, graph_name: str, max_points: int = 10000) -> List[Tuple[float, float]]:
- """Read graph data using "out-of-bounds = end" idiom.
- Motor-CAD API only exposes the most recently displayed curve.
- Reading past the end throws MotorCADError, which we use as
- the sequence termination signal.
- Reference: Official doc section 3.1 point 2 - graph reading idiom.
- Args:
- graph_name: Name of the graph to read (check in Motor-CAD Help -> Graph Viewer)
- max_points: Safety limit to prevent infinite loops
- Returns:
- List of (x, y) data points
- """
- points: List[Tuple[float, float]] = []
- if not self.mc:
- return points
- try:
- i = 0
- while i < max_points:
- try:
- x = self.mc.get_magnetic_graph_point(graph_name, i)
- # get_magnetic_graph_point may return tuple or single value
- if isinstance(x, (list, tuple)):
- points.append((float(x[0]), float(x[1])))
- else:
- # Single value return - use index as x
- points.append((float(i), float(x)))
- i += 1
- except MotorCADError:
- # Out of bounds = end of data (official idiom)
- break
- except Exception:
- break
- except Exception as e:
- self._log(f"Graph reading error: {e}")
- return points
- def _parse_export(self, filepath: str) -> Dict[str, float]:
- """Parse Motor-CAD export CSV with normalized bilingual matching.
- Delegates to the platform single source of truth
- (src/afmcore/metrics.py), which applies full-width -> half-width
- normalization. This fixes the historical bug where tavg_nm and
- ripple_pct could not be matched due to invisible full-width chars
- in exported field names.
- """
- if not os.path.exists(filepath):
- return {}
- return _platform_extract_all_metrics(_platform_parse_export(filepath))
- def _write_result_to_disk(self, result: Dict[str, Any]) -> None:
- """Write result to CSV and JSON immediately (flush + fsync)."""
- if not self._csv_header_written:
- header = ["point_index", "point_label", "status", "duration_s"]
- for md in METRIC_DEFINITIONS:
- header.append(md["key"])
- if result["params"]:
- for k in result["params"]:
- if k not in ("point_index", "point_label"):
- header.append(f"param_{k}")
- with open(self._csv_path, "w", newline="", encoding="utf-8") as f:
- writer = csv.writer(f, delimiter=";")
- writer.writerow(header)
- f.flush()
- os.fsync(f.fileno())
- self._csv_header_written = True
- row = [result["point_index"], result["point_label"],
- result["status"], result["duration_s"]]
- for md in METRIC_DEFINITIONS:
- row.append(result["metrics"].get(md["key"], ""))
- if result["params"]:
- for k, v in result["params"].items():
- if k not in ("point_index", "point_label"):
- row.append(v)
- with open(self._csv_path, "a", newline="", encoding="utf-8") as f:
- writer = csv.writer(f, delimiter=";")
- writer.writerow(row)
- f.flush()
- os.fsync(f.fileno())
- with open(self._json_path, "w", encoding="utf-8") as f:
- json.dump({"results": self._all_results}, f, ensure_ascii=False, indent=2)
- f.flush()
- os.fsync(f.fileno())
- def _log(self, message: str) -> None:
- """Write timestamped log message."""
- ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
- line = f"[{ts}] {message}\n"
- try:
- with open(self._log_path, "a", encoding="utf-8") as f:
- f.write(line)
- f.flush()
- except Exception:
- pass
- def get_summary(self) -> Dict[str, Any]:
- """Get run summary."""
- ok = [r for r in self._all_results if r["status"] == "OK"]
- failed = [r for r in self._all_results if r["status"] == "FAILED"]
- return {
- "total": len(self._all_results),
- "ok": len(ok),
- "failed": len(failed),
- "output_dir": self.output_dir,
- "csv_path": self._csv_path,
- "json_path": self._json_path,
- "log_path": self._log_path,
- "headless_mode": self.headless,
- }
|