motorcad.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. """Motor-CAD adapter - concrete SimulationAdapter implementation.
  2. Wraps RobustMotorCADSolver (scripts/robust_motorcad.py) behind the uniform
  3. SimulationAdapter protocol so the executor / GUI / future strategies never
  4. depend on Motor-CAD specifics.
  5. Registering this module (importing it) makes the "motorcad" tool available
  6. through afmcore.adapters.get_adapter("motorcad", ...).
  7. To add a new tool (e.g. Maxwell): create a sibling module implementing the
  8. same protocol and register it under its own tool name. Nothing in the
  9. executor changes.
  10. All source is ASCII only.
  11. """
  12. from __future__ import annotations
  13. from typing import Any, Dict, Optional
  14. from . import SimulationAdapter, register_adapter
  15. class MotorCADAdapter(SimulationAdapter):
  16. """Adapter over RobustMotorCADSolver (reuses its full robustness
  17. protocol: write-back verification, baseline reload, popup suppression,
  18. per-point retry/reconnect, per-point disk write)."""
  19. tool_name = "motorcad"
  20. tool_label = "Motor-CAD (ANSYS)"
  21. capability_domains = ("electromagnetic",)
  22. def __init__(
  23. self,
  24. model_path: Optional[str] = None,
  25. output_dir: Optional[str] = None,
  26. point_timeout: int = 300,
  27. max_retries: int = 3,
  28. headless: bool = False,
  29. enable_thermal: bool = False,
  30. ambient_temperature: Optional[float] = None,
  31. log_cb=None,
  32. progress_cb=None,
  33. **kwargs: Any,
  34. ):
  35. super().__init__(log_cb=log_cb, progress_cb=progress_cb, **kwargs)
  36. self.model_path = model_path
  37. self.output_dir = output_dir
  38. self.point_timeout = point_timeout
  39. self.max_retries = max_retries
  40. self.headless = headless
  41. # P5-M6: after each EM solve, also run a steady-state thermal solve
  42. # and merge thermal metrics (OFF by default, preserves EM-only flow).
  43. self.enable_thermal = bool(enable_thermal)
  44. # P5-M6 thermal boundary: Ambient_Temperature override (degC); None =
  45. # leave model value. MARS ships 125 C, so pass 25-40 for valid results.
  46. self.ambient_temperature = ambient_temperature
  47. self._solver = None # lazy RobustMotorCADSolver
  48. # -- internal ----------------------------------------------------------
  49. def _ensure_solver(self):
  50. """Lazily create the wrapped RobustMotorCADSolver instance."""
  51. if self._solver is None:
  52. import os
  53. import sys
  54. # scripts/robust_motorcad.py is imported via the `scripts` package,
  55. # which requires the repo root on sys.path. Executor runs may only
  56. # have scripts/ and src/ on sys.path, so ensure the repo root is
  57. # present or `from scripts.robust_motorcad import ...` fails with
  58. # "No module named 'scripts'".
  59. _root = os.path.dirname(
  60. os.path.dirname(
  61. os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  62. )
  63. )
  64. if _root not in sys.path:
  65. sys.path.insert(0, _root)
  66. from scripts.robust_motorcad import RobustMotorCADSolver # type: ignore
  67. self._solver = RobustMotorCADSolver(
  68. model_path=self.model_path or "",
  69. output_dir=self.output_dir,
  70. point_timeout=self.point_timeout,
  71. max_retries=self.max_retries,
  72. headless=self.headless,
  73. enable_thermal=self.enable_thermal,
  74. ambient_temperature=self.ambient_temperature,
  75. )
  76. return self._solver
  77. # -- lifecycle ---------------------------------------------------------
  78. def connect(self) -> None:
  79. self._ensure_solver().connect()
  80. self._log("Motor-CAD connected (MotorCADAdapter)")
  81. def disconnect(self) -> None:
  82. if self._solver is not None:
  83. try:
  84. self._solver.disconnect()
  85. finally:
  86. self._solver = None
  87. # -- model & parameters ------------------------------------------------
  88. def load_model(self, model_path: str) -> None:
  89. # RobustMotorCADSolver reloads the baseline before every point, so we
  90. # only record the path here.
  91. self.model_path = model_path
  92. self._log("Model path set: %s" % model_path)
  93. def set_parameter(self, name: str, value: float) -> None:
  94. # Delegates to the robust write-back verification.
  95. self._ensure_solver()._write_and_verify(name, float(value))
  96. def run_simulation(self, mode: str = "electromagnetic") -> None:
  97. solver = self._ensure_solver()
  98. if solver.mc is None:
  99. solver.connect()
  100. if mode == "electromagnetic":
  101. solver.mc.do_magnetic_calculation()
  102. else:
  103. raise ValueError(
  104. "MotorCADAdapter currently supports mode='electromagnetic' only"
  105. )
  106. # -- results -----------------------------------------------------------
  107. def extract_metrics(self, output_dir: str, tag: str = "") -> Dict[str, Any]:
  108. """Extract metrics from the most recent raw export written by the
  109. wrapped solver (it manages its own raw/ directory)."""
  110. solver = self._ensure_solver()
  111. metrics: Dict[str, float] = {}
  112. raw_path = ""
  113. error = ""
  114. status = "OK"
  115. try:
  116. import os
  117. raw_dir = os.path.join(solver.output_dir, "raw")
  118. if os.path.isdir(raw_dir):
  119. files = sorted(
  120. os.path.join(raw_dir, f) for f in os.listdir(raw_dir)
  121. )
  122. if files:
  123. raw_path = files[-1]
  124. metrics = solver._parse_export(raw_path)
  125. if not metrics:
  126. status = "UNCERTAIN"
  127. error = "No metrics extracted from latest raw export"
  128. except Exception as exc: # noqa: BLE001
  129. status = "FAILED"
  130. error = f"{type(exc).__name__}: {exc}"
  131. return {"metrics": metrics, "raw_path": raw_path, "status": status, "error": error}
  132. # -- high-level ---------------------------------------------------------
  133. def run_point(
  134. self,
  135. model_path: str,
  136. params: Optional[Dict[str, float]] = None,
  137. output_dir: str = "output",
  138. tag: str = "",
  139. thermal_mode: Optional[str] = None,
  140. ) -> Dict[str, Any]:
  141. """Run one point through the full robust protocol and map the result
  142. to the uniform adapter schema.
  143. thermal_mode: per-task override ("off"/"steady"/"coupled"). None uses
  144. the solver default (self.thermal_mode or the legacy enable_thermal).
  145. """
  146. import time
  147. started = time.time()
  148. solver = self._ensure_solver()
  149. # run_point is called with an explicit model_path; the solver is
  150. # created lazily with a possibly-empty default, so sync it here
  151. # or load_from_file would target the wrong (empty) path.
  152. if model_path:
  153. solver.model_path = model_path
  154. if solver.mc is None:
  155. try:
  156. solver.connect()
  157. except Exception as exc: # noqa: BLE001
  158. return {
  159. "metrics": {},
  160. "status": "FAILED",
  161. "error": f"{type(exc).__name__}: {exc}",
  162. "raw_path": "",
  163. "solve_time_s": round(time.time() - started, 1),
  164. "params": params or {},
  165. }
  166. result = solver.run_single_point(
  167. params or {}, point_index=0, thermal_mode=thermal_mode
  168. )
  169. return {
  170. "metrics": result.get("metrics", {}),
  171. "status": result.get("status", "FAILED"),
  172. "error": result.get("error"),
  173. "raw_path": "",
  174. "solve_time_s": result.get("duration_s", round(time.time() - started, 1)),
  175. "params": params or {},
  176. }
  177. # Register the adapter so get_adapter("motorcad", ...) works as soon as this
  178. # module is imported (the executor imports it at startup).
  179. register_adapter(MotorCADAdapter.tool_name, MotorCADAdapter)