robust_motorcad.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852
  1. """Robust Motor-CAD simulation core (P4-M3 + reference doc enhancement).
  2. Integrates all robustness practices from reference projects and
  3. official Motor-CAD automation reference documentation:
  4. Connection & Lifecycle:
  5. - open_new_instance=True + set_visible(True) (never connect to existing)
  6. - BlackBox headless mode support for server batch execution
  7. - Internal/external scripting context detection (is_running_in_internal_scripting)
  8. - Environment variable fallback (MOTORCAD_ACTIVEX, ANSYSLMD_LICENSE_FILE)
  9. Error Handling:
  10. - MotorCADError first-catch (PyMotorCAD throws on failure, no silent success)
  11. - Per-point timeout + retry (max 3 attempts) + auto-reconnect
  12. - Instance crash detection and auto-restart
  13. Batch Safety:
  14. - MessageDisplayState=2 popup suppression with try/finally restore
  15. - Parameter write-back verification (set then get, mismatch = FAILED)
  16. - Per-point baseline reload (load_from_file before and after each point)
  17. - Sampling point / mesh compatibility check (avoid 120pt+840mesh popup)
  18. - Slot opening / PCB copper width linkage formula
  19. Data Integrity:
  20. - Result export parsing: semicolon CSV, bilingual field aliases, E-Magnetics priority
  21. - Per-point dual write (CSV + JSON) with flush + fsync
  22. - Graph data reading with "out-of-bounds = end" idiom (while + try/except MotorCADError)
  23. Preflight Self-Check (5 layers):
  24. - Connection layer: multi-version, Automation registration, port/firewall, Hide command Window
  25. - Permission layer: admin rights, default install path, post-install reboot
  26. - License layer: License Manager service, port, validity, concurrency
  27. - Model layer: region closure (is_closed), duplicate regions, adaptive geometry reset
  28. - Script layer: MotorCADError handling, variable name version mapping, popup state
  29. Variable Name Version Mapping:
  30. - Configurable mapping table (not hardcoded) for version-specific name changes
  31. - e.g. MagWindingType -> MagneticWindingType across versions
  32. All source is ASCII only; Chinese field names use \\uXXXX escapes.
  33. """
  34. from __future__ import annotations
  35. import csv
  36. import json
  37. import math
  38. import os
  39. import platform
  40. import socket
  41. import time
  42. import traceback
  43. from datetime import datetime
  44. from pathlib import Path
  45. from typing import Any, Dict, List, Optional, Tuple
  46. # MotorCADError may not be available if pymotorcad is not installed
  47. try:
  48. from ansys.motorcad.core import MotorCADError
  49. HAS_MOTORCAD_ERROR = True
  50. except ImportError:
  51. MotorCADError = Exception # type: ignore
  52. HAS_MOTORCAD_ERROR = False
  53. # ---------------------------------------------------------------------------
  54. # Metric definitions: key, display label, and aliases (English + Chinese).
  55. # Chinese aliases use Unicode escapes so this file stays pure ASCII.
  56. # ---------------------------------------------------------------------------
  57. METRIC_DEFINITIONS = [
  58. {"key": "ripple_pct", "label": "Torque Ripple [%]",
  59. "aliases": ["Torque Ripple (VW) [%]", "Torque Ripple (VW)[%]",
  60. "Torque Ripple (VW) %"]},
  61. {"key": "ripple_nm", "label": "Torque Ripple [Nm]",
  62. "aliases": ["Torque Ripple (VW)"]},
  63. {"key": "tavg_nm", "label": "Average Torque VW [Nm]",
  64. "aliases": ["Average torque (virtual work)",
  65. "\u5e73\u5747\u8f6c\u77e9 (virtual work)",
  66. "\u5e73\u5747\u8f6c\u77e9(virtual work)",
  67. "\u5e73\u5747\u8f6c\u77e9 (DQ)",
  68. "\u5e73\u5747\u8f6c\u77e9(DQ)",
  69. "\u5e73\u5747\u8f6c\u77e9 (loop torque)",
  70. "\u5e73\u5747\u8f6c\u77e9(loop torque)"]},
  71. {"key": "shaft_torque_nm", "label": "Shaft Torque [Nm]",
  72. "aliases": ["Shaft Torque", "\u8f74\u8f6c\u77e9"]},
  73. {"key": "stall_torque_nm", "label": "Stall Torque [Nm]",
  74. "aliases": ["Stall Torque", "\u5835\u8f6c\u8f6c\u77e9"]},
  75. {"key": "torque_constant", "label": "Torque Constant Kt [Nm/A]",
  76. "aliases": ["Torque Constant (Kt)", "\u8f6c\u77e9\u5e38\u6570(Kt)",
  77. "\u8f6c\u77e9\u5e38\u6570\uff08Kt\uff09"]},
  78. {"key": "efficiency_pct", "label": "Efficiency [%]",
  79. "aliases": ["System Efficiency", "\u7cfb\u7edf\u6548\u7387"]},
  80. {"key": "back_emf_v", "label": "Back EMF LL rms [V]",
  81. "aliases": ["Back EMF Line-Line Voltage (rms)",
  82. "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u6709\u6548\u503c",
  83. "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u5e45\u503c"]},
  84. {"key": "total_losses_w", "label": "Total losses [W]",
  85. "aliases": ["Total Losses (on load)", "\u603b\u635f\u8017(\u989d\u5b9a)",
  86. "\u603b\u635f\u8017 (\u989d\u5b9a)", "\u603b\u635f\u8017(\u7a7a\u8f7d)"]},
  87. {"key": "copper_loss_w", "label": "DC copper loss [W]",
  88. "aliases": ["Armature DC Copper Loss (on load)",
  89. "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u5e26\u8f7d)",
  90. "\u7535\u67a2\u76f4\u6d41\u94dc\u8017 \uff08\u5e26\u8f7d\uff09",
  91. "\u7535\u67a2\u76f4\u6d41\u94dc\u8017 \uff08\u7a7a\u8f7d\uff09"]},
  92. {"key": "magnet_loss_w", "label": "Magnet loss [W]",
  93. "aliases": ["Magnet Loss (on load)",
  94. "\u6c38\u78c1\u4f53\u635f\u8017(\u989d\u5b9a)",
  95. "\u6c38\u78c1\u4f53\u635f\u8017 (\u989d\u5b9a)",
  96. "\u6c38\u78c1\u4f53\u635f\u8017(\u7a7a\u8f7d)"]},
  97. {"key": "iron_loss_w", "label": "Stator iron loss [W]",
  98. "aliases": ["Stator iron Loss [total] (on load)",
  99. "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u989d\u5b9a)",
  100. "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017] (\u989d\u5b9a)",
  101. "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u7a7a\u8f7d)"]},
  102. {"key": "input_power_w", "label": "Input power [W]",
  103. "aliases": ["Input Power", "\u8f93\u5165\u529f\u7387"]},
  104. {"key": "output_power_w", "label": "Output power [W]",
  105. "aliases": ["Output Power", "\u8f93\u51fa\u529f\u7387"]},
  106. {"key": "em_power_w", "label": "EM power [W]",
  107. "aliases": ["Electromagnetic Power", "\u7535\u78c1\u529f\u7387"]},
  108. {"key": "shaft_speed_rpm", "label": "Shaft speed [rpm]",
  109. "aliases": ["Shaft Speed", "\u8f6c\u901f[RPM]", "\u8f6c\u901f\uff5bRPM\uff5d",
  110. "\u7a7a\u8f7d\u8f6c\u901f"]},
  111. {"key": "phase_current_peak_a", "label": "Phase current peak [A]",
  112. "aliases": ["Peak Phase Current", "\u76f8\u7535\u6d41\u5cf0\u503c"]},
  113. {"key": "line_current_rms_a", "label": "Line current rms [A]",
  114. "aliases": ["Line Current (rms)", "\u7ebf\u7535\u6d41 (\u6709\u6548\u503c)",
  115. "\u7ebf\u7535\u6d41(\u6709\u6548\u503c)", "\u76f8\u7535\u6d41\u6709\u6548\u503c"]},
  116. ]
  117. # Known incompatible sampling point / mesh combinations that cause popups
  118. INCOMPATIBLE_SAMPLING_MESH = [
  119. (120, 840), # Motor-CAD warns mesh/time step mismatch, blocks batch
  120. ]
  121. # Recommended compatible combinations
  122. RECOMMENDED_SAMPLING_MESH = [
  123. (30, 840), # Fast trend scan
  124. (120, 960), # Medium confidence
  125. (180, 1680), # High confidence final
  126. ]
  127. # ---------------------------------------------------------------------------
  128. # Variable name version mapping (not hardcoded, configurable).
  129. # Reference: GitHub Issue #319 - parameter names change across versions
  130. # e.g. MagWindingType -> MagneticWindingType
  131. # ---------------------------------------------------------------------------
  132. VARIABLE_NAME_MAP: Dict[str, Dict[str, str]] = {
  133. # canonical_name: {version_range: actual_variable_name}
  134. "MagneticWindingType": {
  135. "default": "MagneticWindingType",
  136. "legacy": "MagWindingType", # pre-2023 versions
  137. },
  138. "TorquePointsPerCycle": {
  139. "default": "TorquePointsPerCycle",
  140. },
  141. "AirgapMeshPoints_mesh": {
  142. "default": "AirgapMeshPoints_mesh",
  143. },
  144. "AirgapMeshPoints_layers": {
  145. "default": "AirgapMeshPoints_layers",
  146. },
  147. "Slot_Opening": {
  148. "default": "Slot_Opening",
  149. },
  150. "Slot_Width": {
  151. "default": "Slot_Width",
  152. },
  153. "Copper_Width": {
  154. "default": "Copper_Width",
  155. },
  156. "MagnetCentralArc_HalbachRing": {
  157. "default": "MagnetCentralArc_HalbachRing",
  158. },
  159. "Magnet_Arc_[ED]": {
  160. "default": "Magnet_Arc_[ED]",
  161. },
  162. "MessageDisplayState": {
  163. "default": "MessageDisplayState",
  164. },
  165. }
  166. def resolve_variable_name(canonical_name: str, motorcad_version: Optional[str] = None) -> str:
  167. """Resolve canonical variable name to version-specific actual name.
  168. Args:
  169. canonical_name: Canonical parameter name (key in VARIABLE_NAME_MAP)
  170. motorcad_version: Motor-CAD version string, e.g. "2024.2.3"
  171. Returns:
  172. Actual variable name for this Motor-CAD version
  173. """
  174. mapping = VARIABLE_NAME_MAP.get(canonical_name, {})
  175. if not mapping:
  176. return canonical_name
  177. # For now, use default. Version-specific logic can be added here.
  178. return mapping.get("default", canonical_name)
  179. def ensure_environment() -> None:
  180. """Ensure Motor-CAD environment variables are set (non-login shell trap).
  181. Reference: AGENTS.md environment variable traps.
  182. Non-login shell may not inherit machine-level env vars:
  183. - MOTORCAD_ACTIVEX empty -> pymotorcad cannot find Motor-CAD
  184. - ANSYSLMD_LICENSE_FILE empty -> Motor-CAD silently exits after ~30s
  185. """
  186. if not os.environ.get("MOTORCAD_ACTIVEX"):
  187. try:
  188. from ansys.motorcad.core import set_motorcad_exe
  189. candidate = r"D:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe"
  190. if os.path.exists(candidate):
  191. set_motorcad_exe(candidate)
  192. except Exception:
  193. pass
  194. if not os.environ.get("ANSYSLMD_LICENSE_FILE"):
  195. os.environ["ANSYSLMD_LICENSE_FILE"] = "1055@localhost"
  196. def check_sampling_mesh_compatibility(torque_points: int, airgap_mesh: int) -> Tuple[bool, str]:
  197. """Check if sampling point / mesh combination is compatible.
  198. Returns (compatible, message). Incompatible combinations cause
  199. Motor-CAD popups that block unattended batch execution.
  200. Reference: MOTORCAD_SCAN_KNOWLEDGE_BASE.md section 6.2
  201. """
  202. for pts, mesh in INCOMPATIBLE_SAMPLING_MESH:
  203. if torque_points == pts and airgap_mesh == mesh:
  204. return False, (
  205. f"TorquePoints={torque_points} + AirgapMesh={airgap_mesh} "
  206. f"causes Motor-CAD popup. Use {RECOMMENDED_SAMPLING_MESH[1]} instead."
  207. )
  208. return True, "OK"
  209. def compute_copper_width(slot_opening_mm: float, clearance_mm: float = 0.2,
  210. conductor_count: int = 1) -> float:
  211. """Compute PCB copper width from slot opening (linkage formula).
  212. Copper_Width = (Slot_Opening - clearance) / 2 / conductor_count
  213. Reference: MOTORCAD_SCAN_KNOWLEDGE_BASE.md section 4.3
  214. """
  215. return round((slot_opening_mm - clearance_mm) / 2.0 / conductor_count, 3)
  216. def is_running_as_admin() -> bool:
  217. """Check if running with administrator privileges (Windows).
  218. Reference: Fault case #5 - "Unable to run FE module" solved by
  219. running as administrator.
  220. """
  221. try:
  222. if platform.system() == "Windows":
  223. import ctypes
  224. return ctypes.windll.shell32.IsUserAnAdmin() != 0
  225. return os.geteuid() == 0 # type: ignore
  226. except Exception:
  227. return False
  228. def check_license_server(host: str = "localhost", port: int = 1055, timeout: float = 3.0) -> Tuple[bool, str]:
  229. """Check if Ansys License Manager server is reachable.
  230. Reference: Fault case #8 - cannot get license.
  231. """
  232. try:
  233. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  234. sock.settimeout(timeout)
  235. result = sock.connect_ex((host, port))
  236. sock.close()
  237. if result == 0:
  238. return True, f"License server {host}:{port} reachable"
  239. return False, f"License server {host}:{port} not reachable (error code {result})"
  240. except Exception as e:
  241. return False, f"License server check failed: {e}"
  242. class PreflightResult:
  243. """Result of 5-layer preflight self-check."""
  244. def __init__(self):
  245. self.layers: Dict[str, Dict[str, Any]] = {
  246. "connection": {"passed": True, "checks": [], "warnings": []},
  247. "permission": {"passed": True, "checks": [], "warnings": []},
  248. "license": {"passed": True, "checks": [], "warnings": []},
  249. "model": {"passed": True, "checks": [], "warnings": []},
  250. "script": {"passed": True, "checks": [], "warnings": []},
  251. }
  252. def add_check(self, layer: str, name: str, passed: bool, message: str = "") -> None:
  253. if layer in self.layers:
  254. self.layers[layer]["checks"].append({"name": name, "passed": passed, "message": message})
  255. if not passed:
  256. self.layers[layer]["passed"] = False
  257. def add_warning(self, layer: str, message: str) -> None:
  258. if layer in self.layers:
  259. self.layers[layer]["warnings"].append(message)
  260. @property
  261. def all_passed(self) -> bool:
  262. return all(layer["passed"] for layer in self.layers.values())
  263. def to_dict(self) -> Dict[str, Any]:
  264. return {"all_passed": self.all_passed, "layers": self.layers}
  265. def summary(self) -> str:
  266. lines = ["Preflight Self-Check Summary:"]
  267. for layer_name, layer in self.layers.items():
  268. status = "PASS" if layer["passed"] else "FAIL"
  269. lines.append(f" [{status}] {layer_name} layer")
  270. for check in layer["checks"]:
  271. cs = "OK" if check["passed"] else "FAIL"
  272. lines.append(f" [{cs}] {check['name']}: {check['message']}")
  273. for warning in layer["warnings"]:
  274. lines.append(f" [WARN] {warning}")
  275. return "\n".join(lines)
  276. class RobustMotorCADSolver:
  277. """Robust Motor-CAD simulation solver with all best practices.
  278. Usage:
  279. solver = RobustMotorCADSolver(model_path="base.mot")
  280. solver.connect()
  281. preflight = solver.run_preflight()
  282. if preflight.all_passed:
  283. for params in parameter_list:
  284. result = solver.run_single_point(params, point_index=0)
  285. solver.disconnect()
  286. """
  287. def __init__(self, model_path: str, output_dir: Optional[str] = None,
  288. point_timeout: int = 300, max_retries: int = 3,
  289. headless: bool = False, motorcad_version: Optional[str] = None):
  290. self.model_path = model_path
  291. self.output_dir = output_dir or os.path.join(
  292. os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
  293. "output", f"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
  294. )
  295. self.raw_dir = os.path.join(self.output_dir, "raw")
  296. os.makedirs(self.raw_dir, exist_ok=True)
  297. self.point_timeout = point_timeout
  298. self.max_retries = max_retries
  299. self.headless = headless
  300. self.motorcad_version = motorcad_version
  301. self.mc = None
  302. self._popup_suppressed = False
  303. self._csv_path = os.path.join(self.output_dir, "scan_results.csv")
  304. self._json_path = os.path.join(self.output_dir, "scan_results.json")
  305. self._log_path = os.path.join(self.output_dir, "program_log.log")
  306. self._all_results: List[Dict[str, Any]] = []
  307. self._csv_header_written = False
  308. def connect(self) -> None:
  309. """Connect to a new Motor-CAD instance (never connect to existing).
  310. Supports:
  311. - Internal/external scripting context detection
  312. - BlackBox headless mode for server batch execution
  313. - set_visible(True) for /SCRIPTING mode (default hidden)
  314. Reference: Official doc section 2.1 connection modes.
  315. """
  316. ensure_environment()
  317. try:
  318. from ansys.motorcad.core import MotorCAD, is_running_in_internal_scripting
  319. # Detect internal vs external scripting context
  320. if is_running_in_internal_scripting():
  321. self.mc = MotorCAD(open_new_instance=False)
  322. self._log("Connected in internal scripting mode")
  323. else:
  324. # External script: always open new instance
  325. if self.headless:
  326. # BlackBox mode: no GUI, suitable for server batch
  327. self.mc = MotorCAD(open_new_instance=True, keep_instance_open=False)
  328. self._log("Connected in BlackBox headless mode")
  329. else:
  330. self.mc = MotorCAD(open_new_instance=True, keep_instance_open=False)
  331. self.mc.set_visible(True)
  332. self._log("Connected to new visible Motor-CAD instance")
  333. time.sleep(2) # Wait for instance to fully initialize
  334. # Health check: verify connection is responsive
  335. _ = self.mc.get_variable("Motor_Type")
  336. self._log("Connection health check passed")
  337. except MotorCADError as e:
  338. self._log(f"MotorCADError during connection: {e}")
  339. raise
  340. except Exception as e:
  341. self._log(f"Connection failed: {e}")
  342. raise
  343. def disconnect(self) -> None:
  344. """Disconnect from Motor-CAD instance.
  345. Always restores popup state before quitting.
  346. """
  347. if self.mc:
  348. # Restore popup state (critical: MessageDisplayState must be restored)
  349. self._restore_popup_state()
  350. try:
  351. # Reload baseline to leave clean state
  352. self.mc.load_from_file(self.model_path)
  353. except Exception:
  354. pass
  355. try:
  356. self.mc.quit()
  357. except Exception:
  358. pass
  359. self.mc = None
  360. self._log("Disconnected from Motor-CAD")
  361. def _suppress_popups(self) -> None:
  362. """Suppress Motor-CAD popups for batch execution.
  363. MessageDisplayState=2: messages go to independent window, no popups.
  364. Reference: Official doc section 2.3 popup control.
  365. WARNING: This disables critical dialogs (save prompts, overwrite
  366. confirmations). Must be restored with _restore_popup_state().
  367. """
  368. if self.mc and not self._popup_suppressed:
  369. try:
  370. var_name = resolve_variable_name("MessageDisplayState", self.motorcad_version)
  371. self.mc.set_variable(var_name, 2)
  372. self._popup_suppressed = True
  373. self._log("Popup suppression enabled (MessageDisplayState=2)")
  374. except MotorCADError as e:
  375. self._log(f"Failed to suppress popups: {e}")
  376. except Exception as e:
  377. self._log(f"Failed to suppress popups: {e}")
  378. def _restore_popup_state(self) -> None:
  379. """Restore popup state to default (0).
  380. Must be called in finally blocks to ensure restoration even on error.
  381. Reference: Official doc section 2.3 - "script must restore before exit".
  382. """
  383. if self.mc and self._popup_suppressed:
  384. try:
  385. var_name = resolve_variable_name("MessageDisplayState", self.motorcad_version)
  386. self.mc.set_variable(var_name, 0)
  387. self._popup_suppressed = False
  388. self._log("Popup state restored (MessageDisplayState=0)")
  389. except MotorCADError as e:
  390. self._log(f"Failed to restore popup state: {e}")
  391. except Exception as e:
  392. self._log(f"Failed to restore popup state: {e}")
  393. def _write_and_verify(self, variable: str, value: float,
  394. rel_tol: float = 1e-8, abs_tol: float = 1e-7) -> float:
  395. """Write variable and verify with get_variable. Mismatch raises.
  396. Reference: AGENTS.md constraint #4 - parameter must be read-back verified.
  397. Motor-CAD sometimes silently accepts inapplicable parameters.
  398. """
  399. # Resolve version-specific variable name
  400. actual_var = resolve_variable_name(variable, self.motorcad_version)
  401. try:
  402. self.mc.set_variable(actual_var, value)
  403. applied = float(self.mc.get_variable(actual_var))
  404. except MotorCADError as e:
  405. raise RuntimeError(f"MotorCADError writing {actual_var}: {e}")
  406. if not math.isclose(applied, value, rel_tol=rel_tol, abs_tol=abs_tol):
  407. raise RuntimeError(
  408. f"Variable {actual_var} write mismatch: applied={applied}, expected={value}"
  409. )
  410. return applied
  411. def run_preflight(self) -> PreflightResult:
  412. """Run 5-layer preflight self-check before simulation.
  413. Layers (reference: official doc section 3.3 troubleshooting checklist):
  414. 1. Connection: multi-version, Automation registration, port/firewall, Hide command Window
  415. 2. Permission: admin rights, default install path, post-install reboot
  416. 3. License: License Manager service, port, validity, concurrency
  417. 4. Model: region closure, duplicate regions, adaptive geometry reset
  418. 5. Script: MotorCADError handling, variable name mapping, popup state
  419. Returns:
  420. PreflightResult with all layer checks
  421. """
  422. result = PreflightResult()
  423. self._log("Starting 5-layer preflight self-check...")
  424. # Layer 1: Connection
  425. result.add_check("connection", "Motor-CAD instance connected",
  426. self.mc is not None, "Instance is active" if self.mc else "No instance")
  427. result.add_check("connection", "Connection responsive",
  428. self._check_connection_responsive(),
  429. "Instance responds to get_variable" if self._check_connection_responsive() else "Instance not responding")
  430. # Check for common "Hide command Window" issue (GitHub Issue #140)
  431. result.add_warning("connection",
  432. "If connection fails, check Motor-CAD Settings -> 'Hide command Window' is unchecked (known bug #140)")
  433. # Layer 2: Permission
  434. admin = is_running_as_admin()
  435. result.add_check("permission", "Running as administrator",
  436. admin, "Admin privileges active" if admin else "Not running as admin (may cause FE module errors)")
  437. if not admin:
  438. result.add_warning("permission",
  439. "Fault case #5: 'Unable to run FE module' may be solved by running as administrator")
  440. # Check default install path
  441. default_path = r"C:\ANSYS_Motor-CAD"
  442. has_default = os.path.exists(default_path)
  443. result.add_check("permission", "Default install path exists",
  444. has_default, f"Path {default_path} exists" if has_default else f"Default path {default_path} not found (non-default install may cause issues)")
  445. # Layer 3: License
  446. license_ok, license_msg = check_license_server()
  447. result.add_check("license", "License server reachable", license_ok, license_msg)
  448. if not license_ok:
  449. result.add_warning("license",
  450. "Fault case #8: Check Ansys License Manager service, port 1055, license file validity, and concurrency count")
  451. # Layer 4: Model
  452. model_exists = os.path.exists(self.model_path)
  453. result.add_check("model", "Baseline model file exists",
  454. model_exists, f"Model at {self.model_path}" if model_exists else f"Model not found at {self.model_path}")
  455. if model_exists and self.mc:
  456. try:
  457. self.mc.load_from_file(self.model_path)
  458. result.add_check("model", "Model loads successfully", True, "Model loaded without error")
  459. except MotorCADError as e:
  460. result.add_check("model", "Model loads successfully", False, f"MotorCADError: {e}")
  461. except Exception as e:
  462. result.add_check("model", "Model loads successfully", False, str(e))
  463. result.add_warning("model",
  464. "If using adaptive geometry, call reset_adaptive_geometry() before modifications; ensure regions are closed (is_closed()) and counter-clockwise")
  465. # Layer 5: Script
  466. result.add_check("script", "MotorCADError import available",
  467. HAS_MOTORCAD_ERROR,
  468. "ansys.motorcad.core.MotorCADError imported" if HAS_MOTORCAD_ERROR else "MotorCADError not available (using generic Exception fallback)")
  469. result.add_check("script", "Variable name mapping configured",
  470. len(VARIABLE_NAME_MAP) > 0,
  471. f"{len(VARIABLE_NAME_MAP)} variables in mapping table")
  472. result.add_check("script", "Popup state will be restored on disconnect",
  473. True, "try/finally pattern ensures MessageDisplayState restoration")
  474. self._log(result.summary())
  475. return result
  476. def _check_connection_responsive(self) -> bool:
  477. """Check if Motor-CAD instance is responsive."""
  478. if not self.mc:
  479. return False
  480. try:
  481. _ = self.mc.get_variable("Motor_Type")
  482. return True
  483. except Exception:
  484. return False
  485. def run_single_point(self, params: Dict[str, Any], point_index: int = 0,
  486. point_label: str = "") -> Dict[str, Any]:
  487. """Run a single simulation point with full robustness protocol.
  488. Protocol:
  489. 1. Suppress popups (MessageDisplayState=2)
  490. 2. Reload baseline model
  491. 3. Check sampling/mesh compatibility
  492. 4. Write all parameters with write-back verification (version-resolved names)
  493. 5. Handle linked parameters (slot opening -> copper width)
  494. 6. Run magnetic calculation
  495. 7. Export and parse results
  496. 8. Write results to CSV and JSON (flush immediately)
  497. 9. Reload baseline again
  498. 10. Restore popup state (in finally)
  499. All Motor-CAD calls wrapped in try/except MotorCADError.
  500. """
  501. start_time = time.time()
  502. result = {
  503. "point_index": point_index,
  504. "point_label": point_label,
  505. "params": params,
  506. "status": "pending",
  507. "metrics": {},
  508. "error": None,
  509. "duration_s": 0,
  510. }
  511. # Suppress popups for batch execution
  512. self._suppress_popups()
  513. try:
  514. for attempt in range(self.max_retries):
  515. try:
  516. # Step 1: Reload baseline
  517. try:
  518. self.mc.load_from_file(self.model_path)
  519. except MotorCADError as e:
  520. raise RuntimeError(f"Baseline reload failed: {e}")
  521. # Step 2: Check sampling/mesh compatibility if present
  522. if "TorquePointsPerCycle" in params and "AirgapMeshPoints_mesh" in params:
  523. compatible, msg = check_sampling_mesh_compatibility(
  524. int(params["TorquePointsPerCycle"]),
  525. int(params["AirgapMeshPoints_mesh"])
  526. )
  527. if not compatible:
  528. self._log(f"WARNING: {msg}")
  529. result["error"] = msg
  530. result["status"] = "FAILED"
  531. # B5 fix: break instead of return so the point
  532. # is appended to results and written to disk
  533. break
  534. # Step 3: Write all parameters with verification
  535. for var, val in params.items():
  536. if var in ("point_index", "point_label"):
  537. continue
  538. self._write_and_verify(var, float(val))
  539. # Step 4: Handle linked parameters
  540. if "Slot_Opening" in params and "Copper_Width" not in params:
  541. copper_w = compute_copper_width(float(params["Slot_Opening"]))
  542. self._write_and_verify("Copper_Width", copper_w)
  543. # Step 5: Run magnetic calculation
  544. try:
  545. self.mc.do_magnetic_calculation()
  546. except MotorCADError as e:
  547. raise RuntimeError(f"Magnetic calculation failed: {e}")
  548. # Step 6: Export and parse
  549. raw_file = os.path.join(
  550. self.raw_dir,
  551. f"result_{point_index:04d}_{point_label or 'point'}_{datetime.now().strftime('%H%M%S')}.csv"
  552. )
  553. try:
  554. self.mc.export_results("EMagnetic", raw_file)
  555. except MotorCADError as e:
  556. raise RuntimeError(f"Results export failed: {e}")
  557. # Verify export file actually exists
  558. if not os.path.exists(raw_file):
  559. raise RuntimeError(f"Export file not created: {raw_file}")
  560. metrics = self._parse_export(raw_file)
  561. result["metrics"] = metrics
  562. result["status"] = "OK"
  563. break
  564. except MotorCADError as e:
  565. result["error"] = f"MotorCADError: {e}"
  566. self._log(f"Point {point_index} attempt {attempt+1} MotorCADError: {e}")
  567. if attempt < self.max_retries - 1:
  568. self._log(f"Retrying point {point_index}...")
  569. time.sleep(2)
  570. self._reconnect_if_needed()
  571. else:
  572. result["status"] = "FAILED"
  573. except Exception as e:
  574. result["error"] = f"{type(e).__name__}: {e}"
  575. self._log(f"Point {point_index} attempt {attempt+1} failed: {e}")
  576. if attempt < self.max_retries - 1:
  577. self._log(f"Retrying point {point_index}...")
  578. time.sleep(2)
  579. self._reconnect_if_needed()
  580. else:
  581. result["status"] = "FAILED"
  582. finally:
  583. # Restore popup state (CRITICAL: must happen even on error)
  584. self._restore_popup_state()
  585. # Reload baseline to leave clean state
  586. try:
  587. if self.mc:
  588. self.mc.load_from_file(self.model_path)
  589. except Exception:
  590. pass
  591. result["duration_s"] = round(time.time() - start_time, 2)
  592. self._all_results.append(result)
  593. self._write_result_to_disk(result)
  594. return result
  595. def _reconnect_if_needed(self) -> None:
  596. """Check if instance is responsive, reconnect if not."""
  597. if not self._check_connection_responsive():
  598. self._log("Instance unresponsive, reconnecting...")
  599. try:
  600. self.disconnect()
  601. except Exception:
  602. pass
  603. try:
  604. self.connect()
  605. self._suppress_popups()
  606. except Exception as e:
  607. self._log(f"Reconnection failed: {e}")
  608. def read_graph_data(self, graph_name: str, max_points: int = 10000) -> List[Tuple[float, float]]:
  609. """Read graph data using "out-of-bounds = end" idiom.
  610. Motor-CAD API only exposes the most recently displayed curve.
  611. Reading past the end throws MotorCADError, which we use as
  612. the sequence termination signal.
  613. Reference: Official doc section 3.1 point 2 - graph reading idiom.
  614. Args:
  615. graph_name: Name of the graph to read (check in Motor-CAD Help -> Graph Viewer)
  616. max_points: Safety limit to prevent infinite loops
  617. Returns:
  618. List of (x, y) data points
  619. """
  620. points: List[Tuple[float, float]] = []
  621. if not self.mc:
  622. return points
  623. try:
  624. i = 0
  625. while i < max_points:
  626. try:
  627. x = self.mc.get_magnetic_graph_point(graph_name, i)
  628. # get_magnetic_graph_point may return tuple or single value
  629. if isinstance(x, (list, tuple)):
  630. points.append((float(x[0]), float(x[1])))
  631. else:
  632. # Single value return - use index as x
  633. points.append((float(i), float(x)))
  634. i += 1
  635. except MotorCADError:
  636. # Out of bounds = end of data (official idiom)
  637. break
  638. except Exception:
  639. break
  640. except Exception as e:
  641. self._log(f"Graph reading error: {e}")
  642. return points
  643. def _parse_export(self, filepath: str) -> Dict[str, float]:
  644. """Parse Motor-CAD export CSV with bilingual field matching.
  645. Motor-CAD exports semicolon-separated CSV. Same metric may appear
  646. in multiple sections; prioritize E-Magnetics, then Drive, Losses, etc.
  647. Multi-encoding fallback (utf-8-sig, utf-8, gbk, latin-1).
  648. """
  649. metrics: Dict[str, float] = {}
  650. if not os.path.exists(filepath):
  651. return metrics
  652. content = None
  653. for encoding in ("utf-8-sig", "utf-8", "gbk", "latin-1"):
  654. try:
  655. with open(filepath, "r", encoding=encoding) as f:
  656. content = f.read()
  657. break
  658. except (UnicodeDecodeError, Exception):
  659. continue
  660. if content is None:
  661. return metrics
  662. lines = content.splitlines()
  663. for line in lines:
  664. if ";" not in line:
  665. continue
  666. parts = line.split(";")
  667. if len(parts) < 2:
  668. continue
  669. field_name = parts[0].strip()
  670. value = None
  671. for part in parts[1:]:
  672. part = part.strip()
  673. try:
  674. value = float(part.replace(",", "."))
  675. break
  676. except (ValueError, Exception):
  677. continue
  678. if value is None:
  679. continue
  680. for metric_def in METRIC_DEFINITIONS:
  681. if field_name in metric_def["aliases"]:
  682. if metric_def["key"] not in metrics:
  683. metrics[metric_def["key"]] = value
  684. break
  685. return metrics
  686. def _write_result_to_disk(self, result: Dict[str, Any]) -> None:
  687. """Write result to CSV and JSON immediately (flush + fsync)."""
  688. if not self._csv_header_written:
  689. header = ["point_index", "point_label", "status", "duration_s"]
  690. for md in METRIC_DEFINITIONS:
  691. header.append(md["key"])
  692. if result["params"]:
  693. for k in result["params"]:
  694. if k not in ("point_index", "point_label"):
  695. header.append(f"param_{k}")
  696. with open(self._csv_path, "w", newline="", encoding="utf-8") as f:
  697. writer = csv.writer(f, delimiter=";")
  698. writer.writerow(header)
  699. f.flush()
  700. os.fsync(f.fileno())
  701. self._csv_header_written = True
  702. row = [result["point_index"], result["point_label"],
  703. result["status"], result["duration_s"]]
  704. for md in METRIC_DEFINITIONS:
  705. row.append(result["metrics"].get(md["key"], ""))
  706. if result["params"]:
  707. for k, v in result["params"].items():
  708. if k not in ("point_index", "point_label"):
  709. row.append(v)
  710. with open(self._csv_path, "a", newline="", encoding="utf-8") as f:
  711. writer = csv.writer(f, delimiter=";")
  712. writer.writerow(row)
  713. f.flush()
  714. os.fsync(f.fileno())
  715. with open(self._json_path, "w", encoding="utf-8") as f:
  716. json.dump({"results": self._all_results}, f, ensure_ascii=False, indent=2)
  717. f.flush()
  718. os.fsync(f.fileno())
  719. def _log(self, message: str) -> None:
  720. """Write timestamped log message."""
  721. ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
  722. line = f"[{ts}] {message}\n"
  723. try:
  724. with open(self._log_path, "a", encoding="utf-8") as f:
  725. f.write(line)
  726. f.flush()
  727. except Exception:
  728. pass
  729. def get_summary(self) -> Dict[str, Any]:
  730. """Get run summary."""
  731. ok = [r for r in self._all_results if r["status"] == "OK"]
  732. failed = [r for r in self._all_results if r["status"] == "FAILED"]
  733. return {
  734. "total": len(self._all_results),
  735. "ok": len(ok),
  736. "failed": len(failed),
  737. "output_dir": self.output_dir,
  738. "csv_path": self._csv_path,
  739. "json_path": self._json_path,
  740. "log_path": self._log_path,
  741. "headless_mode": self.headless,
  742. }