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