task_executor.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. """Task executor for local simulation system (P4-M2).
  2. Listens for tasks dispatched from Web backend, executes Motor-CAD
  3. simulations via RobustMotorCADSolver, reports progress and results.
  4. NOTE: All strings must be ASCII only. Chinese text uses \\uXXXX escapes.
  5. """
  6. import json
  7. import os
  8. import sys
  9. import time
  10. import uuid
  11. import threading
  12. from datetime import datetime
  13. from pathlib import Path
  14. from typing import Dict, List, Optional, Any, Callable
  15. try:
  16. import requests
  17. except ImportError:
  18. requests = None
  19. # Add scripts directory to path for robust_motorcad import
  20. _SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
  21. if _SCRIPTS_DIR not in sys.path:
  22. sys.path.insert(0, _SCRIPTS_DIR)
  23. class TaskExecutor:
  24. """Executes simulation tasks dispatched from Web backend."""
  25. def __init__(
  26. self,
  27. web_base_url: str = "http://127.0.0.1:8000",
  28. task_dir: Optional[str] = None,
  29. on_progress: Optional[Callable] = None,
  30. on_complete: Optional[Callable] = None,
  31. on_error: Optional[Callable] = None,
  32. enable_mock: bool = False,
  33. ):
  34. self.web_base_url = web_base_url.rstrip("/")
  35. self.task_dir = task_dir or os.path.join(
  36. os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
  37. "output", "tasks"
  38. )
  39. os.makedirs(self.task_dir, exist_ok=True)
  40. self.on_progress = on_progress
  41. self.on_complete = on_complete
  42. self.on_error = on_error
  43. self.enable_mock = enable_mock
  44. self._running = False
  45. self._current_task: Optional[Dict[str, Any]] = None
  46. self._stop_event = threading.Event()
  47. def fetch_pending_tasks(self) -> List[Dict[str, Any]]:
  48. """Fetch pending tasks from Web backend."""
  49. if requests is None:
  50. return self._scan_local_task_files()
  51. try:
  52. resp = requests.get(
  53. f"{self.web_base_url}/api/tasks",
  54. params={"status": "pending", "limit": 10},
  55. timeout=10,
  56. )
  57. if resp.status_code == 200:
  58. data = resp.json()
  59. return data.get("tasks", [])
  60. except Exception as e:
  61. if self.on_error:
  62. self.on_error(f"Fetch tasks failed: {str(e)}")
  63. return []
  64. def _scan_local_task_files(self) -> List[Dict[str, Any]]:
  65. """Scan local task directory for task files (fallback mode).
  66. Only picks up *_task.json files. Completed tasks are renamed
  67. to *_task.done.json to prevent infinite re-execution (B7 fix).
  68. """
  69. tasks = []
  70. for fname in os.listdir(self.task_dir):
  71. if fname.endswith("_task.json") and not fname.endswith("_task.done.json"):
  72. fpath = os.path.join(self.task_dir, fname)
  73. try:
  74. with open(fpath, "r", encoding="utf-8") as f:
  75. task = json.load(f)
  76. task["_local_file"] = fpath
  77. tasks.append(task)
  78. except Exception:
  79. continue
  80. return tasks
  81. def _mark_local_task_done(self, task: Dict[str, Any]) -> None:
  82. """Rename completed local task file to prevent re-execution (B7 fix)."""
  83. fpath = task.get("_local_file")
  84. if fpath and os.path.exists(fpath):
  85. done_path = fpath.replace("_task.json", "_task.done.json")
  86. try:
  87. os.rename(fpath, done_path)
  88. except Exception as e:
  89. if self.on_error:
  90. self.on_error(f"Failed to mark task done: {str(e)}")
  91. def dispatch_task(self, task_id: str) -> bool:
  92. """Mark task as dispatched on Web backend."""
  93. if requests is None:
  94. return True
  95. try:
  96. resp = requests.post(
  97. f"{self.web_base_url}/api/tasks/{task_id}/dispatch",
  98. timeout=10,
  99. )
  100. return resp.status_code in (200, 201)
  101. except Exception as e:
  102. if self.on_error:
  103. self.on_error(f"Dispatch task {task_id} failed: {str(e)}")
  104. return False
  105. def report_progress(
  106. self,
  107. task_id: str,
  108. current_point: int,
  109. total_points: int,
  110. current_params: Optional[Dict[str, Any]] = None,
  111. elapsed_time: Optional[float] = None,
  112. ) -> bool:
  113. """Report simulation progress to Web backend."""
  114. if requests is None:
  115. if self.on_progress:
  116. self.on_progress(task_id, current_point, total_points)
  117. return True
  118. try:
  119. payload = {
  120. "current_point": current_point,
  121. "total_points": total_points,
  122. "current_params": current_params,
  123. "elapsed_time": elapsed_time,
  124. }
  125. resp = requests.post(
  126. f"{self.web_base_url}/api/tasks/{task_id}/progress",
  127. json=payload,
  128. timeout=10,
  129. )
  130. return resp.status_code == 200
  131. except Exception as e:
  132. if self.on_error:
  133. self.on_error(f"Report progress failed: {str(e)}")
  134. return False
  135. def report_results(
  136. self,
  137. task_id: str,
  138. results: List[Dict[str, Any]],
  139. metrics: Optional[Dict[str, Any]] = None,
  140. logs: Optional[str] = None,
  141. duration: Optional[float] = None,
  142. status: str = "completed",
  143. ) -> bool:
  144. """Report final results to Web backend."""
  145. if requests is None:
  146. if self.on_complete:
  147. self.on_complete(task_id, results)
  148. return True
  149. try:
  150. payload = {
  151. "results": results,
  152. "metrics": metrics,
  153. "logs": logs,
  154. "duration": duration,
  155. "status": status,
  156. }
  157. resp = requests.post(
  158. f"{self.web_base_url}/api/tasks/{task_id}/results",
  159. json=payload,
  160. timeout=30,
  161. )
  162. return resp.status_code == 200
  163. except Exception as e:
  164. if self.on_error:
  165. self.on_error(f"Report results failed: {str(e)}")
  166. return False
  167. def execute_task(self, task: Dict[str, Any]) -> None:
  168. """Execute a single simulation task.
  169. This is a template method. Override _run_simulation_point in
  170. subclasses to implement actual Motor-CAD simulation.
  171. """
  172. task_id = task.get("task_id", str(uuid.uuid4())[:8])
  173. parameters = task.get("parameters", [])
  174. total_points = len(parameters)
  175. results = []
  176. start_time = time.time()
  177. self._current_task = task
  178. self.dispatch_task(task_id)
  179. for idx, params in enumerate(parameters):
  180. if self._stop_event.is_set():
  181. break
  182. elapsed = time.time() - start_time
  183. self.report_progress(task_id, idx, total_points, params, elapsed)
  184. try:
  185. point_result = self._run_simulation_point(params, idx)
  186. point_result["point_index"] = idx
  187. point_result["params"] = params
  188. results.append(point_result)
  189. except Exception as e:
  190. # A2 fix: failed points are recorded as failed, NOT mock data
  191. results.append({
  192. "point_index": idx,
  193. "params": params,
  194. "status": "FAILED",
  195. "error": str(e),
  196. })
  197. if self.on_error:
  198. self.on_error(f"Point {idx} failed: {str(e)}")
  199. duration = time.time() - start_time
  200. metrics = self._compute_metrics(results)
  201. # Status reflects actual outcome: completed/cancelled/failed
  202. if self._stop_event.is_set():
  203. status = "cancelled"
  204. elif any(r.get("status") == "FAILED" for r in results):
  205. status = "completed_with_errors" if any(
  206. r.get("status") == "OK" for r in results
  207. ) else "failed"
  208. else:
  209. status = "completed"
  210. self.report_results(task_id, results, metrics, None, duration, status)
  211. self.report_progress(task_id, total_points, total_points, None, duration)
  212. # B7 fix: mark local task file as done to prevent re-execution
  213. if requests is None:
  214. self._mark_local_task_done(task)
  215. self._current_task = None
  216. if self.on_complete:
  217. self.on_complete(task_id, results, metrics)
  218. def _run_simulation_point(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
  219. """Run a single simulation point. Override in subclass.
  220. Mock data is ONLY returned when enable_mock=True (explicit opt-in).
  221. Mock results are tagged with source="mock" so they can never be
  222. confused with real simulation data (A2 fix).
  223. """
  224. if not self.enable_mock:
  225. raise RuntimeError(
  226. "No simulation backend configured. "
  227. "Use MotorCADTaskExecutor for real Motor-CAD simulation, "
  228. "or set enable_mock=True for testing."
  229. )
  230. import random
  231. rng = random.Random(index + hash(json.dumps(params, sort_keys=True)) % 10000)
  232. airgap = params.get("airgap_mm", 1.0)
  233. current = params.get("current_a", 15.0)
  234. return {
  235. "tavg_nm": round(current * 2.5 / (airgap ** 0.5) + rng.gauss(0, 0.3), 4),
  236. "efficiency_pct": round(88 + rng.gauss(0, 2), 2),
  237. "total_losses_w": round(50 + rng.gauss(0, 10), 2),
  238. "winding_temp_c": round(90 + rng.gauss(0, 10), 1),
  239. "status": "OK",
  240. "source": "mock",
  241. }
  242. def _compute_metrics(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
  243. """Compute aggregated metrics from results."""
  244. ok_results = [r for r in results if r.get("status") == "OK"]
  245. if not ok_results:
  246. return {
  247. "total_points": len(results),
  248. "successful_points": 0,
  249. "failed_points": len(results),
  250. }
  251. metrics = {
  252. "total_points": len(results),
  253. "successful_points": len(ok_results),
  254. "failed_points": len(results) - len(ok_results),
  255. }
  256. for key in ["tavg_nm", "efficiency_pct", "total_losses_w", "winding_temp_c"]:
  257. values = [r[key] for r in ok_results if key in r]
  258. if values:
  259. metrics[f"{key}_min"] = min(values)
  260. metrics[f"{key}_max"] = max(values)
  261. metrics[f"{key}_mean"] = round(sum(values) / len(values), 4)
  262. return metrics
  263. def start_polling(self, interval: int = 5) -> threading.Thread:
  264. """Start background thread to poll for and execute tasks."""
  265. self._running = True
  266. self._stop_event.clear()
  267. def poll_loop():
  268. while self._running and not self._stop_event.is_set():
  269. try:
  270. tasks = self.fetch_pending_tasks()
  271. for task in tasks:
  272. if self._stop_event.is_set():
  273. break
  274. self.execute_task(task)
  275. except Exception as e:
  276. if self.on_error:
  277. self.on_error(f"Poll loop error: {str(e)}")
  278. self._stop_event.wait(interval)
  279. thread = threading.Thread(target=poll_loop, daemon=True)
  280. thread.start()
  281. return thread
  282. def stop(self):
  283. """Stop the executor."""
  284. self._running = False
  285. self._stop_event.set()
  286. class MotorCADTaskExecutor(TaskExecutor):
  287. """Task executor that uses RobustMotorCADSolver for real Motor-CAD simulation.
  288. A3/A4/A5 fixes:
  289. - Reuses RobustMotorCADSolver (open_new_instance=True, set_visible,
  290. baseline reload per point, popup suppression, write-back verification)
  291. - Write-back verification failures propagate (no silent except:pass)
  292. - Results extracted via export file parsing (not bogus get_variable names)
  293. - Failed points raise exception -> recorded as status=failed (no mock fallback)
  294. """
  295. def __init__(self, *args, model_path: Optional[str] = None, **kwargs):
  296. # Mock fallback is disabled by default for real Motor-CAD executor
  297. kwargs.setdefault("enable_mock", False)
  298. super().__init__(*args, **kwargs)
  299. self.model_path = model_path
  300. self._solver = None
  301. def _ensure_solver(self):
  302. """Lazily create RobustMotorCADSolver instance."""
  303. if self._solver is not None:
  304. return self._solver
  305. from robust_motorcad import RobustMotorCADSolver
  306. if not self.model_path:
  307. raise RuntimeError("model_path is required for MotorCADTaskExecutor")
  308. # Output directory under task dir
  309. output_dir = os.path.join(
  310. os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
  311. "output", f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
  312. )
  313. self._solver = RobustMotorCADSolver(
  314. model_path=self.model_path,
  315. output_dir=output_dir,
  316. )
  317. self._solver.connect()
  318. return self._solver
  319. def _run_simulation_point(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
  320. """Run a single simulation point via RobustMotorCADSolver.
  321. A2 fix: No mock fallback on failure. Exception propagates to
  322. execute_task which records status=failed.
  323. A3 fix: RobustMotorCADSolver handles open_new_instance, set_visible,
  324. baseline reload, popup suppression.
  325. A4 fix: Write-back verification inside solver raises on mismatch.
  326. A5 fix: Results from export file parsing, not get_variable.
  327. """
  328. solver = self._ensure_solver()
  329. # run_single_point handles baseline reload, write-verify, calculation,
  330. # export, parsing, and per-point disk flush.
  331. point_result = solver.run_single_point(params, point_index=index)
  332. return point_result
  333. def cleanup(self):
  334. """Disconnect solver and release Motor-CAD instance."""
  335. if self._solver is not None:
  336. try:
  337. self._solver.disconnect()
  338. except Exception:
  339. pass
  340. self._solver = None
  341. if __name__ == "__main__":
  342. # Standalone test: run executor with mock data (explicit)
  343. executor = TaskExecutor(
  344. web_base_url=os.environ.get("WEB_BASE_URL", "http://127.0.0.1:8000"),
  345. on_progress=lambda tid, cur, tot: print(f"[{tid}] Progress: {cur}/{tot}"),
  346. on_complete=lambda tid, res, met: print(f"[{tid}] Complete: {len(res)} points"),
  347. on_error=lambda msg: print(f"ERROR: {msg}"),
  348. enable_mock=True,
  349. )
  350. print("Task executor started (mock mode). Press Ctrl+C to stop.")
  351. try:
  352. thread = executor.start_polling(interval=5)
  353. while thread.is_alive():
  354. time.sleep(1)
  355. except KeyboardInterrupt:
  356. executor.stop()
  357. print("Executor stopped.")