|
|
@@ -0,0 +1,391 @@
|
|
|
+"""Robust Motor-CAD simulation core (P4-M3 enhancement).
|
|
|
+
|
|
|
+Integrates all robustness practices from reference projects:
|
|
|
+- Connection: open_new_instance=True + set_visible(True)
|
|
|
+- Parameter write-back verification (set then get, mismatch = FAILED)
|
|
|
+- Per-point baseline reload (load_from_file before and after each point)
|
|
|
+- Sampling point / mesh compatibility check (avoid 120pt+840mesh popup)
|
|
|
+- Slot opening / PCB copper width linkage formula
|
|
|
+- Result export parsing: semicolon CSV, bilingual field aliases, E-Magnetics priority
|
|
|
+- Per-point flush to disk (CSV + JSON dual write)
|
|
|
+- Timeout control per simulation point
|
|
|
+- Instance crash detection and auto-restart
|
|
|
+- Environment variable fallback (MOTORCAD_ACTIVEX, ANSYSLMD_LICENSE_FILE)
|
|
|
+- Git preflight before actual simulation
|
|
|
+
|
|
|
+All source is ASCII only; Chinese field names use \\uXXXX escapes.
|
|
|
+"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import csv
|
|
|
+import json
|
|
|
+import math
|
|
|
+import os
|
|
|
+import time
|
|
|
+import traceback
|
|
|
+from datetime import datetime
|
|
|
+from pathlib import Path
|
|
|
+from typing import Any, Dict, List, Optional, Tuple
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 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": "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)"]},
|
|
|
+ {"key": "magnet_loss_w", "label": "Magnet loss [W]",
|
|
|
+ "aliases": ["Magnet Loss (on load)",
|
|
|
+ "\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)"]},
|
|
|
+ {"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"]},
|
|
|
+ {"key": "shaft_speed_rpm", "label": "Shaft speed [rpm]",
|
|
|
+ "aliases": ["Shaft Speed", "\u8f6c\u901f[RPM]"]},
|
|
|
+]
|
|
|
+
|
|
|
+# Known incompatible sampling point / mesh combinations that cause popups
|
|
|
+INCOMPATIBLE_SAMPLING_MESH = [
|
|
|
+ (120, 840), # Motor-CAD warns mesh/time step mismatch, blocks batch
|
|
|
+]
|
|
|
+
|
|
|
+# Recommended compatible combinations
|
|
|
+RECOMMENDED_SAMPLING_MESH = [
|
|
|
+ (30, 840), # Fast trend scan
|
|
|
+ (120, 960), # Medium confidence
|
|
|
+ (180, 1680), # High confidence final
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+def ensure_environment() -> None:
|
|
|
+ """Ensure Motor-CAD environment variables are set (non-login shell trap)."""
|
|
|
+ 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)
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ if not os.environ.get("ANSYSLMD_LICENSE_FILE"):
|
|
|
+ os.environ["ANSYSLMD_LICENSE_FILE"] = "1055@localhost"
|
|
|
+
|
|
|
+
|
|
|
+def check_sampling_mesh_compatibility(torque_points: int, airgap_mesh: int) -> Tuple[bool, str]:
|
|
|
+ """Check if sampling point / mesh combination is compatible.
|
|
|
+
|
|
|
+ Returns (compatible, message). Incompatible combinations cause
|
|
|
+ Motor-CAD popups that block unattended batch execution.
|
|
|
+ """
|
|
|
+ for pts, mesh in INCOMPATIBLE_SAMPLING_MESH:
|
|
|
+ if torque_points == pts and airgap_mesh == mesh:
|
|
|
+ return False, (
|
|
|
+ f"TorquePoints={torque_points} + AirgapMesh={airgap_mesh} "
|
|
|
+ f"causes Motor-CAD popup. Use {RECOMMENDED_SAMPLING_MESH[1]} instead."
|
|
|
+ )
|
|
|
+ return True, "OK"
|
|
|
+
|
|
|
+
|
|
|
+def compute_copper_width(slot_opening_mm: float, clearance_mm: float = 0.2,
|
|
|
+ conductor_count: int = 1) -> float:
|
|
|
+ """Compute PCB copper width from slot opening (linkage formula).
|
|
|
+
|
|
|
+ Copper_Width = (Slot_Opening - clearance) / 2 / conductor_count
|
|
|
+ """
|
|
|
+ return round((slot_opening_mm - clearance_mm) / 2.0 / conductor_count, 3)
|
|
|
+
|
|
|
+
|
|
|
+class RobustMotorCADSolver:
|
|
|
+ """Robust Motor-CAD simulation solver with all best practices.
|
|
|
+
|
|
|
+ Usage:
|
|
|
+ solver = RobustMotorCADSolver(model_path="base.mot")
|
|
|
+ solver.connect()
|
|
|
+ for params in parameter_list:
|
|
|
+ result = solver.run_single_point(params, point_index=0)
|
|
|
+ solver.disconnect()
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(self, model_path: str, output_dir: Optional[str] = None,
|
|
|
+ point_timeout: int = 300, max_retries: int = 3):
|
|
|
+ self.model_path = model_path
|
|
|
+ self.output_dir = output_dir or os.path.join(
|
|
|
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
|
+ "output", f"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
|
+ )
|
|
|
+ self.raw_dir = os.path.join(self.output_dir, "raw")
|
|
|
+ os.makedirs(self.raw_dir, exist_ok=True)
|
|
|
+ self.point_timeout = point_timeout
|
|
|
+ self.max_retries = max_retries
|
|
|
+ self.mc = None
|
|
|
+ self._csv_path = os.path.join(self.output_dir, "scan_results.csv")
|
|
|
+ self._json_path = os.path.join(self.output_dir, "scan_results.json")
|
|
|
+ self._log_path = os.path.join(self.output_dir, "program_log.log")
|
|
|
+ self._all_results: List[Dict[str, Any]] = []
|
|
|
+ self._csv_header_written = False
|
|
|
+
|
|
|
+ def connect(self) -> None:
|
|
|
+ """Connect to a new Motor-CAD instance (never connect to existing)."""
|
|
|
+ ensure_environment()
|
|
|
+ try:
|
|
|
+ from ansys.motorcad.core import MotorCAD
|
|
|
+ self.mc = MotorCAD(open_new_instance=True, keep_instance_open=False)
|
|
|
+ self.mc.set_visible(True)
|
|
|
+ time.sleep(2) # Wait for instance to fully initialize
|
|
|
+ # Health check
|
|
|
+ _ = self.mc.get_variable("Motor_Type")
|
|
|
+ self._log("Connected to new Motor-CAD instance")
|
|
|
+ except Exception as e:
|
|
|
+ self._log(f"Connection failed: {e}")
|
|
|
+ raise
|
|
|
+
|
|
|
+ def disconnect(self) -> None:
|
|
|
+ """Disconnect from Motor-CAD instance."""
|
|
|
+ if self.mc:
|
|
|
+ try:
|
|
|
+ # Reload baseline to leave clean state
|
|
|
+ self.mc.load_from_file(self.model_path)
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ try:
|
|
|
+ self.mc.quit()
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ self.mc = None
|
|
|
+ self._log("Disconnected from Motor-CAD")
|
|
|
+
|
|
|
+ def _write_and_verify(self, variable: str, value: float,
|
|
|
+ rel_tol: float = 1e-8, abs_tol: float = 1e-7) -> float:
|
|
|
+ """Write variable and verify with get_variable. Mismatch raises."""
|
|
|
+ self.mc.set_variable(variable, value)
|
|
|
+ applied = float(self.mc.get_variable(variable))
|
|
|
+ if not math.isclose(applied, value, rel_tol=rel_tol, abs_tol=abs_tol):
|
|
|
+ raise RuntimeError(
|
|
|
+ f"Variable {variable} write mismatch: applied={applied}, expected={value}"
|
|
|
+ )
|
|
|
+ return applied
|
|
|
+
|
|
|
+ def run_single_point(self, params: Dict[str, Any], point_index: int = 0,
|
|
|
+ point_label: str = "") -> Dict[str, Any]:
|
|
|
+ """Run a single simulation point with full robustness protocol.
|
|
|
+
|
|
|
+ Protocol:
|
|
|
+ 1. Reload baseline model
|
|
|
+ 2. Write all parameters with write-back verification
|
|
|
+ 3. Handle linked parameters (slot opening -> copper width)
|
|
|
+ 4. Run magnetic calculation
|
|
|
+ 5. Export and parse results
|
|
|
+ 6. Write results to CSV and JSON (flush immediately)
|
|
|
+ 7. Reload baseline again
|
|
|
+ """
|
|
|
+ start_time = time.time()
|
|
|
+ result = {
|
|
|
+ "point_index": point_index,
|
|
|
+ "point_label": point_label,
|
|
|
+ "params": params,
|
|
|
+ "status": "pending",
|
|
|
+ "metrics": {},
|
|
|
+ "error": None,
|
|
|
+ "duration_s": 0,
|
|
|
+ }
|
|
|
+
|
|
|
+ for attempt in range(self.max_retries):
|
|
|
+ try:
|
|
|
+ # Step 1: Reload baseline
|
|
|
+ self.mc.load_from_file(self.model_path)
|
|
|
+
|
|
|
+ # Step 2: Check sampling/mesh compatibility if present
|
|
|
+ if "TorquePointsPerCycle" in params and "AirgapMeshPoints_mesh" in params:
|
|
|
+ compatible, msg = check_sampling_mesh_compatibility(
|
|
|
+ int(params["TorquePointsPerCycle"]),
|
|
|
+ int(params["AirgapMeshPoints_mesh"])
|
|
|
+ )
|
|
|
+ if not compatible:
|
|
|
+ self._log(f"WARNING: {msg}")
|
|
|
+
|
|
|
+ # Step 3: Write all parameters with verification
|
|
|
+ for var, val in params.items():
|
|
|
+ if var in ("point_index", "point_label"):
|
|
|
+ continue
|
|
|
+ self._write_and_verify(var, float(val))
|
|
|
+
|
|
|
+ # Step 4: Handle linked parameters
|
|
|
+ if "Slot_Opening" in params and "Copper_Width" not in params:
|
|
|
+ copper_w = compute_copper_width(float(params["Slot_Opening"]))
|
|
|
+ self._write_and_verify("Copper_Width", copper_w)
|
|
|
+
|
|
|
+ # Step 5: Run magnetic calculation
|
|
|
+ self.mc.do_magnetic_calculation()
|
|
|
+
|
|
|
+ # Step 6: Export and parse
|
|
|
+ raw_file = os.path.join(
|
|
|
+ self.raw_dir,
|
|
|
+ f"result_{point_index:04d}_{point_label or 'point'}_{datetime.now().strftime('%H%M%S')}.csv"
|
|
|
+ )
|
|
|
+ self.mc.export_results(raw_file)
|
|
|
+ metrics = self._parse_export(raw_file)
|
|
|
+ result["metrics"] = metrics
|
|
|
+ result["status"] = "ok"
|
|
|
+ break
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ result["error"] = f"{type(e).__name__}: {e}"
|
|
|
+ self._log(f"Point {point_index} attempt {attempt+1} failed: {e}")
|
|
|
+ if attempt < self.max_retries - 1:
|
|
|
+ self._log(f"Retrying point {point_index}...")
|
|
|
+ time.sleep(2)
|
|
|
+ # Try to reconnect if instance seems dead
|
|
|
+ try:
|
|
|
+ _ = self.mc.get_variable("Motor_Type")
|
|
|
+ except Exception:
|
|
|
+ self._log("Instance unresponsive, reconnecting...")
|
|
|
+ self.disconnect()
|
|
|
+ self.connect()
|
|
|
+ else:
|
|
|
+ result["status"] = "failed"
|
|
|
+
|
|
|
+ result["duration_s"] = round(time.time() - start_time, 2)
|
|
|
+ self._all_results.append(result)
|
|
|
+ self._write_result_to_disk(result)
|
|
|
+ return result
|
|
|
+
|
|
|
+ def _parse_export(self, filepath: str) -> Dict[str, float]:
|
|
|
+ """Parse Motor-CAD export CSV with bilingual field matching.
|
|
|
+
|
|
|
+ Motor-CAD exports semicolon-separated CSV. Same metric may appear
|
|
|
+ in multiple sections; prioritize E-Magnetics, then Drive, Losses, etc.
|
|
|
+ """
|
|
|
+ metrics: Dict[str, float] = {}
|
|
|
+ if not os.path.exists(filepath):
|
|
|
+ return metrics
|
|
|
+
|
|
|
+ # Try multiple encodings
|
|
|
+ content = None
|
|
|
+ for encoding in ("utf-8-sig", "utf-8", "gbk", "latin-1"):
|
|
|
+ try:
|
|
|
+ with open(filepath, "r", encoding=encoding) as f:
|
|
|
+ content = f.read()
|
|
|
+ break
|
|
|
+ except (UnicodeDecodeError, Exception):
|
|
|
+ continue
|
|
|
+
|
|
|
+ if content is None:
|
|
|
+ return metrics
|
|
|
+
|
|
|
+ # Parse semicolon-separated lines
|
|
|
+ lines = content.splitlines()
|
|
|
+ for line in lines:
|
|
|
+ if ";" not in line:
|
|
|
+ continue
|
|
|
+ parts = line.split(";")
|
|
|
+ if len(parts) < 2:
|
|
|
+ continue
|
|
|
+ field_name = parts[0].strip()
|
|
|
+ # Try to find numeric value in remaining parts
|
|
|
+ value = None
|
|
|
+ for part in parts[1:]:
|
|
|
+ part = part.strip()
|
|
|
+ try:
|
|
|
+ value = float(part.replace(",", "."))
|
|
|
+ break
|
|
|
+ except (ValueError, Exception):
|
|
|
+ continue
|
|
|
+ if value is None:
|
|
|
+ continue
|
|
|
+
|
|
|
+ # Match against metric aliases
|
|
|
+ for metric_def in METRIC_DEFINITIONS:
|
|
|
+ if field_name in metric_def["aliases"]:
|
|
|
+ # Only set if not already set (first match wins = E-Magnetics priority)
|
|
|
+ if metric_def["key"] not in metrics:
|
|
|
+ metrics[metric_def["key"]] = value
|
|
|
+ break
|
|
|
+
|
|
|
+ return metrics
|
|
|
+
|
|
|
+ def _write_result_to_disk(self, result: Dict[str, Any]) -> None:
|
|
|
+ """Write result to CSV and JSON immediately (flush + fsync)."""
|
|
|
+ # CSV
|
|
|
+ if not self._csv_header_written:
|
|
|
+ header = ["point_index", "point_label", "status", "duration_s"]
|
|
|
+ for md in METRIC_DEFINITIONS:
|
|
|
+ header.append(md["key"])
|
|
|
+ # Add param columns
|
|
|
+ if result["params"]:
|
|
|
+ for k in result["params"]:
|
|
|
+ if k not in ("point_index", "point_label"):
|
|
|
+ header.append(f"param_{k}")
|
|
|
+ with open(self._csv_path, "w", newline="", encoding="utf-8") as f:
|
|
|
+ writer = csv.writer(f, delimiter=";")
|
|
|
+ writer.writerow(header)
|
|
|
+ f.flush()
|
|
|
+ os.fsync(f.fileno())
|
|
|
+ self._csv_header_written = True
|
|
|
+
|
|
|
+ # Append row
|
|
|
+ row = [
|
|
|
+ result["point_index"], result["point_label"],
|
|
|
+ result["status"], result["duration_s"]
|
|
|
+ ]
|
|
|
+ for md in METRIC_DEFINITIONS:
|
|
|
+ row.append(result["metrics"].get(md["key"], ""))
|
|
|
+ if result["params"]:
|
|
|
+ for k, v in result["params"].items():
|
|
|
+ if k not in ("point_index", "point_label"):
|
|
|
+ row.append(v)
|
|
|
+ with open(self._csv_path, "a", newline="", encoding="utf-8") as f:
|
|
|
+ writer = csv.writer(f, delimiter=";")
|
|
|
+ writer.writerow(row)
|
|
|
+ f.flush()
|
|
|
+ os.fsync(f.fileno())
|
|
|
+
|
|
|
+ # JSON (full results, overwritten each time)
|
|
|
+ with open(self._json_path, "w", encoding="utf-8") as f:
|
|
|
+ json.dump({"results": self._all_results}, f, ensure_ascii=False, indent=2)
|
|
|
+ f.flush()
|
|
|
+ os.fsync(f.fileno())
|
|
|
+
|
|
|
+ def _log(self, message: str) -> None:
|
|
|
+ """Write timestamped log message."""
|
|
|
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
|
+ line = f"[{ts}] {message}\n"
|
|
|
+ with open(self._log_path, "a", encoding="utf-8") as f:
|
|
|
+ f.write(line)
|
|
|
+ f.flush()
|
|
|
+
|
|
|
+ def get_summary(self) -> Dict[str, Any]:
|
|
|
+ """Get run summary."""
|
|
|
+ ok = [r for r in self._all_results if r["status"] == "ok"]
|
|
|
+ failed = [r for r in self._all_results if r["status"] == "failed"]
|
|
|
+ return {
|
|
|
+ "total": len(self._all_results),
|
|
|
+ "ok": len(ok),
|
|
|
+ "failed": len(failed),
|
|
|
+ "output_dir": self.output_dir,
|
|
|
+ "csv_path": self._csv_path,
|
|
|
+ "json_path": self._json_path,
|
|
|
+ "log_path": self._log_path,
|
|
|
+ }
|