|
|
@@ -0,0 +1,310 @@
|
|
|
+"""Parameter scan engine for PCB axial flux motor simulation.
|
|
|
+
|
|
|
+Supports single-parameter and multi-parameter Cartesian product scans
|
|
|
+with per-point baseline reload, write-back verification, checkpoint
|
|
|
+resume, and per-point result persistence (CSV + raw + manifest + log).
|
|
|
+
|
|
|
+Built on top of MotorCADSolver from solver_core.
|
|
|
+
|
|
|
+All source is ASCII.
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import csv
|
|
|
+import json
|
|
|
+import math
|
|
|
+import time
|
|
|
+import traceback
|
|
|
+from datetime import datetime
|
|
|
+from pathlib import Path
|
|
|
+from typing import Callable
|
|
|
+
|
|
|
+from .solver_core import (
|
|
|
+ MotorCADSolver,
|
|
|
+ METRIC_KEYS,
|
|
|
+ METRIC_LABELS,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# Utility functions
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+def values_inclusive(start: float, stop: float, step: float) -> list[float]:
|
|
|
+ """Generate values from start to stop inclusive, appending stop if
|
|
|
+ it is not exactly reachable by integer steps."""
|
|
|
+ if step <= 0 or stop < start:
|
|
|
+ raise ValueError("step must be positive and stop must be >= start")
|
|
|
+ count = int(math.floor((stop - start) / step + 1e-9))
|
|
|
+ values = [round(start + i * step, 10) for i in range(count + 1)]
|
|
|
+ if not math.isclose(values[-1], stop, abs_tol=1e-9):
|
|
|
+ values.append(float(stop))
|
|
|
+ return values
|
|
|
+
|
|
|
+
|
|
|
+def generate_cartesian_points(variables: list[dict]) -> list[dict]:
|
|
|
+ """Generate all parameter combinations via Cartesian product.
|
|
|
+
|
|
|
+ Each variable dict: {"name": str, "display_name": str, "values": [float]}
|
|
|
+ Returns list of {"index": int, "params": {var_name: value, ...}}
|
|
|
+ """
|
|
|
+ if not variables:
|
|
|
+ return [{"index": 1, "params": {}}]
|
|
|
+
|
|
|
+ # Build list of (name, values) pairs
|
|
|
+ var_list = [(v["name"], v["values"]) for v in variables]
|
|
|
+
|
|
|
+ # Recursive Cartesian product
|
|
|
+ def _combine(idx: int, current: dict) -> list[dict]:
|
|
|
+ if idx == len(var_list):
|
|
|
+ return [{"params": dict(current)}]
|
|
|
+ name, vals = var_list[idx]
|
|
|
+ results = []
|
|
|
+ for v in vals:
|
|
|
+ current[name] = v
|
|
|
+ results.extend(_combine(idx + 1, current))
|
|
|
+ return results
|
|
|
+
|
|
|
+ points = _combine(0, {})
|
|
|
+ for i, p in enumerate(points, 1):
|
|
|
+ p["index"] = i
|
|
|
+ return points
|
|
|
+
|
|
|
+
|
|
|
+def estimate_total_time(points: list[dict], per_point_s: float = 140.0) -> dict:
|
|
|
+ """Estimate total scan time. Returns dict with count, per_point_s, total_s, total_h."""
|
|
|
+ n = len(points)
|
|
|
+ total_s = n * per_point_s
|
|
|
+ return {
|
|
|
+ "count": n,
|
|
|
+ "per_point_s": per_point_s,
|
|
|
+ "total_s": round(total_s, 1),
|
|
|
+ "total_min": round(total_s / 60, 1),
|
|
|
+ "total_h": round(total_s / 3600, 2),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# Scan engine
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+class ScanEngine:
|
|
|
+ """Runs a parameter scan using a connected MotorCADSolver.
|
|
|
+
|
|
|
+ Per point: reload baseline model -> write params (with write-back
|
|
|
+ verify) -> magnetic calculation -> export results -> extract metrics
|
|
|
+ -> append CSV row (flushed immediately).
|
|
|
+
|
|
|
+ Supports checkpoint resume: points already present in the output CSV
|
|
|
+ with status OK are skipped.
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ solver: MotorCADSolver,
|
|
|
+ model_path: str | Path,
|
|
|
+ output_dir: str | Path = "output",
|
|
|
+ scan_name: str = "scan",
|
|
|
+ log_cb: Callable[[str], None] | None = None,
|
|
|
+ progress_cb: Callable[[int, int], None] | None = None,
|
|
|
+ row_cb: Callable[[dict], None] | None = None,
|
|
|
+ ):
|
|
|
+ self.solver = solver
|
|
|
+ self.model_path = str(Path(model_path).resolve())
|
|
|
+ self.output_dir = Path(output_dir)
|
|
|
+ self.scan_name = scan_name
|
|
|
+ self.log_cb = log_cb
|
|
|
+ self.progress_cb = progress_cb
|
|
|
+ self.row_cb = row_cb
|
|
|
+ self._cancel = False
|
|
|
+
|
|
|
+ def _log(self, text: str) -> None:
|
|
|
+ if self.log_cb:
|
|
|
+ self.log_cb(text)
|
|
|
+
|
|
|
+ def cancel(self) -> None:
|
|
|
+ """Request cancellation after the current point finishes."""
|
|
|
+ self._cancel = True
|
|
|
+
|
|
|
+ def _csv_fields(self, var_names: list[str]) -> list[str]:
|
|
|
+ """Build CSV field list: index, status, seconds, error, params, metrics."""
|
|
|
+ return ["run_index", "status", "seconds", "error"] + var_names + METRIC_KEYS
|
|
|
+
|
|
|
+ def _load_completed_indices(self, csv_path: Path) -> set[int]:
|
|
|
+ """Load set of already-completed (OK) point indices from CSV."""
|
|
|
+ completed = set()
|
|
|
+ if not csv_path.exists():
|
|
|
+ return completed
|
|
|
+ try:
|
|
|
+ with open(csv_path, "r", encoding="utf-8-sig", newline="") as f:
|
|
|
+ reader = csv.DictReader(f)
|
|
|
+ for row in reader:
|
|
|
+ if row.get("status") == "OK":
|
|
|
+ try:
|
|
|
+ completed.add(int(row["run_index"]))
|
|
|
+ except (ValueError, KeyError):
|
|
|
+ pass
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ return completed
|
|
|
+
|
|
|
+ def run(
|
|
|
+ self,
|
|
|
+ points: list[dict],
|
|
|
+ var_names: list[str] | None = None,
|
|
|
+ ) -> dict:
|
|
|
+ """Run the full scan.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ points: list of {"index": int, "params": {var_name: value}}
|
|
|
+ var_names: ordered list of variable names for CSV columns.
|
|
|
+ If None, extracted from first point's params keys.
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ dict with run_dir, csv_path, log_path, manifest_path, summary.
|
|
|
+ """
|
|
|
+ if var_names is None:
|
|
|
+ var_names = list(points[0]["params"].keys()) if points else []
|
|
|
+
|
|
|
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
|
|
|
+ run_dir = self.output_dir / f"{timestamp}_{self.scan_name}"
|
|
|
+ raw_dir = run_dir / "raw"
|
|
|
+ run_dir.mkdir(parents=True, exist_ok=True)
|
|
|
+ raw_dir.mkdir(parents=True, exist_ok=True)
|
|
|
+
|
|
|
+ csv_path = run_dir / f"scan_results_{timestamp}.csv"
|
|
|
+ log_path = run_dir / f"program_log_{timestamp}.log"
|
|
|
+ manifest_path = run_dir / f"run_manifest_{timestamp}.json"
|
|
|
+
|
|
|
+ csv_fields = self._csv_fields(var_names)
|
|
|
+
|
|
|
+ # Checkpoint: load already completed indices
|
|
|
+ completed = self._load_completed_indices(csv_path)
|
|
|
+ if completed:
|
|
|
+ self._log(f"Checkpoint: {len(completed)} points already completed, will skip")
|
|
|
+
|
|
|
+ # Write manifest
|
|
|
+ manifest = {
|
|
|
+ "timestamp": timestamp,
|
|
|
+ "model": self.model_path,
|
|
|
+ "scan_name": self.scan_name,
|
|
|
+ "total_points": len(points),
|
|
|
+ "variable_names": var_names,
|
|
|
+ "points": [
|
|
|
+ {
|
|
|
+ "index": p["index"],
|
|
|
+ "params": p["params"],
|
|
|
+ }
|
|
|
+ for p in points
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
|
+
|
|
|
+ # Open log file
|
|
|
+ log_file = open(log_path, "a", encoding="ascii", errors="backslashreplace")
|
|
|
+
|
|
|
+ def _file_log(text: str) -> None:
|
|
|
+ stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
|
+ line = f"{stamp} {text}"
|
|
|
+ log_file.write(line + "\n")
|
|
|
+ log_file.flush()
|
|
|
+ self._log(line)
|
|
|
+
|
|
|
+ _file_log(f"Scan started: {self.scan_name}")
|
|
|
+ _file_log(f"Model: {self.model_path}")
|
|
|
+ _file_log(f"Total points: {len(points)}")
|
|
|
+ _file_log(f"Variables: {var_names}")
|
|
|
+ _file_log(f"Run directory: {run_dir}")
|
|
|
+
|
|
|
+ summary = {"ok": 0, "failed": 0, "skipped": 0, "results": []}
|
|
|
+
|
|
|
+ try:
|
|
|
+ # Open CSV for appending (checkpoint support)
|
|
|
+ file_exists = csv_path.exists()
|
|
|
+ with open(csv_path, "a", newline="", encoding="utf-8-sig") as csv_file:
|
|
|
+ writer = csv.DictWriter(csv_file, fieldnames=csv_fields)
|
|
|
+ if not file_exists:
|
|
|
+ writer.writeheader()
|
|
|
+ csv_file.flush()
|
|
|
+
|
|
|
+ for point in points:
|
|
|
+ if self._cancel:
|
|
|
+ _file_log("Cancel requested; stopping before next point")
|
|
|
+ break
|
|
|
+
|
|
|
+ idx = point["index"]
|
|
|
+ params = point["params"]
|
|
|
+
|
|
|
+ # Checkpoint skip
|
|
|
+ if idx in completed:
|
|
|
+ summary["skipped"] += 1
|
|
|
+ _file_log(f"[{idx}/{len(points)}] SKIP (already completed)")
|
|
|
+ if self.progress_cb:
|
|
|
+ self.progress_cb(idx, len(points))
|
|
|
+ continue
|
|
|
+
|
|
|
+ param_str = ", ".join(f"{k}={v:g}" for k, v in params.items())
|
|
|
+ _file_log(f"[{idx}/{len(points)}] {param_str}")
|
|
|
+
|
|
|
+ # Run single point via solver
|
|
|
+ result = self.solver.run_single(
|
|
|
+ model_path=self.model_path,
|
|
|
+ params=params,
|
|
|
+ output_dir=raw_dir,
|
|
|
+ tag=f"pt{idx:04d}",
|
|
|
+ )
|
|
|
+
|
|
|
+ # Build CSV row
|
|
|
+ row = {field: "" for field in csv_fields}
|
|
|
+ row["run_index"] = idx
|
|
|
+ row["status"] = result["status"]
|
|
|
+ row["seconds"] = result["solve_time_s"]
|
|
|
+ row["error"] = result.get("error", "")
|
|
|
+ for k, v in params.items():
|
|
|
+ if k in row:
|
|
|
+ row[k] = v
|
|
|
+ for k, v in result.get("metrics", {}).items():
|
|
|
+ if k in row:
|
|
|
+ row[k] = v
|
|
|
+
|
|
|
+ writer.writerow(row)
|
|
|
+ csv_file.flush()
|
|
|
+
|
|
|
+ if result["status"] == "OK":
|
|
|
+ summary["ok"] += 1
|
|
|
+ else:
|
|
|
+ summary["failed"] += 1
|
|
|
+ summary["results"].append(row)
|
|
|
+
|
|
|
+ if self.row_cb:
|
|
|
+ self.row_cb(row)
|
|
|
+ if self.progress_cb:
|
|
|
+ self.progress_cb(idx, len(points))
|
|
|
+
|
|
|
+ ripple = row.get("ripple_pct", "")
|
|
|
+ tavg = row.get("tavg_nm", "")
|
|
|
+ eff = row.get("efficiency_pct", "")
|
|
|
+ _file_log(
|
|
|
+ f"[{idx}/{len(points)}] {row['status']} "
|
|
|
+ f"Tavg={tavg} ripple={ripple} eff={eff} "
|
|
|
+ f"seconds={row['seconds']}"
|
|
|
+ )
|
|
|
+
|
|
|
+ _file_log(f"Scan ended. OK={summary['ok']} FAILED={summary['failed']} SKIPPED={summary['skipped']}")
|
|
|
+ _file_log(f"Results CSV: {csv_path}")
|
|
|
+
|
|
|
+ except Exception as exc:
|
|
|
+ _file_log(f"FATAL: {type(exc).__name__}: {exc}")
|
|
|
+ _file_log(traceback.format_exc())
|
|
|
+ raise
|
|
|
+ finally:
|
|
|
+ log_file.close()
|
|
|
+
|
|
|
+ return {
|
|
|
+ "run_dir": str(run_dir),
|
|
|
+ "csv_path": str(csv_path),
|
|
|
+ "log_path": str(log_path),
|
|
|
+ "manifest_path": str(manifest_path),
|
|
|
+ "summary": summary,
|
|
|
+ }
|