"""Motor-CAD parameter scan solver core. Pure computation module, no GUI dependencies. Handles Motor-CAD connection, model reload, parameter write-back verification, magnetic calculation, result export parsing, and metric extraction with bilingual (English/Chinese) field matching. All source is ASCII; Chinese field names use \\uXXXX escapes. """ from __future__ import annotations import csv import json import math import os import subprocess import time import traceback from datetime import datetime from pathlib import Path # --------------------------------------------------------------------------- # Metric definitions: key, display label, and aliases (English + Chinese). # Chinese aliases use Unicode escapes so this file stays pure ASCII. # --------------------------------------------------------------------------- METRIC_DEFINITIONS = [ { "key": "ripple_pct", "label": "Torque Ripple [%]", "aliases": [ "Torque Ripple (VW) [%]", "Torque Ripple (VW)[%]", ], }, { "key": "ripple_nm", "label": "Torque Ripple [Nm]", "aliases": [ "Torque Ripple (VW)", ], }, { "key": "tavg_nm", "label": "Tavg VW [Nm]", "aliases": [ "Average torque (virtual work)", "\u5e73\u5747\u8f6c\u77e9 (virtual work)", "\u5e73\u5747\u8f6c\u77e9(virtual work)", ], }, { "key": "efficiency_pct", "label": "Efficiency [%]", "aliases": [ "System Efficiency", "\u7cfb\u7edf\u6548\u7387", ], }, { "key": "back_emf_v", "label": "Back EMF LL rms [V]", "aliases": [ "Back EMF Line-Line Voltage (rms)", "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u6709\u6548\u503c", ], }, { "key": "back_emf_thd_pct", "label": "Back EMF THD [%]", "aliases": [ "Harmonic Distortion Back EMF Line-Line Voltage", "\u7ebf\u53cd\u5411\u7535\u52a8\u52bf\u8c10\u6ce2", "\u7ebf\u7535\u538b\u8c10\u6ce2", ], }, { "key": "total_losses_w", "label": "Total losses [W]", "aliases": [ "Total Losses (on load)", "\u603b\u635f\u8017(\u989d\u5b9a)", "\u603b\u635f\u8017 (\u989d\u5b9a)", ], }, { "key": "copper_loss_w", "label": "DC copper loss [W]", "aliases": [ "Armature DC Copper Loss (on load)", "\u7535\u67a2\u76f4\u6d41\u94dc\u8017 (\u5e26\u8f7d)", "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u5e26\u8f7d)", ], }, { "key": "magnet_loss_w", "label": "Magnet loss [W]", "aliases": [ "Magnet Loss (on load)", "\u6c38\u78c1\u4f53\u635f\u8017(\u989d\u5b9a)", "\u6c38\u78c1\u4f53\u635f\u8017 (\u989d\u5b9a)", ], }, { "key": "iron_loss_w", "label": "Stator iron loss [W]", "aliases": [ "Stator iron Loss [total] (on load)", "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u989d\u5b9a)", "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017] (\u989d\u5b9a)", ], }, { "key": "input_power_w", "label": "Input power [W]", "aliases": [ "Input Power", "\u8f93\u5165\u529f\u7387", ], }, { "key": "output_power_w", "label": "Output power [W]", "aliases": [ "Output Power", "\u8f93\u51fa\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9", ], }, { "key": "em_power_w", "label": "EM power [W]", "aliases": [ "Electromagnetic Power", "\u7535\u78c1\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9", ], }, { "key": "shaft_speed_rpm", "label": "Shaft speed [rpm]", "aliases": [ "Shaft Speed", "\u8f6c\u901f[RPM]", "\u8f6c\u901f [RPM]", ], }, { "key": "no_load_speed_rpm", "label": "No-load speed [rpm]", "aliases": [ "No load speed", "\u7a7a\u8f7d\u8f6c\u901f", ], }, { "key": "shaft_torque_nm", "label": "Shaft torque [Nm]", "aliases": [ "Shaft Torque", "\u8f74\u8f6c\u77e9", ], }, ] METRIC_KEYS = [m["key"] for m in METRIC_DEFINITIONS] METRIC_LABELS = {m["key"]: m["label"] for m in METRIC_DEFINITIONS} # Section name aliases for priority ordering. SECTION_PRIORITY = [ "E-Magnetics", "\u7535\u78c1", "Drive", "\u9a71\u52a8", "Losses", "\u635f\u8017", "Materials", "\u6750\u6599", "Miscellaneous", "\u6742\u9879", ] # --------------------------------------------------------------------------- # Utility functions # --------------------------------------------------------------------------- def values_inclusive(start: float, stop: float, step: float) -> list[float]: """Generate values from start to stop inclusive, appending stop if it is not exactly reachable by integer steps.""" if step <= 0 or stop < start: raise ValueError("step must be positive and stop must be >= start") count = int(math.floor((stop - start) / step + 1e-9)) values = [round(start + i * step, 10) for i in range(count + 1)] if not math.isclose(values[-1], stop, abs_tol=1e-9): values.append(float(stop)) return values def _normalize_name(name: str) -> str: """Normalize a field name for matching: unify brackets, remove whitespace, lowercase.""" s = name # Full-width brackets to half-width. s = s.replace("\uff08", "(").replace("\uff09", ")") # Remove all whitespace. s = "".join(s.split()) return s.lower() # Pre-normalize aliases for fast matching. _METRIC_ALIAS_MAP: dict[str, str] = {} for _m in METRIC_DEFINITIONS: for _alias in _m["aliases"]: _METRIC_ALIAS_MAP[_normalize_name(_alias)] = _m["key"] def parse_export(path: Path) -> dict[str, dict[str, float]]: """Parse a Motor-CAD semicolon-delimited export file. Returns a dict of section_name -> {field_name: value}. Handles UTF-8, cp1252, gbk, and latin-1 encodings. """ text = None for encoding in ("utf-8-sig", "gbk", "cp1252", "latin-1"): try: text = path.read_text(encoding=encoding) break except UnicodeDecodeError: continue if text is None: return {} result: dict[str, dict[str, float]] = {} section = "(root)" for raw in text.splitlines(): line = raw.strip() if not line: continue if ";" not in line: section = line result.setdefault(section, {}) continue parts = line.split(";") try: field = parts[0].strip().strip('"') value = float(parts[1]) result.setdefault(section, {})[field] = value except (IndexError, ValueError): continue return result def pick_metric(results: dict[str, dict[str, float]], metric_key: str): """Extract a metric value from parsed export results. Tries exact alias match first (section priority order), then falls back to prefix-based fuzzy match across all sections. Returns the value (float) or "" if not found. """ # Build the set of normalized aliases for this metric. wanted_aliases = set() for m in METRIC_DEFINITIONS: if m["key"] == metric_key: for alias in m["aliases"]: wanted_aliases.add(_normalize_name(alias)) break if not wanted_aliases: return "" # Phase 1: exact match in priority section order. for section_name in SECTION_PRIORITY: section = results.get(section_name) if section is None: continue for field, value in section.items(): if _normalize_name(field) in wanted_aliases: return value # Phase 2: exact match across all sections. for section in results.values(): for field, value in section.items(): if _normalize_name(field) in wanted_aliases: return value # Phase 3: prefix fuzzy match. for alias_norm in wanted_aliases: for section in results.values(): for field, value in section.items(): field_norm = _normalize_name(field) if field_norm.startswith(alias_norm) or alias_norm.startswith(field_norm): if len(field_norm) > 3: # avoid trivial matches return value return "" def extract_all_metrics(results: dict[str, dict[str, float]]) -> dict[str, float]: """Extract all defined metrics from parsed results.""" out: dict[str, float] = {} for m in METRIC_DEFINITIONS: val = pick_metric(results, m["key"]) if val != "": out[m["key"]] = val return out # --------------------------------------------------------------------------- # Git preflight # --------------------------------------------------------------------------- def find_repo(start: Path) -> Path | None: """Walk up from start to find a directory containing .git.""" for candidate in (start, *start.parents): if (candidate / ".git").exists(): return candidate return None def git_preflight(script_dir: Path) -> tuple[bool, str]: """Check that a git repo exists, HEAD is valid, and tracked files are clean. Returns (ok, commit_or_error).""" repo = find_repo(script_dir) if repo is None: return False, "No Git repository found." safe = f"safe.directory={repo.as_posix()}" base = ["git", "-c", safe] try: commit = subprocess.check_output( base + ["rev-parse", "--short", "HEAD"], cwd=repo, text=True, stderr=subprocess.STDOUT, ).strip() dirty = subprocess.check_output( base + ["status", "--porcelain", "--untracked-files=no"], cwd=repo, text=True, stderr=subprocess.STDOUT, ).strip() except (OSError, subprocess.CalledProcessError) as exc: return False, f"Git preflight failed: {exc}" if dirty: return False, "Tracked files have uncommitted changes. Commit before starting a scan." return True, commit # --------------------------------------------------------------------------- # Motor-CAD solver # --------------------------------------------------------------------------- class MotorCADSolver: """Manages a dedicated, foreground-visible Motor-CAD instance and runs parameter scans with per-point baseline reload, write-back verification, and immediate CSV/log persistence. No GUI dependencies. Callbacks (log_cb, progress_cb, row_cb) allow the caller to receive events. """ def __init__( self, model_path: Path, log_cb=None, progress_cb=None, row_cb=None, ): self.model_path = Path(model_path).resolve() self.log_cb = log_cb self.progress_cb = progress_cb self.row_cb = row_cb self.mc = None self._cancel = False def _log(self, text: str) -> None: if self.log_cb: self.log_cb(text) def cancel(self) -> None: """Request cancellation after the current point finishes.""" self._cancel = True def connect(self) -> None: """Open a dedicated, foreground-visible Motor-CAD instance. Falls back to set_motorcad_exe if MOTORCAD_ACTIVEX is not set. """ import ansys.motorcad.core as pymotorcad 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) self._log(f"MOTORCAD_ACTIVEX not set; using {candidate}") except (ImportError, Exception): pass self._log("Opening a separate, visible Motor-CAD instance") self.mc = pymotorcad.MotorCAD(open_new_instance=True, keep_instance_open=False) self.mc.set_visible(True) self.mc.set_variable("MessageDisplayState", 2) self.mc.display_screen("Scripting") def disconnect(self) -> None: """Reload the baseline model and close the instance.""" if self.mc is not None: try: self.mc.load_from_file(str(self.model_path)) except Exception: pass try: self.mc.quit() except Exception: pass self.mc = None def _write_and_verify(self, variable: str, value: float) -> None: """Write a variable and read it back. Raises RuntimeError on mismatch.""" self.mc.set_variable(variable, value) applied = float(self.mc.get_variable(variable)) if not math.isclose(applied, value, rel_tol=1e-8, abs_tol=1e-7): raise RuntimeError( f"Write verification failed for {variable}: " f"wrote {value}, read {applied}" ) def run_single_point( self, index: int, total: int, writes: list[tuple[str, float]], torque_points: int = 0, airgap_mesh: int = 0, raw_dir: Path | None = None, ) -> dict: """Run a single simulation point. Args: index: 1-based point index. total: total number of points. writes: list of (variable_name, value) to set, in order. torque_points: TorquePointsPerCycle (0 = keep model default). airgap_mesh: Airgap mesh/layers value (0 = keep model default). raw_dir: directory to save raw export CSV. Returns: dict with metrics, status, error, seconds. """ started = time.time() result: dict = { "index": index, "status": "FAILED", "error": "", "seconds": 0, "metrics": {}, } try: # Reload baseline model for every point. self.mc.load_from_file(str(self.model_path)) self.mc.set_visible(True) self.mc.display_screen("Scripting") # Solver discretization (if specified). if torque_points > 0: self.mc.set_variable("TorquePointsPerCycle", torque_points) if airgap_mesh > 0: self.mc.set_variable("AirgapMeshPoints_mesh", airgap_mesh) self.mc.set_variable("AirgapMeshPoints_layers", airgap_mesh) # Write design variables and verify each. for variable, value in writes: self._write_and_verify(variable, value) self._log(f"[{index}/{total}] Starting magnetic calculation") self.mc.do_magnetic_calculation() # Export raw results. if raw_dir is not None: raw_dir.mkdir(parents=True, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] val_tag = "_".join(f"{v}_{val:g}" for v, val in writes) raw_path = raw_dir / f"result_{index:04d}_{val_tag}_{ts}.csv" self.mc.export_results("EMagnetic", str(raw_path)) parsed = parse_export(raw_path) result["metrics"] = extract_all_metrics(parsed) result["raw_path"] = str(raw_path) result["status"] = "OK" except Exception as exc: result["status"] = "FAILED" result["error"] = f"{type(exc).__name__}: {exc}" self._log(result["error"]) self._log(traceback.format_exc()) result["seconds"] = round(time.time() - started, 1) return result def run_scan( self, points: list[dict], output_dir: Path, torque_points: int = 0, airgap_mesh: int = 0, scan_name: str = "scan", extra_csv_fields: list[str] | None = None, ) -> dict: """Run a full parameter scan. Args: points: list of dicts, each with 'writes' (list of (var, val)) and optional extra fields for CSV. output_dir: base directory for run output. torque_points: solver setting. airgap_mesh: solver setting. scan_name: name for the run subdirectory. extra_csv_fields: additional CSV column names beyond metrics. Returns: dict with run_dir, csv_path, log_path, manifest_path, summary. """ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] run_dir = Path(output_dir) / f"{timestamp}_{scan_name}" raw_dir = run_dir / "raw" run_dir.mkdir(parents=True, exist_ok=True) csv_path = run_dir / f"scan_results_{timestamp}.csv" log_path = run_dir / f"program_log_{timestamp}.log" manifest_path = run_dir / f"run_manifest_{timestamp}.json" extra_fields = extra_csv_fields or [] csv_fields = ["run_index", "status", "seconds", "error"] + extra_fields + METRIC_KEYS log_file = log_path.open("a", encoding="ascii", errors="backslashreplace") def log(text: str) -> None: stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] line = f"{stamp} {text}" log_file.write(line + "\n") log_file.flush() self._log(line) # Write manifest. manifest = { "timestamp": timestamp, "model": str(self.model_path), "scan_name": scan_name, "total_points": len(points), "torque_points": torque_points, "airgap_mesh": airgap_mesh, "points": [ { "writes": [[v, val] for v, val in p.get("writes", [])], **{k: p[k] for k in p if k != "writes"}, } for p in points ], } manifest_path.write_text(json.dumps(manifest, indent=2), encoding="ascii") log(f"Run directory: {run_dir}") log(f"Model: {self.model_path}") log(f"Total points: {len(points)}") summary = {"ok": 0, "failed": 0, "results": []} try: with csv_path.open("w", newline="", encoding="utf-8-sig") as csv_file: writer = csv.DictWriter(csv_file, fieldnames=csv_fields) writer.writeheader() csv_file.flush() for idx, point in enumerate(points, 1): if self._cancel: log("Cancel requested; stopping before next point") break writes = point.get("writes", []) log( f"[{idx}/{len(points)}] " + ", ".join(f"{v}={val:g}" for v, val in writes) ) point_result = self.run_single_point( index=idx, total=len(points), writes=writes, torque_points=torque_points, airgap_mesh=airgap_mesh, raw_dir=raw_dir, ) row = {field: "" for field in csv_fields} row["run_index"] = idx row["status"] = point_result["status"] row["seconds"] = point_result["seconds"] row["error"] = point_result["error"] for key in extra_fields: if key in point: row[key] = point[key] for key, value in point_result["metrics"].items(): row[key] = value writer.writerow(row) csv_file.flush() if point_result["status"] == "OK": summary["ok"] += 1 else: summary["failed"] += 1 summary["results"].append(row) if self.row_cb: self.row_cb(row) if self.progress_cb: self.progress_cb(idx, len(points)) ripple = row.get("ripple_pct", "") tavg = row.get("tavg_nm", "") log( f"[{idx}/{len(points)}] {row['status']} " f"ripple={ripple} tavg={tavg} seconds={row['seconds']}" ) log(f"Scan ended. OK={summary['ok']} FAILED={summary['failed']}") log(f"Results: {csv_path}") finally: log_file.close() return { "run_dir": str(run_dir), "csv_path": str(csv_path), "log_path": str(log_path), "manifest_path": str(manifest_path), "summary": summary, }