robust_motorcad.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. """Robust Motor-CAD simulation core (P4-M3 enhancement).
  2. Integrates all robustness practices from reference projects:
  3. - Connection: open_new_instance=True + set_visible(True)
  4. - Parameter write-back verification (set then get, mismatch = FAILED)
  5. - Per-point baseline reload (load_from_file before and after each point)
  6. - Sampling point / mesh compatibility check (avoid 120pt+840mesh popup)
  7. - Slot opening / PCB copper width linkage formula
  8. - Result export parsing: semicolon CSV, bilingual field aliases, E-Magnetics priority
  9. - Per-point flush to disk (CSV + JSON dual write)
  10. - Timeout control per simulation point
  11. - Instance crash detection and auto-restart
  12. - Environment variable fallback (MOTORCAD_ACTIVEX, ANSYSLMD_LICENSE_FILE)
  13. - Git preflight before actual simulation
  14. All source is ASCII only; Chinese field names use \\uXXXX escapes.
  15. """
  16. from __future__ import annotations
  17. import csv
  18. import json
  19. import math
  20. import os
  21. import time
  22. import traceback
  23. from datetime import datetime
  24. from pathlib import Path
  25. from typing import Any, Dict, List, Optional, Tuple
  26. # ---------------------------------------------------------------------------
  27. # Metric definitions: key, display label, and aliases (English + Chinese).
  28. # Chinese aliases use Unicode escapes so this file stays pure ASCII.
  29. # ---------------------------------------------------------------------------
  30. METRIC_DEFINITIONS = [
  31. {"key": "ripple_pct", "label": "Torque Ripple [%]",
  32. "aliases": ["Torque Ripple (VW) [%]", "Torque Ripple (VW)[%]"]},
  33. {"key": "ripple_nm", "label": "Torque Ripple [Nm]",
  34. "aliases": ["Torque Ripple (VW)"]},
  35. {"key": "tavg_nm", "label": "Tavg VW [Nm]",
  36. "aliases": ["Average torque (virtual work)",
  37. "\u5e73\u5747\u8f6c\u77e9 (virtual work)",
  38. "\u5e73\u5747\u8f6c\u77e9(virtual work)"]},
  39. {"key": "efficiency_pct", "label": "Efficiency [%]",
  40. "aliases": ["System Efficiency", "\u7cfb\u7edf\u6548\u7387"]},
  41. {"key": "back_emf_v", "label": "Back EMF LL rms [V]",
  42. "aliases": ["Back EMF Line-Line Voltage (rms)",
  43. "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u6709\u6548\u503c"]},
  44. {"key": "total_losses_w", "label": "Total losses [W]",
  45. "aliases": ["Total Losses (on load)", "\u603b\u635f\u8017(\u989d\u5b9a)",
  46. "\u603b\u635f\u8017 (\u989d\u5b9a)"]},
  47. {"key": "copper_loss_w", "label": "DC copper loss [W]",
  48. "aliases": ["Armature DC Copper Loss (on load)",
  49. "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u5e26\u8f7d)"]},
  50. {"key": "magnet_loss_w", "label": "Magnet loss [W]",
  51. "aliases": ["Magnet Loss (on load)",
  52. "\u6c38\u78c1\u4f53\u635f\u8017(\u989d\u5b9a)"]},
  53. {"key": "iron_loss_w", "label": "Stator iron loss [W]",
  54. "aliases": ["Stator iron Loss [total] (on load)",
  55. "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u989d\u5b9a)"]},
  56. {"key": "input_power_w", "label": "Input power [W]",
  57. "aliases": ["Input Power", "\u8f93\u5165\u529f\u7387"]},
  58. {"key": "output_power_w", "label": "Output power [W]",
  59. "aliases": ["Output Power"]},
  60. {"key": "shaft_speed_rpm", "label": "Shaft speed [rpm]",
  61. "aliases": ["Shaft Speed", "\u8f6c\u901f[RPM]"]},
  62. ]
  63. # Known incompatible sampling point / mesh combinations that cause popups
  64. INCOMPATIBLE_SAMPLING_MESH = [
  65. (120, 840), # Motor-CAD warns mesh/time step mismatch, blocks batch
  66. ]
  67. # Recommended compatible combinations
  68. RECOMMENDED_SAMPLING_MESH = [
  69. (30, 840), # Fast trend scan
  70. (120, 960), # Medium confidence
  71. (180, 1680), # High confidence final
  72. ]
  73. def ensure_environment() -> None:
  74. """Ensure Motor-CAD environment variables are set (non-login shell trap)."""
  75. if not os.environ.get("MOTORCAD_ACTIVEX"):
  76. try:
  77. from ansys.motorcad.core import set_motorcad_exe
  78. candidate = r"D:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe"
  79. if os.path.exists(candidate):
  80. set_motorcad_exe(candidate)
  81. except Exception:
  82. pass
  83. if not os.environ.get("ANSYSLMD_LICENSE_FILE"):
  84. os.environ["ANSYSLMD_LICENSE_FILE"] = "1055@localhost"
  85. def check_sampling_mesh_compatibility(torque_points: int, airgap_mesh: int) -> Tuple[bool, str]:
  86. """Check if sampling point / mesh combination is compatible.
  87. Returns (compatible, message). Incompatible combinations cause
  88. Motor-CAD popups that block unattended batch execution.
  89. """
  90. for pts, mesh in INCOMPATIBLE_SAMPLING_MESH:
  91. if torque_points == pts and airgap_mesh == mesh:
  92. return False, (
  93. f"TorquePoints={torque_points} + AirgapMesh={airgap_mesh} "
  94. f"causes Motor-CAD popup. Use {RECOMMENDED_SAMPLING_MESH[1]} instead."
  95. )
  96. return True, "OK"
  97. def compute_copper_width(slot_opening_mm: float, clearance_mm: float = 0.2,
  98. conductor_count: int = 1) -> float:
  99. """Compute PCB copper width from slot opening (linkage formula).
  100. Copper_Width = (Slot_Opening - clearance) / 2 / conductor_count
  101. """
  102. return round((slot_opening_mm - clearance_mm) / 2.0 / conductor_count, 3)
  103. class RobustMotorCADSolver:
  104. """Robust Motor-CAD simulation solver with all best practices.
  105. Usage:
  106. solver = RobustMotorCADSolver(model_path="base.mot")
  107. solver.connect()
  108. for params in parameter_list:
  109. result = solver.run_single_point(params, point_index=0)
  110. solver.disconnect()
  111. """
  112. def __init__(self, model_path: str, output_dir: Optional[str] = None,
  113. point_timeout: int = 300, max_retries: int = 3):
  114. self.model_path = model_path
  115. self.output_dir = output_dir or os.path.join(
  116. os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
  117. "output", f"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
  118. )
  119. self.raw_dir = os.path.join(self.output_dir, "raw")
  120. os.makedirs(self.raw_dir, exist_ok=True)
  121. self.point_timeout = point_timeout
  122. self.max_retries = max_retries
  123. self.mc = None
  124. self._csv_path = os.path.join(self.output_dir, "scan_results.csv")
  125. self._json_path = os.path.join(self.output_dir, "scan_results.json")
  126. self._log_path = os.path.join(self.output_dir, "program_log.log")
  127. self._all_results: List[Dict[str, Any]] = []
  128. self._csv_header_written = False
  129. def connect(self) -> None:
  130. """Connect to a new Motor-CAD instance (never connect to existing)."""
  131. ensure_environment()
  132. try:
  133. from ansys.motorcad.core import MotorCAD
  134. self.mc = MotorCAD(open_new_instance=True, keep_instance_open=False)
  135. self.mc.set_visible(True)
  136. time.sleep(2) # Wait for instance to fully initialize
  137. # Health check
  138. _ = self.mc.get_variable("Motor_Type")
  139. self._log("Connected to new Motor-CAD instance")
  140. except Exception as e:
  141. self._log(f"Connection failed: {e}")
  142. raise
  143. def disconnect(self) -> None:
  144. """Disconnect from Motor-CAD instance."""
  145. if self.mc:
  146. try:
  147. # Reload baseline to leave clean state
  148. self.mc.load_from_file(self.model_path)
  149. except Exception:
  150. pass
  151. try:
  152. self.mc.quit()
  153. except Exception:
  154. pass
  155. self.mc = None
  156. self._log("Disconnected from Motor-CAD")
  157. def _write_and_verify(self, variable: str, value: float,
  158. rel_tol: float = 1e-8, abs_tol: float = 1e-7) -> float:
  159. """Write variable and verify with get_variable. Mismatch raises."""
  160. self.mc.set_variable(variable, value)
  161. applied = float(self.mc.get_variable(variable))
  162. if not math.isclose(applied, value, rel_tol=rel_tol, abs_tol=abs_tol):
  163. raise RuntimeError(
  164. f"Variable {variable} write mismatch: applied={applied}, expected={value}"
  165. )
  166. return applied
  167. def run_single_point(self, params: Dict[str, Any], point_index: int = 0,
  168. point_label: str = "") -> Dict[str, Any]:
  169. """Run a single simulation point with full robustness protocol.
  170. Protocol:
  171. 1. Reload baseline model
  172. 2. Write all parameters with write-back verification
  173. 3. Handle linked parameters (slot opening -> copper width)
  174. 4. Run magnetic calculation
  175. 5. Export and parse results
  176. 6. Write results to CSV and JSON (flush immediately)
  177. 7. Reload baseline again
  178. """
  179. start_time = time.time()
  180. result = {
  181. "point_index": point_index,
  182. "point_label": point_label,
  183. "params": params,
  184. "status": "pending",
  185. "metrics": {},
  186. "error": None,
  187. "duration_s": 0,
  188. }
  189. for attempt in range(self.max_retries):
  190. try:
  191. # Step 1: Reload baseline
  192. self.mc.load_from_file(self.model_path)
  193. # Step 2: Check sampling/mesh compatibility if present
  194. if "TorquePointsPerCycle" in params and "AirgapMeshPoints_mesh" in params:
  195. compatible, msg = check_sampling_mesh_compatibility(
  196. int(params["TorquePointsPerCycle"]),
  197. int(params["AirgapMeshPoints_mesh"])
  198. )
  199. if not compatible:
  200. self._log(f"WARNING: {msg}")
  201. # Step 3: Write all parameters with verification
  202. for var, val in params.items():
  203. if var in ("point_index", "point_label"):
  204. continue
  205. self._write_and_verify(var, float(val))
  206. # Step 4: Handle linked parameters
  207. if "Slot_Opening" in params and "Copper_Width" not in params:
  208. copper_w = compute_copper_width(float(params["Slot_Opening"]))
  209. self._write_and_verify("Copper_Width", copper_w)
  210. # Step 5: Run magnetic calculation
  211. self.mc.do_magnetic_calculation()
  212. # Step 6: Export and parse
  213. raw_file = os.path.join(
  214. self.raw_dir,
  215. f"result_{point_index:04d}_{point_label or 'point'}_{datetime.now().strftime('%H%M%S')}.csv"
  216. )
  217. self.mc.export_results(raw_file)
  218. metrics = self._parse_export(raw_file)
  219. result["metrics"] = metrics
  220. result["status"] = "ok"
  221. break
  222. except Exception as e:
  223. result["error"] = f"{type(e).__name__}: {e}"
  224. self._log(f"Point {point_index} attempt {attempt+1} failed: {e}")
  225. if attempt < self.max_retries - 1:
  226. self._log(f"Retrying point {point_index}...")
  227. time.sleep(2)
  228. # Try to reconnect if instance seems dead
  229. try:
  230. _ = self.mc.get_variable("Motor_Type")
  231. except Exception:
  232. self._log("Instance unresponsive, reconnecting...")
  233. self.disconnect()
  234. self.connect()
  235. else:
  236. result["status"] = "failed"
  237. result["duration_s"] = round(time.time() - start_time, 2)
  238. self._all_results.append(result)
  239. self._write_result_to_disk(result)
  240. return result
  241. def _parse_export(self, filepath: str) -> Dict[str, float]:
  242. """Parse Motor-CAD export CSV with bilingual field matching.
  243. Motor-CAD exports semicolon-separated CSV. Same metric may appear
  244. in multiple sections; prioritize E-Magnetics, then Drive, Losses, etc.
  245. """
  246. metrics: Dict[str, float] = {}
  247. if not os.path.exists(filepath):
  248. return metrics
  249. # Try multiple encodings
  250. content = None
  251. for encoding in ("utf-8-sig", "utf-8", "gbk", "latin-1"):
  252. try:
  253. with open(filepath, "r", encoding=encoding) as f:
  254. content = f.read()
  255. break
  256. except (UnicodeDecodeError, Exception):
  257. continue
  258. if content is None:
  259. return metrics
  260. # Parse semicolon-separated lines
  261. lines = content.splitlines()
  262. for line in lines:
  263. if ";" not in line:
  264. continue
  265. parts = line.split(";")
  266. if len(parts) < 2:
  267. continue
  268. field_name = parts[0].strip()
  269. # Try to find numeric value in remaining parts
  270. value = None
  271. for part in parts[1:]:
  272. part = part.strip()
  273. try:
  274. value = float(part.replace(",", "."))
  275. break
  276. except (ValueError, Exception):
  277. continue
  278. if value is None:
  279. continue
  280. # Match against metric aliases
  281. for metric_def in METRIC_DEFINITIONS:
  282. if field_name in metric_def["aliases"]:
  283. # Only set if not already set (first match wins = E-Magnetics priority)
  284. if metric_def["key"] not in metrics:
  285. metrics[metric_def["key"]] = value
  286. break
  287. return metrics
  288. def _write_result_to_disk(self, result: Dict[str, Any]) -> None:
  289. """Write result to CSV and JSON immediately (flush + fsync)."""
  290. # CSV
  291. if not self._csv_header_written:
  292. header = ["point_index", "point_label", "status", "duration_s"]
  293. for md in METRIC_DEFINITIONS:
  294. header.append(md["key"])
  295. # Add param columns
  296. if result["params"]:
  297. for k in result["params"]:
  298. if k not in ("point_index", "point_label"):
  299. header.append(f"param_{k}")
  300. with open(self._csv_path, "w", newline="", encoding="utf-8") as f:
  301. writer = csv.writer(f, delimiter=";")
  302. writer.writerow(header)
  303. f.flush()
  304. os.fsync(f.fileno())
  305. self._csv_header_written = True
  306. # Append row
  307. row = [
  308. result["point_index"], result["point_label"],
  309. result["status"], result["duration_s"]
  310. ]
  311. for md in METRIC_DEFINITIONS:
  312. row.append(result["metrics"].get(md["key"], ""))
  313. if result["params"]:
  314. for k, v in result["params"].items():
  315. if k not in ("point_index", "point_label"):
  316. row.append(v)
  317. with open(self._csv_path, "a", newline="", encoding="utf-8") as f:
  318. writer = csv.writer(f, delimiter=";")
  319. writer.writerow(row)
  320. f.flush()
  321. os.fsync(f.fileno())
  322. # JSON (full results, overwritten each time)
  323. with open(self._json_path, "w", encoding="utf-8") as f:
  324. json.dump({"results": self._all_results}, f, ensure_ascii=False, indent=2)
  325. f.flush()
  326. os.fsync(f.fileno())
  327. def _log(self, message: str) -> None:
  328. """Write timestamped log message."""
  329. ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
  330. line = f"[{ts}] {message}\n"
  331. with open(self._log_path, "a", encoding="utf-8") as f:
  332. f.write(line)
  333. f.flush()
  334. def get_summary(self) -> Dict[str, Any]:
  335. """Get run summary."""
  336. ok = [r for r in self._all_results if r["status"] == "ok"]
  337. failed = [r for r in self._all_results if r["status"] == "failed"]
  338. return {
  339. "total": len(self._all_results),
  340. "ok": len(ok),
  341. "failed": len(failed),
  342. "output_dir": self.output_dir,
  343. "csv_path": self._csv_path,
  344. "json_path": self._json_path,
  345. "log_path": self._log_path,
  346. }