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