motorcad_scan_gui.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. """Tkinter GUI for timestamped Motor-CAD parameter scans."""
  2. from __future__ import annotations
  3. import csv
  4. import json
  5. import math
  6. import os
  7. import queue
  8. import subprocess
  9. import sys
  10. import threading
  11. import time
  12. import traceback
  13. from datetime import datetime
  14. from pathlib import Path
  15. import tkinter as tk
  16. from tkinter import filedialog, messagebox, ttk
  17. from scan_central_mag_arc import parse_export, pick, values_inclusive
  18. try:
  19. from build_info import BUILD_COMMIT
  20. except ImportError:
  21. BUILD_COMMIT = "source-tree"
  22. APP_TITLE = "Motor-CAD Parameter Scan"
  23. DEFAULT_MODEL_NAME = "MARS-9S8P_SSSR_Halbach_PCB-V1.0.mot"
  24. PROFILE_FILE = "scan_parameters.json"
  25. METRICS = [
  26. ("ripple_pct", "Torque Ripple [%]", "Torque Ripple (VW) [%]"),
  27. ("ripple_nm", "Torque Ripple [Nm]", "Torque Ripple (VW)"),
  28. ("tavg_nm", "Tavg VW [Nm]", "Average torque (virtual work)"),
  29. ("efficiency_pct", "Efficiency [%]", "System Efficiency"),
  30. ("back_emf_v", "Back EMF LL rms [V]", "Back EMF Line-Line Voltage (rms)"),
  31. ("back_emf_thd_pct", "Back EMF THD [%]", "Harmonic Distortion Back EMF Line-Line Voltage"),
  32. ("total_losses_w", "Total losses [W]", "Total Losses (on load)"),
  33. ("copper_loss_w", "DC copper loss [W]", "Armature DC Copper Loss (on load)"),
  34. ("magnet_loss_w", "Magnet loss [W]", "Magnet Loss (on load)"),
  35. ("iron_loss_w", "Stator iron loss [W]", "Stator iron Loss [total] (on load)"),
  36. ("input_power_w", "Input power [W]", "Input Power"),
  37. ("output_power_w", "Output power [W]", "Output Power"),
  38. ("em_power_w", "EM power [W]", "Electromagnetic Power"),
  39. ("shaft_speed_rpm", "Shaft speed [rpm]", "Shaft Speed"),
  40. ("no_load_speed_rpm", "No-load speed [rpm]", "No load speed"),
  41. ]
  42. CSV_FIELDS = ["run_index", "parameter_1_label", "parameter_1_variable", "parameter_1_value",
  43. "parameter_2_label", "parameter_2_variable", "parameter_2_value",
  44. "linked_variable", "linked_value", "conductor_count", "clearance_mm"] + [
  45. item[0] for item in METRICS
  46. ] + ["seconds", "status", "error", "timestamp"]
  47. def app_root() -> Path:
  48. if getattr(sys, "frozen", False):
  49. return Path(sys.executable).resolve().parent
  50. return Path(__file__).resolve().parent
  51. def bundled_root() -> Path:
  52. return Path(getattr(sys, "_MEIPASS", app_root()))
  53. def default_model_path() -> Path:
  54. for base in (app_root(), *app_root().parents):
  55. candidate = base / DEFAULT_MODEL_NAME
  56. if candidate.exists():
  57. return candidate
  58. return app_root() / DEFAULT_MODEL_NAME
  59. def load_profiles() -> dict:
  60. external = app_root() / PROFILE_FILE
  61. bundled = bundled_root() / PROFILE_FILE
  62. path = external if external.exists() else bundled
  63. return json.loads(path.read_text(encoding="ascii"))
  64. def find_repo(start: Path) -> Path | None:
  65. for candidate in (start, *start.parents):
  66. if (candidate / ".git").exists():
  67. return candidate
  68. return None
  69. def git_preflight() -> tuple[bool, str]:
  70. repo = find_repo(app_root())
  71. if repo is None:
  72. if BUILD_COMMIT not in ("", "source-tree", "unknown"):
  73. return True, BUILD_COMMIT
  74. return False, "No Git repository or embedded build commit was found."
  75. safe = f"safe.directory={repo.as_posix()}"
  76. base = ["git", "-c", safe]
  77. try:
  78. commit = subprocess.check_output(base + ["rev-parse", "--short", "HEAD"], cwd=repo,
  79. text=True, stderr=subprocess.STDOUT).strip()
  80. dirty = subprocess.check_output(base + ["status", "--porcelain", "--untracked-files=no"],
  81. cwd=repo, text=True, stderr=subprocess.STDOUT).strip()
  82. except (OSError, subprocess.CalledProcessError) as exc:
  83. return False, f"Git preflight failed: {exc}"
  84. if dirty:
  85. return False, "Tracked files have uncommitted changes. Commit before starting a scan."
  86. return True, commit
  87. def metric_value(results: dict, key: str):
  88. value = pick(results, key)
  89. if value != "":
  90. return value
  91. wanted = key.lower().replace(" ", "")
  92. for section in results.values():
  93. for name, candidate in section.items():
  94. normalized = name.lower().replace(" ", "")
  95. if normalized.startswith(wanted):
  96. return candidate
  97. return ""
  98. class ScanWorker(threading.Thread):
  99. def __init__(self, config: dict, events: queue.Queue, cancel: threading.Event):
  100. super().__init__(daemon=True)
  101. self.config = config
  102. self.events = events
  103. self.cancel = cancel
  104. def emit(self, kind: str, payload=None) -> None:
  105. self.events.put((kind, payload))
  106. def run(self) -> None:
  107. mc = None
  108. log_file = None
  109. try:
  110. import ansys.motorcad.core as pymotorcad
  111. run_dir = Path(self.config["run_dir"])
  112. raw_dir = run_dir / "raw"
  113. raw_dir.mkdir(parents=True, exist_ok=True)
  114. csv_path = run_dir / f"scan_results_{self.config['timestamp']}.csv"
  115. log_path = run_dir / f"program_log_{self.config['timestamp']}.log"
  116. manifest_path = run_dir / f"run_manifest_{self.config['timestamp']}.json"
  117. manifest_path.write_text(json.dumps(self.config, indent=2), encoding="ascii")
  118. log_file = log_path.open("a", encoding="ascii", errors="backslashreplace")
  119. def log(text: str) -> None:
  120. stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
  121. line = f"{stamp} {text}"
  122. log_file.write(line + "\n")
  123. log_file.flush()
  124. self.emit("log", line)
  125. points = self.config["points"]
  126. log(f"Run directory: {run_dir}")
  127. log(f"Git commit: {self.config['git_commit']}")
  128. log("Opening a separate Motor-CAD instance")
  129. mc = pymotorcad.MotorCAD(open_new_instance=True, keep_instance_open=False)
  130. mc.set_visible(True)
  131. mc.set_variable("MessageDisplayState", 2)
  132. mc.display_screen("Scripting")
  133. with csv_path.open("w", newline="", encoding="utf-8-sig") as csv_file:
  134. writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS)
  135. writer.writeheader()
  136. csv_file.flush()
  137. for index, point in enumerate(points, 1):
  138. if self.cancel.is_set():
  139. log("Cancel requested; stopping before next point")
  140. break
  141. started = time.time()
  142. row = {field: "" for field in CSV_FIELDS}
  143. row.update(
  144. run_index=index,
  145. parameter_1_label=self.config["parameter_1"]["label"],
  146. parameter_1_variable=self.config["parameter_1"]["variable"],
  147. parameter_1_value=point["parameter_1_value"],
  148. parameter_2_label=self.config["parameter_2"]["label"],
  149. parameter_2_variable=self.config["parameter_2"]["variable"],
  150. parameter_2_value=point["parameter_2_value"],
  151. linked_variable=self.config["linked_value"]["variable"],
  152. linked_value=point["linked_value"],
  153. conductor_count=self.config["conductor_count"],
  154. clearance_mm=self.config["clearance"],
  155. status="FAILED",
  156. timestamp=datetime.now().isoformat(timespec="milliseconds"),
  157. )
  158. log(f"[{index}/{len(points)}] Starting "
  159. f"{self.config['parameter_1']['variable']}={point['parameter_1_value']:g}, "
  160. f"{self.config['parameter_2']['variable']}={point['parameter_2_value']:g}, "
  161. f"{self.config['linked_value']['variable']}={point['linked_value']:g}")
  162. try:
  163. mc.load_from_file(self.config["model"])
  164. mc.set_visible(True)
  165. mc.display_screen("Scripting")
  166. if self.config["torque_points"] > 0:
  167. mc.set_variable("TorquePointsPerCycle", self.config["torque_points"])
  168. if self.config["airgap_mesh"] > 0:
  169. mc.set_variable("AirgapMeshPoints_mesh", self.config["airgap_mesh"])
  170. mc.set_variable("AirgapMeshPoints_layers", self.config["airgap_mesh"])
  171. writes = (
  172. (self.config["parameter_2"]["variable"], point["parameter_2_value"]),
  173. (self.config["linked_value"]["variable"], point["linked_value"]),
  174. (self.config["parameter_1"]["variable"], point["parameter_1_value"]),
  175. )
  176. for variable, value in writes:
  177. mc.set_variable(variable, value)
  178. for variable, value in writes:
  179. applied = float(mc.get_variable(variable))
  180. if not math.isclose(applied, value, rel_tol=1e-8, abs_tol=1e-7):
  181. raise RuntimeError(
  182. f"Write verification failed for {variable}: wrote {value}, read {applied}"
  183. )
  184. mc.do_magnetic_calculation()
  185. raw_path = raw_dir / (
  186. f"result_{index:04d}_p1_{point['parameter_1_value']:g}_"
  187. f"p2_{point['parameter_2_value']:g}_{self.config['timestamp']}.csv"
  188. )
  189. mc.export_results("EMagnetic", str(raw_path))
  190. results = parse_export(raw_path)
  191. for field, _, result_name in METRICS:
  192. row[field] = metric_value(results, result_name)
  193. if row["ripple_pct"] == "":
  194. raise RuntimeError("Torque Ripple (VW) [%] was not found")
  195. row["status"] = "OK"
  196. except Exception as exc:
  197. row["error"] = f"{type(exc).__name__}: {exc}"
  198. log(row["error"])
  199. log(traceback.format_exc())
  200. row["seconds"] = round(time.time() - started, 1)
  201. writer.writerow(row)
  202. csv_file.flush()
  203. self.emit("row", row)
  204. self.emit("progress", (index, len(points)))
  205. log(f"[{index}/{len(points)}] {row['status']} ripple={row['ripple_pct']} "
  206. f"tavg={row['tavg_nm']} seconds={row['seconds']}")
  207. log(f"Scan ended. Results: {csv_path}")
  208. self.emit("done", {"run_dir": str(run_dir), "csv": str(csv_path), "log": str(log_path)})
  209. except Exception as exc:
  210. self.emit("fatal", f"{type(exc).__name__}: {exc}\n{traceback.format_exc()}")
  211. finally:
  212. if mc is not None:
  213. try:
  214. mc.load_from_file(self.config["model"])
  215. except Exception:
  216. pass
  217. if log_file is not None:
  218. log_file.close()
  219. class MotorCADScanApp(tk.Tk):
  220. def __init__(self):
  221. super().__init__()
  222. self.title(APP_TITLE)
  223. self.geometry("1500x850")
  224. self.minsize(1050, 650)
  225. self.profiles = load_profiles()
  226. self.events: queue.Queue = queue.Queue()
  227. self.cancel_event = threading.Event()
  228. self.worker: ScanWorker | None = None
  229. self.vars = {
  230. "model": tk.StringVar(value=str(default_model_path())),
  231. "profile": tk.StringVar(value=next(iter(self.profiles))),
  232. "p1_start": tk.StringVar(),
  233. "p1_stop": tk.StringVar(),
  234. "p1_step": tk.StringVar(),
  235. "p2_start": tk.StringVar(),
  236. "p2_stop": tk.StringVar(),
  237. "p2_step": tk.StringVar(),
  238. "conductor_count": tk.StringVar(),
  239. "clearance": tk.StringVar(),
  240. "total_runs": tk.StringVar(value="Total combinations: 0"),
  241. "torque_points": tk.StringVar(value="30"),
  242. "airgap_mesh": tk.StringVar(value="840"),
  243. "status": tk.StringVar(value="Ready"),
  244. "commit": tk.StringVar(value=BUILD_COMMIT),
  245. }
  246. self.build_ui()
  247. self.profile_changed()
  248. self.after(150, self.poll_events)
  249. def build_ui(self) -> None:
  250. controls = ttk.LabelFrame(self, text="Scan setup", padding=8)
  251. controls.pack(fill="x", padx=8, pady=6)
  252. ttk.Label(controls, text="Model").grid(row=0, column=0, sticky="w")
  253. ttk.Entry(controls, textvariable=self.vars["model"], width=100).grid(row=0, column=1, sticky="ew", padx=4)
  254. ttk.Button(controls, text="Browse", command=self.browse_model).grid(row=0, column=2, padx=4)
  255. ttk.Label(controls, text="Scan profile").grid(row=1, column=0, sticky="w")
  256. combo = ttk.Combobox(controls, textvariable=self.vars["profile"], values=list(self.profiles), state="readonly", width=32)
  257. combo.grid(row=1, column=1, sticky="w", padx=4)
  258. combo.bind("<<ComboboxSelected>>", lambda _: self.profile_changed())
  259. p1_frame = ttk.LabelFrame(controls, text="Parameter 1: Central Mag Arc [ED]", padding=4)
  260. p1_frame.grid(row=2, column=1, columnspan=2, sticky="w", padx=4, pady=(6, 2))
  261. for col, (label, key) in enumerate((("Start", "p1_start"), ("Stop", "p1_stop"), ("Step", "p1_step"))):
  262. group = ttk.Frame(p1_frame)
  263. group.grid(row=0, column=col, padx=(0, 18), sticky="w")
  264. ttk.Label(group, text=label, width=6).pack(side="left")
  265. entry = ttk.Entry(group, textvariable=self.vars[key], width=12)
  266. entry.pack(side="left")
  267. entry.bind("<KeyRelease>", lambda _: self.update_total())
  268. p2_frame = ttk.LabelFrame(controls, text="Parameter 2: Slot Opening [mm]", padding=4)
  269. p2_frame.grid(row=3, column=1, columnspan=2, sticky="w", padx=4, pady=2)
  270. for col, (label, key) in enumerate((("Start", "p2_start"), ("Stop", "p2_stop"), ("Step", "p2_step"))):
  271. group = ttk.Frame(p2_frame)
  272. group.grid(row=0, column=col, padx=(0, 18), sticky="w")
  273. ttk.Label(group, text=label, width=6).pack(side="left")
  274. entry = ttk.Entry(group, textvariable=self.vars[key], width=12)
  275. entry.pack(side="left")
  276. entry.bind("<KeyRelease>", lambda _: self.update_total())
  277. link_frame = ttk.LabelFrame(controls, text="Linked copper width", padding=4)
  278. link_frame.grid(row=4, column=1, columnspan=2, sticky="w", padx=4, pady=2)
  279. for col, (label, key) in enumerate((("Conductors per side", "conductor_count"),
  280. ("Clearance [mm]", "clearance"))):
  281. group = ttk.Frame(link_frame)
  282. group.grid(row=0, column=col, padx=(0, 24), sticky="w")
  283. ttk.Label(group, text=label, width=20).pack(side="left")
  284. ttk.Entry(group, textvariable=self.vars[key], width=12).pack(side="left")
  285. ttk.Label(link_frame, text="Copper Width = (Slot Opening - Clearance) / 2 / Conductors").grid(
  286. row=1, column=0, columnspan=2, sticky="w", pady=(5, 0)
  287. )
  288. solver_frame = ttk.Frame(controls)
  289. solver_frame.grid(row=5, column=1, columnspan=2, sticky="w", padx=4, pady=(6, 2))
  290. for col, (label, key) in enumerate((("Torque points", "torque_points"),
  291. ("Airgap mesh", "airgap_mesh"))):
  292. group = ttk.Frame(solver_frame)
  293. group.grid(row=0, column=col, padx=(0, 28), sticky="w")
  294. ttk.Label(group, text=label, width=15).pack(side="left")
  295. ttk.Entry(group, textvariable=self.vars[key], width=12).pack(side="left")
  296. ttk.Label(solver_frame, textvariable=self.vars["total_runs"]).grid(row=1, column=0, columnspan=2, sticky="w", pady=(6, 0))
  297. controls.columnconfigure(1, weight=1)
  298. actions = ttk.Frame(self, padding=(8, 2))
  299. actions.pack(fill="x")
  300. self.start_button = ttk.Button(actions, text="Start scan", command=self.start_scan)
  301. self.start_button.pack(side="left")
  302. self.cancel_button = ttk.Button(actions, text="Stop after current point", command=self.cancel_scan, state="disabled")
  303. self.cancel_button.pack(side="left", padx=6)
  304. ttk.Label(actions, textvariable=self.vars["status"]).pack(side="left", padx=12)
  305. git_frame = ttk.Frame(actions)
  306. git_frame.pack(side="right")
  307. ttk.Label(git_frame, text="Git:").pack(side="left")
  308. ttk.Label(git_frame, textvariable=self.vars["commit"]).pack(side="left", padx=4)
  309. self.progress = ttk.Progressbar(self, mode="determinate")
  310. self.progress.pack(fill="x", padx=8, pady=4)
  311. table_frame = ttk.Frame(self)
  312. table_frame.pack(fill="both", expand=True, padx=8, pady=4)
  313. columns = ["run_index", "parameter_1_value", "parameter_2_value", "linked_value"] + [item[0] for item in METRICS] + ["seconds", "status"]
  314. self.tree = ttk.Treeview(table_frame, columns=columns, show="headings", height=16)
  315. headings = {"run_index": "#", "parameter_1_value": "Central Arc [ED]",
  316. "parameter_2_value": "Slot Opening [mm]", "linked_value": "Copper Width [mm]",
  317. "seconds": "Seconds", "status": "Status"}
  318. headings.update({field: label for field, label, _ in METRICS})
  319. for column in columns:
  320. self.tree.heading(column, text=headings[column])
  321. self.tree.column(column, width=105, anchor="center", stretch=False)
  322. self.tree.column("run_index", width=45)
  323. self.tree.column("status", width=70)
  324. ybar = ttk.Scrollbar(table_frame, orient="vertical", command=self.tree.yview)
  325. xbar = ttk.Scrollbar(table_frame, orient="horizontal", command=self.tree.xview)
  326. self.tree.configure(yscrollcommand=ybar.set, xscrollcommand=xbar.set)
  327. self.tree.grid(row=0, column=0, sticky="nsew")
  328. ybar.grid(row=0, column=1, sticky="ns")
  329. xbar.grid(row=1, column=0, sticky="ew")
  330. table_frame.rowconfigure(0, weight=1)
  331. table_frame.columnconfigure(0, weight=1)
  332. log_frame = ttk.LabelFrame(self, text="Program log", padding=4)
  333. log_frame.pack(fill="both", expand=False, padx=8, pady=(2, 8))
  334. self.log_text = tk.Text(log_frame, height=9, wrap="none")
  335. self.log_text.pack(fill="both", expand=True)
  336. def browse_model(self) -> None:
  337. path = filedialog.askopenfilename(title="Select Motor-CAD model", filetypes=[("Motor-CAD model", "*.mot"), ("All files", "*.*")])
  338. if path:
  339. self.vars["model"].set(path)
  340. def profile_changed(self) -> None:
  341. profile = self.profiles[self.vars["profile"].get()]
  342. p1 = profile["parameter_1"]
  343. p2 = profile["parameter_2"]
  344. linked = profile["linked_value"]
  345. for prefix, parameter in (("p1", p1), ("p2", p2)):
  346. self.vars[f"{prefix}_start"].set(str(parameter["default_start"]))
  347. self.vars[f"{prefix}_stop"].set(str(parameter["default_stop"]))
  348. self.vars[f"{prefix}_step"].set(str(parameter["default_step"]))
  349. self.vars["conductor_count"].set(str(linked["default_conductor_count"]))
  350. self.vars["clearance"].set(str(linked["default_clearance"]))
  351. self.update_total()
  352. def update_total(self) -> None:
  353. try:
  354. p1_values = values_inclusive(float(self.vars["p1_start"].get()),
  355. float(self.vars["p1_stop"].get()),
  356. float(self.vars["p1_step"].get()))
  357. p2_values = values_inclusive(float(self.vars["p2_start"].get()),
  358. float(self.vars["p2_stop"].get()),
  359. float(self.vars["p2_step"].get()))
  360. self.vars["total_runs"].set(
  361. f"Total combinations: {len(p1_values)} x {len(p2_values)} = {len(p1_values) * len(p2_values)}"
  362. )
  363. except ValueError:
  364. self.vars["total_runs"].set("Total combinations: invalid range")
  365. def append_log(self, text: str) -> None:
  366. self.log_text.insert("end", text + "\n")
  367. self.log_text.see("end")
  368. def start_scan(self) -> None:
  369. ok, commit = git_preflight()
  370. if not ok:
  371. messagebox.showerror("Git preflight failed", commit)
  372. return
  373. try:
  374. model = Path(self.vars["model"].get()).resolve()
  375. if not model.exists():
  376. raise ValueError(f"Model does not exist: {model}")
  377. p1_start = float(self.vars["p1_start"].get())
  378. p1_stop = float(self.vars["p1_stop"].get())
  379. p1_step = float(self.vars["p1_step"].get())
  380. p2_start = float(self.vars["p2_start"].get())
  381. p2_stop = float(self.vars["p2_stop"].get())
  382. p2_step = float(self.vars["p2_step"].get())
  383. conductor_count = int(self.vars["conductor_count"].get())
  384. clearance = float(self.vars["clearance"].get())
  385. torque_points = int(self.vars["torque_points"].get())
  386. airgap_mesh = int(self.vars["airgap_mesh"].get())
  387. p1_values = values_inclusive(p1_start, p1_stop, p1_step)
  388. p2_values = values_inclusive(p2_start, p2_stop, p2_step)
  389. profile = self.profiles[self.vars["profile"].get()]
  390. p1 = profile["parameter_1"]
  391. p2 = profile["parameter_2"]
  392. linked = profile["linked_value"]
  393. if p1_values[0] < p1["minimum"] or p1_values[-1] > p1["maximum"]:
  394. raise ValueError(f"Parameter 1 must stay within {p1['minimum']}..{p1['maximum']} {p1['unit']}")
  395. if p2_values[0] < p2["minimum"] or p2_values[-1] > p2["maximum"]:
  396. raise ValueError(f"Parameter 2 must stay within {p2['minimum']}..{p2['maximum']} {p2['unit']}")
  397. if conductor_count < 1:
  398. raise ValueError("Conductor count must be at least 1")
  399. if clearance < 0 or clearance >= p2_values[0]:
  400. raise ValueError("Clearance must be non-negative and smaller than every slot opening")
  401. points = []
  402. for slot_opening in p2_values:
  403. copper_width = round((slot_opening - clearance) / 2.0 / conductor_count, 10)
  404. for magnet_arc in p1_values:
  405. points.append({
  406. "parameter_1_value": magnet_arc,
  407. "parameter_2_value": slot_opening,
  408. "linked_value": copper_width,
  409. })
  410. except ValueError as exc:
  411. messagebox.showerror("Invalid settings", str(exc))
  412. return
  413. estimated_hours = len(points) * 180.0 / 3600.0
  414. proceed = messagebox.askyesno(
  415. "Confirm two-parameter scan",
  416. f"Parameter 1 levels: {len(p1_values)}\n"
  417. f"Parameter 2 levels: {len(p2_values)}\n"
  418. f"Total simulations: {len(points)}\n"
  419. f"Rough duration at 3 minutes per point: {estimated_hours:.1f} hours\n\n"
  420. "Start the scan?",
  421. )
  422. if not proceed:
  423. return
  424. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
  425. run_dir = app_root() / "runs" / f"{timestamp}_two_parameter_scan"
  426. config = {
  427. "timestamp": timestamp,
  428. "run_dir": str(run_dir),
  429. "model": str(model),
  430. "profile": self.vars["profile"].get(),
  431. "parameter_1": p1,
  432. "parameter_2": p2,
  433. "linked_value": linked,
  434. "points": points,
  435. "conductor_count": conductor_count,
  436. "clearance": clearance,
  437. "torque_points": torque_points,
  438. "airgap_mesh": airgap_mesh,
  439. "git_commit": commit,
  440. }
  441. for item in self.tree.get_children():
  442. self.tree.delete(item)
  443. self.log_text.delete("1.0", "end")
  444. self.progress.configure(maximum=len(points), value=0)
  445. self.vars["commit"].set(commit)
  446. self.vars["status"].set(f"Running 0/{len(points)}")
  447. self.cancel_event.clear()
  448. self.start_button.configure(state="disabled")
  449. self.cancel_button.configure(state="normal")
  450. self.worker = ScanWorker(config, self.events, self.cancel_event)
  451. self.worker.start()
  452. def cancel_scan(self) -> None:
  453. self.cancel_event.set()
  454. self.cancel_button.configure(state="disabled")
  455. self.vars["status"].set("Stop requested; waiting for current point")
  456. def poll_events(self) -> None:
  457. try:
  458. while True:
  459. kind, payload = self.events.get_nowait()
  460. if kind == "log":
  461. self.append_log(payload)
  462. elif kind == "row":
  463. columns = ["run_index", "parameter_1_value", "parameter_2_value", "linked_value"] + [
  464. item[0] for item in METRICS
  465. ] + ["seconds", "status"]
  466. self.tree.insert("", "end", values=[payload.get(column, "") for column in columns])
  467. self.tree.yview_moveto(1.0)
  468. elif kind == "progress":
  469. current, total = payload
  470. self.progress.configure(value=current)
  471. self.vars["status"].set(f"Running {current}/{total}")
  472. elif kind == "done":
  473. self.finish_run(f"Complete: {payload['run_dir']}")
  474. messagebox.showinfo("Scan complete", f"Results saved in:\n{payload['run_dir']}")
  475. elif kind == "fatal":
  476. self.finish_run("Failed")
  477. self.append_log(payload)
  478. messagebox.showerror("Scan failed", payload)
  479. except queue.Empty:
  480. pass
  481. self.after(150, self.poll_events)
  482. def finish_run(self, status: str) -> None:
  483. self.vars["status"].set(status)
  484. self.start_button.configure(state="normal")
  485. self.cancel_button.configure(state="disabled")
  486. def main() -> int:
  487. app = MotorCADScanApp()
  488. app.mainloop()
  489. return 0
  490. if __name__ == "__main__":
  491. raise SystemExit(main())