scan_central_mag_arc.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. """Scan Central Mag Arc [ED] and collect Motor-CAD torque ripple results.
  2. Each run reloads the baseline model before changing only
  3. MagnetCentralArc_HalbachRing. Solver discretization is separately configurable.
  4. """
  5. from __future__ import annotations
  6. import argparse
  7. import csv
  8. import json
  9. import math
  10. import statistics
  11. import time
  12. import traceback
  13. from pathlib import Path
  14. ROOT = Path(__file__).resolve().parent
  15. DEFAULT_MODEL = ROOT / "MARS-9S8P_SSSR_Halbach_PCB-V1.0.mot"
  16. PARAMETER = "MagnetCentralArc_HalbachRing"
  17. SECTION_PRIORITY = ("E-Magnetics", "Drive", "Losses", "Materials", "Miscellaneous")
  18. METRICS = (
  19. "Average torque (virtual work)",
  20. "Torque Ripple (VW)",
  21. "Torque Ripple (VW) [%]",
  22. "Cogging Torque Ripple (Vw)",
  23. "No load speed",
  24. )
  25. def values_inclusive(start: float, stop: float, step: float) -> list[float]:
  26. if step <= 0 or stop < start:
  27. raise ValueError("step must be positive and stop must be >= start")
  28. count = int(math.floor((stop - start) / step + 1e-9))
  29. values = [round(start + i * step, 10) for i in range(count + 1)]
  30. if not math.isclose(values[-1], stop, abs_tol=1e-9):
  31. values.append(float(stop))
  32. return values
  33. def norm(name: str) -> str:
  34. return name.split("_(")[0].strip().replace(" ", "").lower()
  35. def parse_export(path: Path) -> dict[str, dict[str, float]]:
  36. text = None
  37. for encoding in ("utf-8-sig", "cp1252", "gbk", "latin-1"):
  38. try:
  39. text = path.read_text(encoding=encoding)
  40. break
  41. except UnicodeDecodeError:
  42. pass
  43. if text is None:
  44. return {}
  45. result: dict[str, dict[str, float]] = {}
  46. section = "(root)"
  47. for raw in text.splitlines():
  48. line = raw.strip()
  49. if not line:
  50. continue
  51. if ";" not in line:
  52. section = line
  53. result.setdefault(section, {})
  54. continue
  55. parts = line.split(";")
  56. try:
  57. result.setdefault(section, {})[norm(parts[0].strip().strip('"'))] = float(parts[1])
  58. except (IndexError, ValueError):
  59. continue
  60. return result
  61. def pick(result: dict[str, dict[str, float]], key: str):
  62. wanted = norm(key)
  63. for section in SECTION_PRIORITY:
  64. if wanted in result.get(section, {}):
  65. return result[section][wanted]
  66. for section in result.values():
  67. if wanted in section:
  68. return section[wanted]
  69. return ""
  70. def existing_ok_values(csv_path: Path) -> set[float]:
  71. if not csv_path.exists():
  72. return set()
  73. with csv_path.open("r", encoding="utf-8-sig", newline="") as fh:
  74. return {
  75. float(row["central_mag_arc_ed"])
  76. for row in csv.DictReader(fh)
  77. if row.get("status") == "OK"
  78. }
  79. def write_analysis(csv_path: Path, output: Path) -> None:
  80. with csv_path.open("r", encoding="utf-8-sig", newline="") as fh:
  81. rows = [r for r in csv.DictReader(fh) if r.get("status") == "OK"]
  82. rows.sort(key=lambda r: float(r["central_mag_arc_ed"]))
  83. valid = [r for r in rows if r.get("torque_ripple_pct")]
  84. if not valid:
  85. output.write_text("# Scan analysis\n\nNo successful results to analyze.\n", encoding="utf-8")
  86. return
  87. best = min(valid, key=lambda r: float(r["torque_ripple_pct"]))
  88. baseline = min(valid, key=lambda r: abs(float(r["central_mag_arc_ed"]) - 120.0))
  89. ripples = [float(r["torque_ripple_pct"]) for r in valid]
  90. trend = "decreased" if ripples[-1] < ripples[0] else "increased"
  91. table = [
  92. "| Central Mag Arc [ED] | Torque Ripple (VW) [%] | Tavg (VW) [Nm] | Status |",
  93. "|---:|---:|---:|:---|",
  94. ]
  95. table += [
  96. f"| {float(r['central_mag_arc_ed']):g} | {float(r['torque_ripple_pct']):.5g} | "
  97. f"{float(r['average_torque_vw_nm']):.6g} | {r['status']} |"
  98. for r in valid
  99. ]
  100. text = f"""# Central Mag Arc scan results and analysis
  101. - Successful points: {len(valid)}
  102. - Minimum Torque Ripple: **{float(best['torque_ripple_pct']):.5g}%** at **{float(best['central_mag_arc_ed']):g} EDeg**
  103. - Average torque at the best point: {float(best['average_torque_vw_nm']):.6g} Nm
  104. - Ripple change versus the point nearest 120 EDeg: {float(best['torque_ripple_pct']) - float(baseline['torque_ripple_pct']):+.4g} percentage points
  105. - Across the scanned interval, Torque Ripple {trend}; population standard deviation is {statistics.pstdev(ripples):.4g} percentage points.
  106. Note: Use this scan for relative comparison. Recheck final candidates with 180
  107. points per electrical cycle and a finer airgap mesh.
  108. ## Results
  109. {chr(10).join(table)}
  110. """
  111. output.write_text(text, encoding="utf-8")
  112. def main() -> int:
  113. parser = argparse.ArgumentParser(description=__doc__)
  114. parser.add_argument("--model", type=Path, default=DEFAULT_MODEL)
  115. parser.add_argument("--start", type=float, default=80.0)
  116. parser.add_argument("--stop", type=float, default=120.0)
  117. parser.add_argument("--step", type=float, default=1.0)
  118. parser.add_argument("--torque-points", type=int, default=120,
  119. help="Points per electrical cycle; 0 preserves the model value")
  120. parser.add_argument("--airgap-mesh", type=int, default=960,
  121. help="Airgap mesh/layers value; 0 preserves the model value")
  122. parser.add_argument("--output-dir", type=Path, default=ROOT / "results")
  123. parser.add_argument("--port", type=int, default=-1,
  124. help="Motor-CAD RPC port for connecting to a specific instance")
  125. parser.add_argument("--new-instance", action="store_true",
  126. help="Open a separate Motor-CAD instance")
  127. parser.add_argument("--no-resume", action="store_true", help="Do not skip successful CSV rows")
  128. parser.add_argument("--dry-run", action="store_true")
  129. args = parser.parse_args()
  130. model = args.model.resolve()
  131. if not model.exists():
  132. raise SystemExit(f"Model does not exist: {model}")
  133. values = values_inclusive(args.start, args.stop, args.step)
  134. out_dir = args.output_dir.resolve()
  135. csv_path = out_dir / "central_mag_arc_scan.csv"
  136. raw_dir = out_dir / "raw"
  137. analysis_path = out_dir / "central_mag_arc_analysis.md"
  138. print(f"Model: {model}", flush=True)
  139. print(f"Scan: {values[0]:g}..{values[-1]:g} EDeg, {len(values)} points; "
  140. f"TorquePointsPerCycle={args.torque_points or 'model default'}, "
  141. f"AirgapMesh={args.airgap_mesh or 'model default'}", flush=True)
  142. print(f"Results: {csv_path}", flush=True)
  143. if args.dry_run:
  144. return 0
  145. out_dir.mkdir(parents=True, exist_ok=True)
  146. raw_dir.mkdir(parents=True, exist_ok=True)
  147. completed = set() if args.no_resume else existing_ok_values(csv_path)
  148. fields = ["central_mag_arc_ed", "torque_ripple_pct", "torque_ripple_nm",
  149. "average_torque_vw_nm", "cogging_ripple_vw_nm", "no_load_speed_rpm",
  150. "seconds", "status", "applied_value", "error"]
  151. new_file = not csv_path.exists() or args.no_resume
  152. mode = "w" if args.no_resume else "a"
  153. import ansys.motorcad.core as pymotorcad
  154. mc = pymotorcad.MotorCAD(port=args.port, open_new_instance=args.new_instance)
  155. mc.set_visible(True)
  156. mc.set_variable("MessageDisplayState", 2)
  157. mc.display_screen("Scripting")
  158. with csv_path.open(mode, encoding="utf-8-sig", newline="") as fh:
  159. writer = csv.DictWriter(fh, fieldnames=fields)
  160. if new_file:
  161. writer.writeheader()
  162. try:
  163. for index, value in enumerate(values, 1):
  164. if value in completed:
  165. print(f"[{index}/{len(values)}] {value:g} EDeg already complete; skipped", flush=True)
  166. continue
  167. started = time.time()
  168. row = {k: "" for k in fields}
  169. row.update(central_mag_arc_ed=value, status="FAILED")
  170. try:
  171. mc.load_from_file(str(model))
  172. mc.display_screen("Scripting")
  173. if args.torque_points:
  174. mc.set_variable("TorquePointsPerCycle", args.torque_points)
  175. if args.airgap_mesh:
  176. mc.set_variable("AirgapMeshPoints_mesh", args.airgap_mesh)
  177. mc.set_variable("AirgapMeshPoints_layers", args.airgap_mesh)
  178. mc.set_variable(PARAMETER, value)
  179. applied = float(mc.get_variable(PARAMETER))
  180. row["applied_value"] = applied
  181. if not math.isclose(applied, value, abs_tol=1e-6):
  182. raise RuntimeError(f"Parameter verification failed: wrote {value}, read {applied}")
  183. mc.do_magnetic_calculation()
  184. raw_path = raw_dir / f"central_mag_arc_{value:g}.csv"
  185. mc.export_results("EMagnetic", str(raw_path))
  186. result = parse_export(raw_path)
  187. row.update(
  188. torque_ripple_pct=pick(result, "Torque Ripple (VW) [%]"),
  189. torque_ripple_nm=pick(result, "Torque Ripple (VW)"),
  190. average_torque_vw_nm=pick(result, "Average torque (virtual work)"),
  191. cogging_ripple_vw_nm=pick(result, "Cogging Torque Ripple (Vw)"),
  192. no_load_speed_rpm=pick(result, "No load speed"),
  193. status="OK",
  194. )
  195. if row["torque_ripple_pct"] == "":
  196. raise RuntimeError("Torque Ripple (VW) [%] was not found in exported results")
  197. except Exception as exc: # noqa: BLE001
  198. row["status"] = "FAILED"
  199. row["error"] = f"{type(exc).__name__}: {exc}"
  200. traceback.print_exc()
  201. row["seconds"] = round(time.time() - started, 1)
  202. writer.writerow(row)
  203. fh.flush()
  204. print(f"[{index}/{len(values)}] {value:g} EDeg {row['status']} Ripple={row['torque_ripple_pct']}% Tavg={row['average_torque_vw_nm']} Nm {row['seconds']}s", flush=True)
  205. finally:
  206. mc.load_from_file(str(model))
  207. write_analysis(csv_path, analysis_path)
  208. print(f"Complete: {csv_path}\nAnalysis: {analysis_path}", flush=True)
  209. return 0
  210. if __name__ == "__main__":
  211. raise SystemExit(main())