task_executor.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. """Task executor for local simulation system (P4-M2).
  2. Listens for tasks dispatched from Web backend, executes Motor-CAD
  3. simulations, reports progress and results back to Web backend.
  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. class TaskExecutor:
  20. """Executes simulation tasks dispatched from Web backend."""
  21. def __init__(
  22. self,
  23. web_base_url: str = "http://127.0.0.1:8000",
  24. task_dir: Optional[str] = None,
  25. on_progress: Optional[Callable] = None,
  26. on_complete: Optional[Callable] = None,
  27. on_error: Optional[Callable] = None,
  28. ):
  29. self.web_base_url = web_base_url.rstrip("/")
  30. self.task_dir = task_dir or os.path.join(
  31. os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
  32. "output", "tasks"
  33. )
  34. os.makedirs(self.task_dir, exist_ok=True)
  35. self.on_progress = on_progress
  36. self.on_complete = on_complete
  37. self.on_error = on_error
  38. self._running = False
  39. self._current_task: Optional[Dict[str, Any]] = None
  40. self._stop_event = threading.Event()
  41. def fetch_pending_tasks(self) -> List[Dict[str, Any]]:
  42. """Fetch pending tasks from Web backend."""
  43. if requests is None:
  44. return self._scan_local_task_files()
  45. try:
  46. resp = requests.get(
  47. f"{self.web_base_url}/api/tasks",
  48. params={"status": "pending", "limit": 10},
  49. timeout=10,
  50. )
  51. if resp.status_code == 200:
  52. data = resp.json()
  53. return data.get("tasks", [])
  54. except Exception as e:
  55. if self.on_error:
  56. self.on_error(f"Fetch tasks failed: {str(e)}")
  57. return []
  58. def _scan_local_task_files(self) -> List[Dict[str, Any]]:
  59. """Scan local task directory for task files (fallback mode)."""
  60. tasks = []
  61. for fname in os.listdir(self.task_dir):
  62. if fname.endswith("_task.json"):
  63. fpath = os.path.join(self.task_dir, fname)
  64. try:
  65. with open(fpath, "r", encoding="utf-8") as f:
  66. task = json.load(f)
  67. task["_local_file"] = fpath
  68. tasks.append(task)
  69. except Exception:
  70. continue
  71. return tasks
  72. def dispatch_task(self, task_id: str) -> bool:
  73. """Mark task as dispatched on Web backend."""
  74. if requests is None:
  75. return True
  76. try:
  77. resp = requests.post(
  78. f"{self.web_base_url}/api/tasks/{task_id}/dispatch",
  79. timeout=10,
  80. )
  81. return resp.status_code in (200, 201)
  82. except Exception as e:
  83. if self.on_error:
  84. self.on_error(f"Dispatch task {task_id} failed: {str(e)}")
  85. return False
  86. def report_progress(
  87. self,
  88. task_id: str,
  89. current_point: int,
  90. total_points: int,
  91. current_params: Optional[Dict[str, Any]] = None,
  92. elapsed_time: Optional[float] = None,
  93. ) -> bool:
  94. """Report simulation progress to Web backend."""
  95. if requests is None:
  96. if self.on_progress:
  97. self.on_progress(task_id, current_point, total_points)
  98. return True
  99. try:
  100. payload = {
  101. "current_point": current_point,
  102. "total_points": total_points,
  103. "current_params": current_params,
  104. "elapsed_time": elapsed_time,
  105. }
  106. resp = requests.post(
  107. f"{self.web_base_url}/api/tasks/{task_id}/progress",
  108. json=payload,
  109. timeout=10,
  110. )
  111. return resp.status_code == 200
  112. except Exception as e:
  113. if self.on_error:
  114. self.on_error(f"Report progress failed: {str(e)}")
  115. return False
  116. def report_results(
  117. self,
  118. task_id: str,
  119. results: List[Dict[str, Any]],
  120. metrics: Optional[Dict[str, Any]] = None,
  121. logs: Optional[str] = None,
  122. duration: Optional[float] = None,
  123. status: str = "completed",
  124. ) -> bool:
  125. """Report final results to Web backend."""
  126. if requests is None:
  127. if self.on_complete:
  128. self.on_complete(task_id, results)
  129. return True
  130. try:
  131. payload = {
  132. "results": results,
  133. "metrics": metrics,
  134. "logs": logs,
  135. "duration": duration,
  136. "status": status,
  137. }
  138. resp = requests.post(
  139. f"{self.web_base_url}/api/tasks/{task_id}/results",
  140. json=payload,
  141. timeout=30,
  142. )
  143. return resp.status_code == 200
  144. except Exception as e:
  145. if self.on_error:
  146. self.on_error(f"Report results failed: {str(e)}")
  147. return False
  148. def execute_task(self, task: Dict[str, Any]) -> None:
  149. """Execute a single simulation task.
  150. This is a template method. Override _run_simulation_point in
  151. subclasses to implement actual Motor-CAD simulation.
  152. """
  153. task_id = task.get("task_id", str(uuid.uuid4())[:8])
  154. parameters = task.get("parameters", [])
  155. total_points = len(parameters)
  156. results = []
  157. start_time = time.time()
  158. self._current_task = task
  159. self.dispatch_task(task_id)
  160. for idx, params in enumerate(parameters):
  161. if self._stop_event.is_set():
  162. break
  163. elapsed = time.time() - start_time
  164. self.report_progress(task_id, idx, total_points, params, elapsed)
  165. try:
  166. point_result = self._run_simulation_point(params, idx)
  167. point_result["point_index"] = idx
  168. point_result["params"] = params
  169. results.append(point_result)
  170. except Exception as e:
  171. results.append({
  172. "point_index": idx,
  173. "params": params,
  174. "status": "failed",
  175. "error": str(e),
  176. })
  177. if self.on_error:
  178. self.on_error(f"Point {idx} failed: {str(e)}")
  179. duration = time.time() - start_time
  180. metrics = self._compute_metrics(results)
  181. status = "completed" if not self._stop_event.is_set() else "cancelled"
  182. self.report_results(task_id, results, metrics, None, duration, status)
  183. self.report_progress(task_id, total_points, total_points, None, duration)
  184. self._current_task = None
  185. if self.on_complete:
  186. self.on_complete(task_id, results, metrics)
  187. def _run_simulation_point(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
  188. """Run a single simulation point. Override in subclass.
  189. Template implementation returns mock data.
  190. """
  191. import random
  192. rng = random.Random(index + hash(json.dumps(params, sort_keys=True)) % 10000)
  193. airgap = params.get("airgap_mm", 1.0)
  194. current = params.get("current_a", 15.0)
  195. return {
  196. "tavg_nm": round(current * 2.5 / (airgap ** 0.5) + rng.gauss(0, 0.3), 4),
  197. "efficiency_pct": round(88 + rng.gauss(0, 2), 2),
  198. "total_losses_w": round(50 + rng.gauss(0, 10), 2),
  199. "winding_temp_c": round(90 + rng.gauss(0, 10), 1),
  200. "status": "ok",
  201. }
  202. def _compute_metrics(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
  203. """Compute aggregated metrics from results."""
  204. ok_results = [r for r in results if r.get("status") == "ok"]
  205. if not ok_results:
  206. return {"total_points": len(results), "successful_points": 0}
  207. metrics = {
  208. "total_points": len(results),
  209. "successful_points": len(ok_results),
  210. }
  211. for key in ["tavg_nm", "efficiency_pct", "total_losses_w", "winding_temp_c"]:
  212. values = [r[key] for r in ok_results if key in r]
  213. if values:
  214. metrics[f"{key}_min"] = min(values)
  215. metrics[f"{key}_max"] = max(values)
  216. metrics[f"{key}_mean"] = round(sum(values) / len(values), 4)
  217. return metrics
  218. def start_polling(self, interval: int = 5) -> threading.Thread:
  219. """Start background thread to poll for and execute tasks."""
  220. self._running = True
  221. self._stop_event.clear()
  222. def poll_loop():
  223. while self._running and not self._stop_event.is_set():
  224. try:
  225. tasks = self.fetch_pending_tasks()
  226. for task in tasks:
  227. if self._stop_event.is_set():
  228. break
  229. self.execute_task(task)
  230. except Exception as e:
  231. if self.on_error:
  232. self.on_error(f"Poll loop error: {str(e)}")
  233. self._stop_event.wait(interval)
  234. thread = threading.Thread(target=poll_loop, daemon=True)
  235. thread.start()
  236. return thread
  237. def stop(self):
  238. """Stop the executor."""
  239. self._running = False
  240. self._stop_event.set()
  241. class MotorCADTaskExecutor(TaskExecutor):
  242. """Task executor that uses actual Motor-CAD for simulation.
  243. Overrides _run_simulation_point to call Motor-CAD via pymotorcad.
  244. Falls back to mock data if Motor-CAD is not available.
  245. """
  246. def __init__(self, *args, model_path: Optional[str] = None, **kwargs):
  247. super().__init__(*args, **kwargs)
  248. self.model_path = model_path
  249. self._mc = None
  250. def _run_simulation_point(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
  251. """Run simulation using Motor-CAD. Falls back to mock if unavailable."""
  252. try:
  253. return self._run_motorcad(params, index)
  254. except Exception as e:
  255. if self.on_error:
  256. self.on_error(f"MotorCAD failed for point {index}, using mock: {str(e)}")
  257. return super()._run_simulation_point(params, index)
  258. def _run_motorcad(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
  259. """Actual Motor-CAD simulation. Requires pymotorcad and Motor-CAD."""
  260. try:
  261. from ansys.motorcad.core import MotorCAD
  262. except ImportError:
  263. raise RuntimeError("pymotorcad not installed")
  264. if self._mc is None:
  265. self._mc = MotorCAD()
  266. if self.model_path and os.path.exists(self.model_path):
  267. self._mc.load_from_file(self.model_path)
  268. # Write parameters
  269. for key, value in params.items():
  270. try:
  271. self._mc.set_variable(key, value)
  272. applied = float(self._mc.get_variable(key))
  273. if abs(applied - value) > 1e-6:
  274. raise RuntimeError(f"Variable {key} write mismatch: {applied} != {value}")
  275. except Exception:
  276. pass
  277. # Run simulation
  278. self._mc.do_magnetic_calculation()
  279. # Read results
  280. result = {
  281. "tavg_nm": float(self._mc.get_variable("Torque_Avg")),
  282. "efficiency_pct": float(self._mc.get_variable("Efficiency")),
  283. "total_losses_w": float(self._mc.get_variable("Total_Losses")),
  284. "status": "ok",
  285. }
  286. return result
  287. if __name__ == "__main__":
  288. # Standalone test: run executor with mock data
  289. executor = TaskExecutor(
  290. web_base_url=os.environ.get("WEB_BASE_URL", "http://127.0.0.1:8000"),
  291. on_progress=lambda tid, cur, tot: print(f"[{tid}] Progress: {cur}/{tot}"),
  292. on_complete=lambda tid, res, met: print(f"[{tid}] Complete: {len(res)} points"),
  293. on_error=lambda msg: print(f"ERROR: {msg}"),
  294. )
  295. print("Task executor started. Press Ctrl+C to stop.")
  296. try:
  297. thread = executor.start_polling(interval=5)
  298. while thread.is_alive():
  299. time.sleep(1)
  300. except KeyboardInterrupt:
  301. executor.stop()
  302. print("Executor stopped.")