scan_engine.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. """Parameter scan engine for PCB axial flux motor simulation.
  2. Supports single-parameter and multi-parameter Cartesian product scans
  3. with per-point baseline reload, write-back verification, checkpoint
  4. resume, and per-point result persistence (CSV + raw + manifest + log).
  5. Built on top of MotorCADSolver from solver_core.
  6. All source is ASCII.
  7. """
  8. from __future__ import annotations
  9. import csv
  10. import json
  11. import math
  12. import time
  13. import traceback
  14. from datetime import datetime
  15. from pathlib import Path
  16. from typing import Callable
  17. from .solver_core import (
  18. MotorCADSolver,
  19. METRIC_KEYS,
  20. METRIC_LABELS,
  21. )
  22. # ---------------------------------------------------------------------------
  23. # Utility functions
  24. # ---------------------------------------------------------------------------
  25. def values_inclusive(start: float, stop: float, step: float) -> list[float]:
  26. """Generate values from start to stop inclusive, appending stop if
  27. it is not exactly reachable by integer steps."""
  28. if step <= 0 or stop < start:
  29. raise ValueError("step must be positive and stop must be >= start")
  30. count = int(math.floor((stop - start) / step + 1e-9))
  31. values = [round(start + i * step, 10) for i in range(count + 1)]
  32. if not math.isclose(values[-1], stop, abs_tol=1e-9):
  33. values.append(float(stop))
  34. return values
  35. def generate_cartesian_points(variables: list[dict]) -> list[dict]:
  36. """Generate all parameter combinations via Cartesian product.
  37. Each variable dict: {"name": str, "display_name": str, "values": [float]}
  38. Returns list of {"index": int, "params": {var_name: value, ...}}
  39. """
  40. if not variables:
  41. return [{"index": 1, "params": {}}]
  42. # Build list of (name, values) pairs
  43. var_list = [(v["name"], v["values"]) for v in variables]
  44. # Recursive Cartesian product
  45. def _combine(idx: int, current: dict) -> list[dict]:
  46. if idx == len(var_list):
  47. return [{"params": dict(current)}]
  48. name, vals = var_list[idx]
  49. results = []
  50. for v in vals:
  51. current[name] = v
  52. results.extend(_combine(idx + 1, current))
  53. return results
  54. points = _combine(0, {})
  55. for i, p in enumerate(points, 1):
  56. p["index"] = i
  57. return points
  58. def estimate_total_time(points: list[dict], per_point_s: float = 140.0) -> dict:
  59. """Estimate total scan time. Returns dict with count, per_point_s, total_s, total_h."""
  60. n = len(points)
  61. total_s = n * per_point_s
  62. return {
  63. "count": n,
  64. "per_point_s": per_point_s,
  65. "total_s": round(total_s, 1),
  66. "total_min": round(total_s / 60, 1),
  67. "total_h": round(total_s / 3600, 2),
  68. }
  69. # ---------------------------------------------------------------------------
  70. # Scan engine
  71. # ---------------------------------------------------------------------------
  72. class ScanEngine:
  73. """Runs a parameter scan using a connected MotorCADSolver.
  74. Per point: reload baseline model -> write params (with write-back
  75. verify) -> magnetic calculation -> export results -> extract metrics
  76. -> append CSV row (flushed immediately).
  77. Supports checkpoint resume: points already present in the output CSV
  78. with status OK are skipped.
  79. """
  80. def __init__(
  81. self,
  82. solver: MotorCADSolver,
  83. model_path: str | Path,
  84. output_dir: str | Path = "output",
  85. scan_name: str = "scan",
  86. log_cb: Callable[[str], None] | None = None,
  87. progress_cb: Callable[[int, int], None] | None = None,
  88. row_cb: Callable[[dict], None] | None = None,
  89. ):
  90. self.solver = solver
  91. self.model_path = str(Path(model_path).resolve())
  92. self.output_dir = Path(output_dir)
  93. self.scan_name = scan_name
  94. self.log_cb = log_cb
  95. self.progress_cb = progress_cb
  96. self.row_cb = row_cb
  97. self._cancel = False
  98. def _log(self, text: str) -> None:
  99. if self.log_cb:
  100. self.log_cb(text)
  101. def cancel(self) -> None:
  102. """Request cancellation after the current point finishes."""
  103. self._cancel = True
  104. def _csv_fields(self, var_names: list[str]) -> list[str]:
  105. """Build CSV field list: index, status, seconds, error, params, metrics."""
  106. return ["run_index", "status", "seconds", "error"] + var_names + METRIC_KEYS
  107. def _load_completed_indices(self, csv_path: Path) -> set[int]:
  108. """Load set of already-completed (OK) point indices from CSV."""
  109. completed = set()
  110. if not csv_path.exists():
  111. return completed
  112. try:
  113. with open(csv_path, "r", encoding="utf-8-sig", newline="") as f:
  114. reader = csv.DictReader(f)
  115. for row in reader:
  116. if row.get("status") == "OK":
  117. try:
  118. completed.add(int(row["run_index"]))
  119. except (ValueError, KeyError):
  120. pass
  121. except Exception:
  122. pass
  123. return completed
  124. def run(
  125. self,
  126. points: list[dict],
  127. var_names: list[str] | None = None,
  128. ) -> dict:
  129. """Run the full scan.
  130. Args:
  131. points: list of {"index": int, "params": {var_name: value}}
  132. var_names: ordered list of variable names for CSV columns.
  133. If None, extracted from first point's params keys.
  134. Returns:
  135. dict with run_dir, csv_path, log_path, manifest_path, summary.
  136. """
  137. if var_names is None:
  138. var_names = list(points[0]["params"].keys()) if points else []
  139. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
  140. run_dir = self.output_dir / f"{timestamp}_{self.scan_name}"
  141. raw_dir = run_dir / "raw"
  142. run_dir.mkdir(parents=True, exist_ok=True)
  143. raw_dir.mkdir(parents=True, exist_ok=True)
  144. csv_path = run_dir / f"scan_results_{timestamp}.csv"
  145. log_path = run_dir / f"program_log_{timestamp}.log"
  146. manifest_path = run_dir / f"run_manifest_{timestamp}.json"
  147. csv_fields = self._csv_fields(var_names)
  148. # Checkpoint: load already completed indices
  149. completed = self._load_completed_indices(csv_path)
  150. if completed:
  151. self._log(f"Checkpoint: {len(completed)} points already completed, will skip")
  152. # Write manifest
  153. manifest = {
  154. "timestamp": timestamp,
  155. "model": self.model_path,
  156. "scan_name": self.scan_name,
  157. "total_points": len(points),
  158. "variable_names": var_names,
  159. "points": [
  160. {
  161. "index": p["index"],
  162. "params": p["params"],
  163. }
  164. for p in points
  165. ],
  166. }
  167. manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
  168. # Open log file
  169. log_file = open(log_path, "a", encoding="ascii", errors="backslashreplace")
  170. def _file_log(text: str) -> None:
  171. stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
  172. line = f"{stamp} {text}"
  173. log_file.write(line + "\n")
  174. log_file.flush()
  175. self._log(line)
  176. _file_log(f"Scan started: {self.scan_name}")
  177. _file_log(f"Model: {self.model_path}")
  178. _file_log(f"Total points: {len(points)}")
  179. _file_log(f"Variables: {var_names}")
  180. _file_log(f"Run directory: {run_dir}")
  181. summary = {"ok": 0, "failed": 0, "skipped": 0, "results": []}
  182. try:
  183. # Open CSV for appending (checkpoint support)
  184. file_exists = csv_path.exists()
  185. with open(csv_path, "a", newline="", encoding="utf-8-sig") as csv_file:
  186. writer = csv.DictWriter(csv_file, fieldnames=csv_fields)
  187. if not file_exists:
  188. writer.writeheader()
  189. csv_file.flush()
  190. for point in points:
  191. if self._cancel:
  192. _file_log("Cancel requested; stopping before next point")
  193. break
  194. idx = point["index"]
  195. params = point["params"]
  196. # Checkpoint skip
  197. if idx in completed:
  198. summary["skipped"] += 1
  199. _file_log(f"[{idx}/{len(points)}] SKIP (already completed)")
  200. if self.progress_cb:
  201. self.progress_cb(idx, len(points))
  202. continue
  203. param_str = ", ".join(f"{k}={v:g}" for k, v in params.items())
  204. _file_log(f"[{idx}/{len(points)}] {param_str}")
  205. # Run single point via solver
  206. result = self.solver.run_single(
  207. model_path=self.model_path,
  208. params=params,
  209. output_dir=raw_dir,
  210. tag=f"pt{idx:04d}",
  211. )
  212. # Build CSV row
  213. row = {field: "" for field in csv_fields}
  214. row["run_index"] = idx
  215. row["status"] = result["status"]
  216. row["seconds"] = result["solve_time_s"]
  217. row["error"] = result.get("error", "")
  218. for k, v in params.items():
  219. if k in row:
  220. row[k] = v
  221. for k, v in result.get("metrics", {}).items():
  222. if k in row:
  223. row[k] = v
  224. writer.writerow(row)
  225. csv_file.flush()
  226. if result["status"] == "OK":
  227. summary["ok"] += 1
  228. else:
  229. summary["failed"] += 1
  230. summary["results"].append(row)
  231. if self.row_cb:
  232. self.row_cb(row)
  233. if self.progress_cb:
  234. self.progress_cb(idx, len(points))
  235. ripple = row.get("ripple_pct", "")
  236. tavg = row.get("tavg_nm", "")
  237. eff = row.get("efficiency_pct", "")
  238. _file_log(
  239. f"[{idx}/{len(points)}] {row['status']} "
  240. f"Tavg={tavg} ripple={ripple} eff={eff} "
  241. f"seconds={row['seconds']}"
  242. )
  243. _file_log(f"Scan ended. OK={summary['ok']} FAILED={summary['failed']} SKIPPED={summary['skipped']}")
  244. _file_log(f"Results CSV: {csv_path}")
  245. except Exception as exc:
  246. _file_log(f"FATAL: {type(exc).__name__}: {exc}")
  247. _file_log(traceback.format_exc())
  248. raise
  249. finally:
  250. log_file.close()
  251. return {
  252. "run_dir": str(run_dir),
  253. "csv_path": str(csv_path),
  254. "log_path": str(log_path),
  255. "manifest_path": str(manifest_path),
  256. "summary": summary,
  257. }