solver.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. """Motor-CAD parameter scan solver core.
  2. Pure computation module, no GUI dependencies.
  3. Handles Motor-CAD connection, model reload, parameter write-back
  4. verification, magnetic calculation, result export parsing, and
  5. metric extraction with bilingual (English/Chinese) field matching.
  6. All source is ASCII; Chinese field names use \\uXXXX escapes.
  7. """
  8. from __future__ import annotations
  9. import csv
  10. import json
  11. import math
  12. import os
  13. import subprocess
  14. import time
  15. import traceback
  16. from datetime import datetime
  17. from pathlib import Path
  18. # ---------------------------------------------------------------------------
  19. # Metric definitions: key, display label, and aliases (English + Chinese).
  20. # Chinese aliases use Unicode escapes so this file stays pure ASCII.
  21. # ---------------------------------------------------------------------------
  22. METRIC_DEFINITIONS = [
  23. {
  24. "key": "ripple_pct",
  25. "label": "Torque Ripple [%]",
  26. "aliases": [
  27. "Torque Ripple (VW) [%]",
  28. "Torque Ripple (VW)[%]",
  29. ],
  30. },
  31. {
  32. "key": "ripple_nm",
  33. "label": "Torque Ripple [Nm]",
  34. "aliases": [
  35. "Torque Ripple (VW)",
  36. ],
  37. },
  38. {
  39. "key": "tavg_nm",
  40. "label": "Tavg VW [Nm]",
  41. "aliases": [
  42. "Average torque (virtual work)",
  43. "\u5e73\u5747\u8f6c\u77e9 (virtual work)",
  44. "\u5e73\u5747\u8f6c\u77e9(virtual work)",
  45. ],
  46. },
  47. {
  48. "key": "efficiency_pct",
  49. "label": "Efficiency [%]",
  50. "aliases": [
  51. "System Efficiency",
  52. "\u7cfb\u7edf\u6548\u7387",
  53. ],
  54. },
  55. {
  56. "key": "back_emf_v",
  57. "label": "Back EMF LL rms [V]",
  58. "aliases": [
  59. "Back EMF Line-Line Voltage (rms)",
  60. "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u6709\u6548\u503c",
  61. ],
  62. },
  63. {
  64. "key": "back_emf_thd_pct",
  65. "label": "Back EMF THD [%]",
  66. "aliases": [
  67. "Harmonic Distortion Back EMF Line-Line Voltage",
  68. "\u7ebf\u53cd\u5411\u7535\u52a8\u52bf\u8c10\u6ce2",
  69. "\u7ebf\u7535\u538b\u8c10\u6ce2",
  70. ],
  71. },
  72. {
  73. "key": "total_losses_w",
  74. "label": "Total losses [W]",
  75. "aliases": [
  76. "Total Losses (on load)",
  77. "\u603b\u635f\u8017(\u989d\u5b9a)",
  78. "\u603b\u635f\u8017 (\u989d\u5b9a)",
  79. ],
  80. },
  81. {
  82. "key": "copper_loss_w",
  83. "label": "DC copper loss [W]",
  84. "aliases": [
  85. "Armature DC Copper Loss (on load)",
  86. "\u7535\u67a2\u76f4\u6d41\u94dc\u8017 (\u5e26\u8f7d)",
  87. "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u5e26\u8f7d)",
  88. ],
  89. },
  90. {
  91. "key": "magnet_loss_w",
  92. "label": "Magnet loss [W]",
  93. "aliases": [
  94. "Magnet Loss (on load)",
  95. "\u6c38\u78c1\u4f53\u635f\u8017(\u989d\u5b9a)",
  96. "\u6c38\u78c1\u4f53\u635f\u8017 (\u989d\u5b9a)",
  97. ],
  98. },
  99. {
  100. "key": "iron_loss_w",
  101. "label": "Stator iron loss [W]",
  102. "aliases": [
  103. "Stator iron Loss [total] (on load)",
  104. "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u989d\u5b9a)",
  105. "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017] (\u989d\u5b9a)",
  106. ],
  107. },
  108. {
  109. "key": "input_power_w",
  110. "label": "Input power [W]",
  111. "aliases": [
  112. "Input Power",
  113. "\u8f93\u5165\u529f\u7387",
  114. ],
  115. },
  116. {
  117. "key": "output_power_w",
  118. "label": "Output power [W]",
  119. "aliases": [
  120. "Output Power",
  121. "\u8f93\u51fa\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9",
  122. ],
  123. },
  124. {
  125. "key": "em_power_w",
  126. "label": "EM power [W]",
  127. "aliases": [
  128. "Electromagnetic Power",
  129. "\u7535\u78c1\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9",
  130. ],
  131. },
  132. {
  133. "key": "shaft_speed_rpm",
  134. "label": "Shaft speed [rpm]",
  135. "aliases": [
  136. "Shaft Speed",
  137. "\u8f6c\u901f[RPM]",
  138. "\u8f6c\u901f [RPM]",
  139. ],
  140. },
  141. {
  142. "key": "no_load_speed_rpm",
  143. "label": "No-load speed [rpm]",
  144. "aliases": [
  145. "No load speed",
  146. "\u7a7a\u8f7d\u8f6c\u901f",
  147. ],
  148. },
  149. {
  150. "key": "shaft_torque_nm",
  151. "label": "Shaft torque [Nm]",
  152. "aliases": [
  153. "Shaft Torque",
  154. "\u8f74\u8f6c\u77e9",
  155. ],
  156. },
  157. ]
  158. METRIC_KEYS = [m["key"] for m in METRIC_DEFINITIONS]
  159. METRIC_LABELS = {m["key"]: m["label"] for m in METRIC_DEFINITIONS}
  160. # Section name aliases for priority ordering.
  161. SECTION_PRIORITY = [
  162. "E-Magnetics",
  163. "\u7535\u78c1",
  164. "Drive",
  165. "\u9a71\u52a8",
  166. "Losses",
  167. "\u635f\u8017",
  168. "Materials",
  169. "\u6750\u6599",
  170. "Miscellaneous",
  171. "\u6742\u9879",
  172. ]
  173. # ---------------------------------------------------------------------------
  174. # Utility functions
  175. # ---------------------------------------------------------------------------
  176. def values_inclusive(start: float, stop: float, step: float) -> list[float]:
  177. """Generate values from start to stop inclusive, appending stop if
  178. it is not exactly reachable by integer steps."""
  179. if step <= 0 or stop < start:
  180. raise ValueError("step must be positive and stop must be >= start")
  181. count = int(math.floor((stop - start) / step + 1e-9))
  182. values = [round(start + i * step, 10) for i in range(count + 1)]
  183. if not math.isclose(values[-1], stop, abs_tol=1e-9):
  184. values.append(float(stop))
  185. return values
  186. def _normalize_name(name: str) -> str:
  187. """Normalize a field name for matching: unify brackets, remove
  188. whitespace, lowercase."""
  189. s = name
  190. # Full-width brackets to half-width.
  191. s = s.replace("\uff08", "(").replace("\uff09", ")")
  192. # Remove all whitespace.
  193. s = "".join(s.split())
  194. return s.lower()
  195. # Pre-normalize aliases for fast matching.
  196. _METRIC_ALIAS_MAP: dict[str, str] = {}
  197. for _m in METRIC_DEFINITIONS:
  198. for _alias in _m["aliases"]:
  199. _METRIC_ALIAS_MAP[_normalize_name(_alias)] = _m["key"]
  200. def parse_export(path: Path) -> dict[str, dict[str, float]]:
  201. """Parse a Motor-CAD semicolon-delimited export file.
  202. Returns a dict of section_name -> {field_name: value}.
  203. Handles UTF-8, cp1252, gbk, and latin-1 encodings.
  204. """
  205. text = None
  206. for encoding in ("utf-8-sig", "gbk", "cp1252", "latin-1"):
  207. try:
  208. text = path.read_text(encoding=encoding)
  209. break
  210. except UnicodeDecodeError:
  211. continue
  212. if text is None:
  213. return {}
  214. result: dict[str, dict[str, float]] = {}
  215. section = "(root)"
  216. for raw in text.splitlines():
  217. line = raw.strip()
  218. if not line:
  219. continue
  220. if ";" not in line:
  221. section = line
  222. result.setdefault(section, {})
  223. continue
  224. parts = line.split(";")
  225. try:
  226. field = parts[0].strip().strip('"')
  227. value = float(parts[1])
  228. result.setdefault(section, {})[field] = value
  229. except (IndexError, ValueError):
  230. continue
  231. return result
  232. def pick_metric(results: dict[str, dict[str, float]], metric_key: str):
  233. """Extract a metric value from parsed export results.
  234. Tries exact alias match first (section priority order), then
  235. falls back to prefix-based fuzzy match across all sections.
  236. Returns the value (float) or "" if not found.
  237. """
  238. # Build the set of normalized aliases for this metric.
  239. wanted_aliases = set()
  240. for m in METRIC_DEFINITIONS:
  241. if m["key"] == metric_key:
  242. for alias in m["aliases"]:
  243. wanted_aliases.add(_normalize_name(alias))
  244. break
  245. if not wanted_aliases:
  246. return ""
  247. # Phase 1: exact match in priority section order.
  248. for section_name in SECTION_PRIORITY:
  249. section = results.get(section_name)
  250. if section is None:
  251. continue
  252. for field, value in section.items():
  253. if _normalize_name(field) in wanted_aliases:
  254. return value
  255. # Phase 2: exact match across all sections.
  256. for section in results.values():
  257. for field, value in section.items():
  258. if _normalize_name(field) in wanted_aliases:
  259. return value
  260. # Phase 3: prefix fuzzy match.
  261. for alias_norm in wanted_aliases:
  262. for section in results.values():
  263. for field, value in section.items():
  264. field_norm = _normalize_name(field)
  265. if field_norm.startswith(alias_norm) or alias_norm.startswith(field_norm):
  266. if len(field_norm) > 3: # avoid trivial matches
  267. return value
  268. return ""
  269. def extract_all_metrics(results: dict[str, dict[str, float]]) -> dict[str, float]:
  270. """Extract all defined metrics from parsed results."""
  271. out: dict[str, float] = {}
  272. for m in METRIC_DEFINITIONS:
  273. val = pick_metric(results, m["key"])
  274. if val != "":
  275. out[m["key"]] = val
  276. return out
  277. # ---------------------------------------------------------------------------
  278. # Git preflight
  279. # ---------------------------------------------------------------------------
  280. def find_repo(start: Path) -> Path | None:
  281. """Walk up from start to find a directory containing .git."""
  282. for candidate in (start, *start.parents):
  283. if (candidate / ".git").exists():
  284. return candidate
  285. return None
  286. def git_preflight(script_dir: Path) -> tuple[bool, str]:
  287. """Check that a git repo exists, HEAD is valid, and tracked files
  288. are clean. Returns (ok, commit_or_error)."""
  289. repo = find_repo(script_dir)
  290. if repo is None:
  291. return False, "No Git repository found."
  292. safe = f"safe.directory={repo.as_posix()}"
  293. base = ["git", "-c", safe]
  294. try:
  295. commit = subprocess.check_output(
  296. base + ["rev-parse", "--short", "HEAD"],
  297. cwd=repo, text=True, stderr=subprocess.STDOUT,
  298. ).strip()
  299. dirty = subprocess.check_output(
  300. base + ["status", "--porcelain", "--untracked-files=no"],
  301. cwd=repo, text=True, stderr=subprocess.STDOUT,
  302. ).strip()
  303. except (OSError, subprocess.CalledProcessError) as exc:
  304. return False, f"Git preflight failed: {exc}"
  305. if dirty:
  306. return False, "Tracked files have uncommitted changes. Commit before starting a scan."
  307. return True, commit
  308. # ---------------------------------------------------------------------------
  309. # Motor-CAD solver
  310. # ---------------------------------------------------------------------------
  311. class MotorCADSolver:
  312. """Manages a dedicated, foreground-visible Motor-CAD instance and
  313. runs parameter scans with per-point baseline reload, write-back
  314. verification, and immediate CSV/log persistence.
  315. No GUI dependencies. Callbacks (log_cb, progress_cb, row_cb) allow
  316. the caller to receive events.
  317. """
  318. def __init__(
  319. self,
  320. model_path: Path,
  321. log_cb=None,
  322. progress_cb=None,
  323. row_cb=None,
  324. ):
  325. self.model_path = Path(model_path).resolve()
  326. self.log_cb = log_cb
  327. self.progress_cb = progress_cb
  328. self.row_cb = row_cb
  329. self.mc = None
  330. self._cancel = False
  331. def _log(self, text: str) -> None:
  332. if self.log_cb:
  333. self.log_cb(text)
  334. def cancel(self) -> None:
  335. """Request cancellation after the current point finishes."""
  336. self._cancel = True
  337. def connect(self) -> None:
  338. """Open a dedicated, foreground-visible Motor-CAD instance.
  339. Falls back to set_motorcad_exe if MOTORCAD_ACTIVEX is not set.
  340. """
  341. import ansys.motorcad.core as pymotorcad
  342. if not os.environ.get("MOTORCAD_ACTIVEX"):
  343. try:
  344. from ansys.motorcad.core import set_motorcad_exe
  345. candidate = r"D:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe"
  346. if os.path.exists(candidate):
  347. set_motorcad_exe(candidate)
  348. self._log(f"MOTORCAD_ACTIVEX not set; using {candidate}")
  349. except (ImportError, Exception):
  350. pass
  351. self._log("Opening a separate, visible Motor-CAD instance")
  352. self.mc = pymotorcad.MotorCAD(open_new_instance=True, keep_instance_open=False)
  353. self.mc.set_visible(True)
  354. self.mc.set_variable("MessageDisplayState", 2)
  355. self.mc.display_screen("Scripting")
  356. def disconnect(self) -> None:
  357. """Reload the baseline model and close the instance."""
  358. if self.mc is not None:
  359. try:
  360. self.mc.load_from_file(str(self.model_path))
  361. except Exception:
  362. pass
  363. try:
  364. self.mc.quit()
  365. except Exception:
  366. pass
  367. self.mc = None
  368. def _write_and_verify(self, variable: str, value: float) -> None:
  369. """Write a variable and read it back. Raises RuntimeError on
  370. mismatch."""
  371. self.mc.set_variable(variable, value)
  372. applied = float(self.mc.get_variable(variable))
  373. if not math.isclose(applied, value, rel_tol=1e-8, abs_tol=1e-7):
  374. raise RuntimeError(
  375. f"Write verification failed for {variable}: "
  376. f"wrote {value}, read {applied}"
  377. )
  378. def run_single_point(
  379. self,
  380. index: int,
  381. total: int,
  382. writes: list[tuple[str, float]],
  383. torque_points: int = 0,
  384. airgap_mesh: int = 0,
  385. raw_dir: Path | None = None,
  386. ) -> dict:
  387. """Run a single simulation point.
  388. Args:
  389. index: 1-based point index.
  390. total: total number of points.
  391. writes: list of (variable_name, value) to set, in order.
  392. torque_points: TorquePointsPerCycle (0 = keep model default).
  393. airgap_mesh: Airgap mesh/layers value (0 = keep model default).
  394. raw_dir: directory to save raw export CSV.
  395. Returns:
  396. dict with metrics, status, error, seconds.
  397. """
  398. started = time.time()
  399. result: dict = {
  400. "index": index,
  401. "status": "FAILED",
  402. "error": "",
  403. "seconds": 0,
  404. "metrics": {},
  405. }
  406. try:
  407. # Reload baseline model for every point.
  408. self.mc.load_from_file(str(self.model_path))
  409. self.mc.set_visible(True)
  410. self.mc.display_screen("Scripting")
  411. # Solver discretization (if specified).
  412. if torque_points > 0:
  413. self.mc.set_variable("TorquePointsPerCycle", torque_points)
  414. if airgap_mesh > 0:
  415. self.mc.set_variable("AirgapMeshPoints_mesh", airgap_mesh)
  416. self.mc.set_variable("AirgapMeshPoints_layers", airgap_mesh)
  417. # Write design variables and verify each.
  418. for variable, value in writes:
  419. self._write_and_verify(variable, value)
  420. self._log(f"[{index}/{total}] Starting magnetic calculation")
  421. self.mc.do_magnetic_calculation()
  422. # Export raw results.
  423. if raw_dir is not None:
  424. raw_dir.mkdir(parents=True, exist_ok=True)
  425. ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
  426. val_tag = "_".join(f"{v}_{val:g}" for v, val in writes)
  427. raw_path = raw_dir / f"result_{index:04d}_{val_tag}_{ts}.csv"
  428. self.mc.export_results("EMagnetic", str(raw_path))
  429. parsed = parse_export(raw_path)
  430. result["metrics"] = extract_all_metrics(parsed)
  431. result["raw_path"] = str(raw_path)
  432. result["status"] = "OK"
  433. except Exception as exc:
  434. result["status"] = "FAILED"
  435. result["error"] = f"{type(exc).__name__}: {exc}"
  436. self._log(result["error"])
  437. self._log(traceback.format_exc())
  438. result["seconds"] = round(time.time() - started, 1)
  439. return result
  440. def run_scan(
  441. self,
  442. points: list[dict],
  443. output_dir: Path,
  444. torque_points: int = 0,
  445. airgap_mesh: int = 0,
  446. scan_name: str = "scan",
  447. extra_csv_fields: list[str] | None = None,
  448. ) -> dict:
  449. """Run a full parameter scan.
  450. Args:
  451. points: list of dicts, each with 'writes' (list of (var, val))
  452. and optional extra fields for CSV.
  453. output_dir: base directory for run output.
  454. torque_points: solver setting.
  455. airgap_mesh: solver setting.
  456. scan_name: name for the run subdirectory.
  457. extra_csv_fields: additional CSV column names beyond metrics.
  458. Returns:
  459. dict with run_dir, csv_path, log_path, manifest_path, summary.
  460. """
  461. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
  462. run_dir = Path(output_dir) / f"{timestamp}_{scan_name}"
  463. raw_dir = run_dir / "raw"
  464. run_dir.mkdir(parents=True, exist_ok=True)
  465. csv_path = run_dir / f"scan_results_{timestamp}.csv"
  466. log_path = run_dir / f"program_log_{timestamp}.log"
  467. manifest_path = run_dir / f"run_manifest_{timestamp}.json"
  468. extra_fields = extra_csv_fields or []
  469. csv_fields = ["run_index", "status", "seconds", "error"] + extra_fields + METRIC_KEYS
  470. log_file = log_path.open("a", encoding="ascii", errors="backslashreplace")
  471. def log(text: str) -> None:
  472. stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
  473. line = f"{stamp} {text}"
  474. log_file.write(line + "\n")
  475. log_file.flush()
  476. self._log(line)
  477. # Write manifest.
  478. manifest = {
  479. "timestamp": timestamp,
  480. "model": str(self.model_path),
  481. "scan_name": scan_name,
  482. "total_points": len(points),
  483. "torque_points": torque_points,
  484. "airgap_mesh": airgap_mesh,
  485. "points": [
  486. {
  487. "writes": [[v, val] for v, val in p.get("writes", [])],
  488. **{k: p[k] for k in p if k != "writes"},
  489. }
  490. for p in points
  491. ],
  492. }
  493. manifest_path.write_text(json.dumps(manifest, indent=2), encoding="ascii")
  494. log(f"Run directory: {run_dir}")
  495. log(f"Model: {self.model_path}")
  496. log(f"Total points: {len(points)}")
  497. summary = {"ok": 0, "failed": 0, "results": []}
  498. try:
  499. with csv_path.open("w", newline="", encoding="utf-8-sig") as csv_file:
  500. writer = csv.DictWriter(csv_file, fieldnames=csv_fields)
  501. writer.writeheader()
  502. csv_file.flush()
  503. for idx, point in enumerate(points, 1):
  504. if self._cancel:
  505. log("Cancel requested; stopping before next point")
  506. break
  507. writes = point.get("writes", [])
  508. log(
  509. f"[{idx}/{len(points)}] "
  510. + ", ".join(f"{v}={val:g}" for v, val in writes)
  511. )
  512. point_result = self.run_single_point(
  513. index=idx,
  514. total=len(points),
  515. writes=writes,
  516. torque_points=torque_points,
  517. airgap_mesh=airgap_mesh,
  518. raw_dir=raw_dir,
  519. )
  520. row = {field: "" for field in csv_fields}
  521. row["run_index"] = idx
  522. row["status"] = point_result["status"]
  523. row["seconds"] = point_result["seconds"]
  524. row["error"] = point_result["error"]
  525. for key in extra_fields:
  526. if key in point:
  527. row[key] = point[key]
  528. for key, value in point_result["metrics"].items():
  529. row[key] = value
  530. writer.writerow(row)
  531. csv_file.flush()
  532. if point_result["status"] == "OK":
  533. summary["ok"] += 1
  534. else:
  535. summary["failed"] += 1
  536. summary["results"].append(row)
  537. if self.row_cb:
  538. self.row_cb(row)
  539. if self.progress_cb:
  540. self.progress_cb(idx, len(points))
  541. ripple = row.get("ripple_pct", "")
  542. tavg = row.get("tavg_nm", "")
  543. log(
  544. f"[{idx}/{len(points)}] {row['status']} "
  545. f"ripple={ripple} tavg={tavg} seconds={row['seconds']}"
  546. )
  547. log(f"Scan ended. OK={summary['ok']} FAILED={summary['failed']}")
  548. log(f"Results: {csv_path}")
  549. finally:
  550. log_file.close()
  551. return {
  552. "run_dir": str(run_dir),
  553. "csv_path": str(csv_path),
  554. "log_path": str(log_path),
  555. "manifest_path": str(manifest_path),
  556. "summary": summary,
  557. }