run_scan.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. """Command-line entry point for parameter scan.
  2. Usage:
  3. python scripts/run_scan.py --config scan_config.json
  4. python scripts/run_scan.py --model models/MARS.mot --var Airgap --start 0.6 --stop 1.5 --step 0.3
  5. All source is ASCII.
  6. """
  7. from __future__ import annotations
  8. import argparse
  9. import json
  10. import os
  11. import subprocess
  12. import sys
  13. from pathlib import Path
  14. # Add project root to sys.path
  15. PROJECT_ROOT = Path(__file__).resolve().parent.parent
  16. sys.path.insert(0, str(PROJECT_ROOT))
  17. from src.solver_core import MotorCADSolver # noqa: E402
  18. from src.scan_engine import ( # noqa: E402
  19. ScanEngine,
  20. generate_cartesian_points,
  21. values_inclusive,
  22. estimate_total_time,
  23. )
  24. DEFAULT_MODEL = PROJECT_ROOT / "models" / "MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot"
  25. DEFAULT_OUTPUT = PROJECT_ROOT / "output"
  26. def _log(text: str) -> None:
  27. from datetime import datetime
  28. stamp = datetime.now().strftime("%H:%M:%S")
  29. print(f"[{stamp}] {text}", flush=True)
  30. def git_preflight(project_root: Path) -> tuple[bool, str]:
  31. """Check that git repo exists, HEAD is valid, and tracked files are clean.
  32. Returns (ok, commit_or_error)."""
  33. try:
  34. safe = f"safe.directory={project_root.as_posix()}"
  35. base = ["git", "-c", safe]
  36. commit = subprocess.check_output(
  37. base + ["rev-parse", "--short", "HEAD"],
  38. cwd=project_root, text=True, stderr=subprocess.STDOUT,
  39. ).strip()
  40. dirty = subprocess.check_output(
  41. base + ["status", "--porcelain", "--untracked-files=no"],
  42. cwd=project_root, text=True, stderr=subprocess.STDOUT,
  43. ).strip()
  44. if dirty:
  45. return False, f"Tracked files have uncommitted changes:\n{dirty}"
  46. return True, commit
  47. except (OSError, subprocess.CalledProcessError) as exc:
  48. return False, f"Git preflight failed: {exc}"
  49. def load_config(config_path: Path) -> dict:
  50. """Load scan configuration from JSON file."""
  51. with open(config_path, "r", encoding="utf-8") as f:
  52. return json.load(f)
  53. def build_points_from_config(config: dict) -> tuple[list[dict], list[str]]:
  54. """Build scan points from config. Returns (points, var_names)."""
  55. variables = []
  56. var_names = []
  57. for v in config.get("variables", []):
  58. name = v["name"]
  59. var_names.append(name)
  60. if "values" in v:
  61. values = [float(x) for x in v["values"]]
  62. else:
  63. values = values_inclusive(
  64. float(v["start"]), float(v["stop"]), float(v["step"])
  65. )
  66. variables.append({"name": name, "display_name": v.get("display_name", name), "values": values})
  67. points = generate_cartesian_points(variables)
  68. return points, var_names
  69. def build_points_from_args(args) -> tuple[list[dict], list[str]]:
  70. """Build scan points from CLI arguments (single variable only)."""
  71. values = values_inclusive(args.start, args.stop, args.step)
  72. variables = [{"name": args.var, "display_name": args.var, "values": values}]
  73. points = generate_cartesian_points(variables)
  74. return points, [args.var]
  75. def main() -> int:
  76. parser = argparse.ArgumentParser(description="Motor-CAD parameter scan")
  77. parser.add_argument("--config", type=str, default=None,
  78. help="Path to scan config JSON")
  79. parser.add_argument("--model", type=str, default=str(DEFAULT_MODEL),
  80. help="Path to .mot model file")
  81. parser.add_argument("--output-dir", type=str, default=str(DEFAULT_OUTPUT),
  82. help="Output directory")
  83. parser.add_argument("--scan-name", type=str, default="scan",
  84. help="Name for this scan run")
  85. parser.add_argument("--var", type=str, default=None,
  86. help="Variable name to scan (CLI mode, single var)")
  87. parser.add_argument("--start", type=float, default=None,
  88. help="Start value (CLI mode)")
  89. parser.add_argument("--stop", type=float, default=None,
  90. help="Stop value (CLI mode)")
  91. parser.add_argument("--step", type=float, default=None,
  92. help="Step value (CLI mode)")
  93. parser.add_argument("--quit", action="store_true",
  94. help="Close Motor-CAD after scan")
  95. parser.add_argument("--skip-git-check", action="store_true",
  96. help="Skip git preflight check")
  97. args = parser.parse_args()
  98. # Git preflight
  99. if not args.skip_git_check:
  100. ok, msg = git_preflight(PROJECT_ROOT)
  101. if not ok:
  102. print(f"GIT PREFLIGHT FAILED: {msg}")
  103. print("Commit all changes first, or use --skip-git-check to bypass.")
  104. return 1
  105. _log(f"Git preflight passed (commit {msg})")
  106. # Build scan points
  107. if args.config:
  108. config = load_config(Path(args.config))
  109. model_path = config.get("model_path", args.model)
  110. scan_name = config.get("scan_name", args.scan_name)
  111. points, var_names = build_points_from_config(config)
  112. elif args.var and args.start is not None and args.stop is not None and args.step is not None:
  113. model_path = args.model
  114. scan_name = args.scan_name
  115. points, var_names = build_points_from_args(args)
  116. else:
  117. print("ERROR: Provide either --config or --var/--start/--stop/--step")
  118. return 1
  119. model_path = Path(model_path)
  120. if not model_path.exists():
  121. print(f"ERROR: Model file not found: {model_path}")
  122. return 1
  123. # Estimate time
  124. est = estimate_total_time(points)
  125. _log("=" * 60)
  126. _log(f"Scan: {scan_name}")
  127. _log(f"Model: {model_path.name}")
  128. _log(f"Variables: {var_names}")
  129. _log(f"Total points: {est['count']}")
  130. _log(f"Estimated time: {est['total_min']} min ({est['total_h']} h)")
  131. _log("=" * 60)
  132. _log("")
  133. # Run
  134. solver = MotorCADSolver(log_cb=_log)
  135. result = {}
  136. try:
  137. solver.connect()
  138. _log("")
  139. engine = ScanEngine(
  140. solver=solver,
  141. model_path=model_path,
  142. output_dir=args.output_dir,
  143. scan_name=scan_name,
  144. log_cb=_log,
  145. )
  146. result = engine.run(points, var_names=var_names)
  147. _log("")
  148. _log("=" * 60)
  149. _log("SCAN COMPLETE")
  150. _log("=" * 60)
  151. s = result["summary"]
  152. _log(f"OK: {s['ok']} FAILED: {s['failed']} SKIPPED: {s['skipped']}")
  153. _log(f"Results CSV: {result['csv_path']}")
  154. _log(f"Run directory: {result['run_dir']}")
  155. except KeyboardInterrupt:
  156. _log("Interrupted by user.")
  157. except Exception as exc:
  158. _log(f"FATAL: {type(exc).__name__}: {exc}")
  159. import traceback
  160. _log(traceback.format_exc())
  161. return 1
  162. finally:
  163. if args.quit:
  164. solver.disconnect()
  165. else:
  166. _log("")
  167. _log("Motor-CAD kept open for manual inspection.")
  168. _log("Use --quit flag to auto-close.")
  169. _log("")
  170. _log("Done.")
  171. return 0
  172. if __name__ == "__main__":
  173. sys.exit(main())