| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- """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,
- }
|