task_executor.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  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 gc
  7. import json
  8. import os
  9. import sys
  10. import time
  11. import uuid
  12. import threading
  13. from datetime import datetime
  14. from pathlib import Path
  15. from typing import Dict, List, Optional, Any, Callable
  16. try:
  17. import requests
  18. except ImportError:
  19. requests = None
  20. # Add scripts directory to path for robust_motorcad import
  21. _SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
  22. if _SCRIPTS_DIR not in sys.path:
  23. sys.path.insert(0, _SCRIPTS_DIR)
  24. class TaskExecutor:
  25. """Executes simulation tasks dispatched from Web backend."""
  26. def __init__(
  27. self,
  28. web_base_url: str = "http://127.0.0.1:8000",
  29. task_dir: Optional[str] = None,
  30. on_progress: Optional[Callable] = None,
  31. on_complete: Optional[Callable] = None,
  32. on_error: Optional[Callable] = None,
  33. enable_mock: bool = False,
  34. executor_id: Optional[str] = None,
  35. ):
  36. self.web_base_url = web_base_url.rstrip("/")
  37. self.task_dir = task_dir or os.path.join(
  38. os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
  39. "output", "tasks"
  40. )
  41. os.makedirs(self.task_dir, exist_ok=True)
  42. self.on_progress = on_progress
  43. self.on_complete = on_complete
  44. self.on_error = on_error
  45. self.enable_mock = enable_mock
  46. self._running = False
  47. self._current_task: Optional[Dict[str, Any]] = None
  48. self._stop_event = threading.Event()
  49. # Set when the web side cancelled the currently-running task
  50. # (detected by polling GET /api/tasks/{id} between points).
  51. self._task_cancelled = False
  52. # Heartbeat observability: monotonic timestamp of the last heartbeat
  53. # that actually reached the backend, plus a handle on the heartbeat
  54. # thread itself. A silently-dead heartbeat thread once left the
  55. # executor running-but-OFFLINE (observed 2026-09-04).
  56. self.last_heartbeat_at: Optional[float] = None
  57. self._hb_thread: Optional[threading.Thread] = None
  58. if executor_id is not None:
  59. self.executor_id = executor_id
  60. else:
  61. self.executor_id = "motorcad-executor-%s-%s" % (os.getpid(), uuid.uuid4().hex[:4])
  62. def fetch_pending_tasks(self) -> List[Dict[str, Any]]:
  63. """Fetch claimable tasks from Web backend.
  64. Web's start-simulation marks tasks as 'dispatched' immediately, while
  65. tasks created via the tasks API stay 'pending'. The executor claims
  66. BOTH states so every task gets picked up regardless of creation path.
  67. """
  68. if requests is None:
  69. return self._scan_local_task_files()
  70. tasks = []
  71. for st in ("pending", "dispatched"):
  72. try:
  73. resp = requests.get(
  74. f"{self.web_base_url}/api/tasks",
  75. params={"status": st, "limit": 20},
  76. timeout=10,
  77. )
  78. if resp.status_code == 200:
  79. data = resp.json()
  80. tasks.extend(data.get("tasks", []))
  81. except Exception as e:
  82. if self.on_error:
  83. self.on_error(f"Fetch tasks failed: {str(e)}")
  84. # De-duplicate by task_id (keep first occurrence)
  85. seen = set()
  86. result = []
  87. for t in tasks:
  88. tid = t.get("task_id")
  89. if tid and tid not in seen:
  90. seen.add(tid)
  91. result.append(t)
  92. return result
  93. def _hydrate_task(self, task: Dict[str, Any]) -> Dict[str, Any]:
  94. """Fetch full task payload (parameters + plan_data) from Web backend.
  95. The list API only returns task metadata; the actual parameter sets
  96. live in the task.json file exposed by the download endpoint.
  97. """
  98. if requests is None:
  99. return task
  100. tid = task.get("task_id")
  101. if not tid:
  102. return task
  103. try:
  104. resp = requests.get(
  105. f"{self.web_base_url}/api/tasks/{tid}/download",
  106. timeout=10,
  107. )
  108. if resp.status_code == 200:
  109. full = resp.json()
  110. if isinstance(full, dict):
  111. if full.get("parameters"):
  112. task = {**task, **full}
  113. elif task.get("_local_file"):
  114. # local file fallback
  115. try:
  116. with open(task["_local_file"], "r", encoding="utf-8") as f:
  117. local = json.load(f)
  118. if local.get("parameters"):
  119. task = {**task, **local}
  120. except Exception:
  121. pass
  122. except Exception as e:
  123. if self.on_error:
  124. self.on_error(f"Hydrate task {tid} failed: {str(e)}")
  125. return task
  126. def _scan_local_task_files(self) -> List[Dict[str, Any]]:
  127. """Scan local task directory for task files (fallback mode).
  128. Only picks up *_task.json files. Completed tasks are renamed
  129. to *_task.done.json to prevent infinite re-execution (B7 fix).
  130. """
  131. tasks = []
  132. for fname in os.listdir(self.task_dir):
  133. if fname.endswith("_task.json") and not fname.endswith("_task.done.json"):
  134. fpath = os.path.join(self.task_dir, fname)
  135. try:
  136. with open(fpath, "r", encoding="utf-8") as f:
  137. task = json.load(f)
  138. task["_local_file"] = fpath
  139. tasks.append(task)
  140. except Exception:
  141. continue
  142. return tasks
  143. def _mark_local_task_done(self, task: Dict[str, Any]) -> None:
  144. """Rename completed local task file to prevent re-execution (B7 fix)."""
  145. fpath = task.get("_local_file")
  146. if fpath and os.path.exists(fpath):
  147. done_path = fpath.replace("_task.json", "_task.done.json")
  148. try:
  149. os.rename(fpath, done_path)
  150. except Exception as e:
  151. if self.on_error:
  152. self.on_error(f"Failed to mark task done: {str(e)}")
  153. def dispatch_task(self, task_id: str) -> bool:
  154. """Mark task as dispatched on Web backend."""
  155. if requests is None:
  156. return True
  157. try:
  158. resp = requests.post(
  159. f"{self.web_base_url}/api/tasks/{task_id}/dispatch",
  160. timeout=10,
  161. )
  162. return resp.status_code in (200, 201)
  163. except Exception as e:
  164. if self.on_error:
  165. self.on_error(f"Dispatch task {task_id} failed: {str(e)}")
  166. return False
  167. def report_progress(
  168. self,
  169. task_id: str,
  170. current_point: int,
  171. total_points: int,
  172. current_params: Optional[Dict[str, Any]] = None,
  173. elapsed_time: Optional[float] = None,
  174. ) -> bool:
  175. """Report simulation progress to Web backend."""
  176. if requests is None:
  177. if self.on_progress:
  178. self.on_progress(task_id, current_point, total_points)
  179. return True
  180. try:
  181. payload = {
  182. "current_point": current_point,
  183. "total_points": total_points,
  184. "current_params": current_params,
  185. "elapsed_time": elapsed_time,
  186. }
  187. resp = requests.post(
  188. f"{self.web_base_url}/api/tasks/{task_id}/progress",
  189. json=payload,
  190. timeout=10,
  191. )
  192. return resp.status_code == 200
  193. except Exception as e:
  194. if self.on_error:
  195. self.on_error(f"Report progress failed: {str(e)}")
  196. return False
  197. def _is_task_cancelled(self, task_id: str) -> bool:
  198. """Best-effort check whether the web side cancelled this task.
  199. Polls GET /api/tasks/{task_id} once per point. Network or backend
  200. failures return False on purpose: a transient error must never abort
  201. a running simulation. Local mode (no requests) cannot be cancelled
  202. remotely, so it always returns False.
  203. """
  204. if requests is None or not self.web_base_url:
  205. return False
  206. try:
  207. resp = requests.get(
  208. f"{self.web_base_url}/api/tasks/{task_id}", timeout=5
  209. )
  210. if resp.status_code == 200:
  211. return resp.json().get("status") == "cancelled"
  212. except Exception:
  213. pass
  214. return False
  215. def report_results(
  216. self,
  217. task_id: str,
  218. results: List[Dict[str, Any]],
  219. metrics: Optional[Dict[str, Any]] = None,
  220. logs: Optional[str] = None,
  221. duration: Optional[float] = None,
  222. status: str = "completed",
  223. ) -> bool:
  224. """Report final results to Web backend."""
  225. if requests is None:
  226. if self.on_complete:
  227. self.on_complete(task_id, results, metrics)
  228. return True
  229. try:
  230. payload = {
  231. "results": results,
  232. "metrics": metrics,
  233. "logs": logs,
  234. "duration": duration,
  235. "status": status,
  236. }
  237. resp = requests.post(
  238. f"{self.web_base_url}/api/tasks/{task_id}/results",
  239. json=payload,
  240. timeout=30,
  241. )
  242. return resp.status_code == 200
  243. except Exception as e:
  244. if self.on_error:
  245. self.on_error(f"Report results failed: {str(e)}")
  246. return False
  247. def _report_to_adaptive_loop(
  248. self, task: Dict[str, Any], results: List[Dict[str, Any]]
  249. ) -> None:
  250. """Feed an adaptive_batch task's results back into its loop.
  251. Maps each point result to {point_id, metrics, status} and posts to the
  252. loop's report-results endpoint. Best-effort: a failure here must not
  253. break normal task reporting (results are already stored on the task).
  254. """
  255. if requests is None:
  256. return
  257. if task.get("task_type") != "adaptive_batch":
  258. return
  259. loop_id = task.get("loop_id")
  260. if not loop_id:
  261. return
  262. point_results = []
  263. for r in results:
  264. pid = r.get("point_id")
  265. if pid is None:
  266. continue
  267. point_results.append({
  268. "point_id": pid,
  269. "metrics": r.get("metrics") or {},
  270. "status": "ok" if r.get("status") == "OK" else "failed",
  271. })
  272. if not point_results:
  273. return
  274. try:
  275. resp = requests.post(
  276. f"{self.web_base_url}/api/adaptive/loops/{loop_id}/report-results",
  277. json={"point_results": point_results},
  278. timeout=120,
  279. )
  280. if self.on_progress:
  281. self.on_progress(
  282. f"Adaptive loop {loop_id}: reported {len(point_results)} "
  283. f"points (HTTP {resp.status_code})"
  284. )
  285. except Exception as e:
  286. if self.on_error:
  287. self.on_error(f"Adaptive loop report failed ({loop_id}): {str(e)}")
  288. def execute_task(self, task: Dict[str, Any]) -> None:
  289. """Execute a single simulation task.
  290. This is a template method. Override _run_simulation_point in
  291. subclasses to implement actual Motor-CAD simulation.
  292. """
  293. task = self._hydrate_task(task)
  294. task_id = task.get("task_id", str(uuid.uuid4())[:8])
  295. parameters = task.get("parameters", [])
  296. total_points = len(parameters)
  297. results = []
  298. start_time = time.time()
  299. self._current_task = task
  300. self._task_cancelled = False
  301. # P6: task-level thermal mode switch (off/steady/coupled). Read once
  302. # per task; every point uses it via _run_simulation_point.
  303. self._current_thermal_mode = task.get("thermal_mode")
  304. # start-simulation already marks a task 'dispatched' at creation, while
  305. # tasks created via the tasks API stay 'pending'. Only claim (dispatch)
  306. # a task that is still pending; re-dispatching an already-dispatched
  307. # task is rejected by the backend (pending -> dispatched only) and
  308. # would otherwise be misreported as "not claimable".
  309. claimed = True
  310. if task.get("status") == "pending":
  311. claimed = self.dispatch_task(task_id)
  312. if not claimed:
  313. # Another instance already claimed this task; skip it so
  314. # parallel executors never duplicate the same simulation.
  315. if self.on_error:
  316. self.on_error("Task %s not claimable (claimed/network); skip" % task_id)
  317. self._current_task = None
  318. return
  319. for idx, params in enumerate(parameters):
  320. if self._stop_event.is_set():
  321. break
  322. # Web-side cancellation: the user clicked stop on the UI, which
  323. # flips the task status to 'cancelled'. Abort between points so a
  324. # cancelled task does not keep consuming Motor-CAD time; points
  325. # already finished are still reported below.
  326. if self._is_task_cancelled(task_id):
  327. self._task_cancelled = True
  328. if self.on_progress:
  329. self.on_progress(task_id, idx, total_points)
  330. break
  331. elapsed = time.time() - start_time
  332. self.report_progress(task_id, idx, total_points, params, elapsed)
  333. try:
  334. point_result = self._run_simulation_point(params, idx)
  335. point_result["point_index"] = idx
  336. if "point_id" in params:
  337. point_result["point_id"] = params["point_id"]
  338. point_result["params"] = params
  339. results.append(point_result)
  340. except Exception as e:
  341. # A2 fix: failed points are recorded as failed, NOT mock data
  342. failed_result = {
  343. "point_index": idx,
  344. "params": params,
  345. "status": "FAILED",
  346. "error": str(e),
  347. }
  348. if "point_id" in params:
  349. failed_result["point_id"] = params["point_id"]
  350. results.append(failed_result)
  351. if self.on_error:
  352. self.on_error(f"Point {idx} failed: {str(e)}")
  353. duration = time.time() - start_time
  354. metrics = self._compute_metrics(results)
  355. # Status reflects actual outcome: completed/cancelled/failed
  356. if self._stop_event.is_set() or self._task_cancelled:
  357. status = "cancelled"
  358. elif any(r.get("status") == "FAILED" for r in results):
  359. status = "completed_with_errors" if any(
  360. r.get("status") == "OK" for r in results
  361. ) else "failed"
  362. else:
  363. status = "completed"
  364. self.report_results(task_id, results, metrics, None, duration, status)
  365. self.report_progress(task_id, total_points, total_points, None, duration)
  366. # Adaptive-loop bridge: an adaptive_batch task belongs to a loop; feed
  367. # per-point results back to /adaptive/loops/{loop_id}/report-results so
  368. # the search advances without manual intervention (P3-M5 gap closure).
  369. self._report_to_adaptive_loop(task, results)
  370. # B7 fix: mark local task file as done to prevent re-execution
  371. if requests is None:
  372. self._mark_local_task_done(task)
  373. self._current_task = None
  374. if self.on_complete:
  375. self.on_complete(task_id, results, metrics)
  376. def _run_simulation_point(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
  377. """Run a single simulation point. Override in subclass.
  378. Mock data is ONLY returned when enable_mock=True (explicit opt-in).
  379. Mock results are tagged with source="mock" so they can never be
  380. confused with real simulation data (A2 fix).
  381. """
  382. if not self.enable_mock:
  383. raise RuntimeError(
  384. "No simulation backend configured. "
  385. "Use MotorCADTaskExecutor for real Motor-CAD simulation, "
  386. "or set enable_mock=True for testing."
  387. )
  388. import random
  389. rng = random.Random(index + hash(json.dumps(params, sort_keys=True)) % 10000)
  390. airgap = params.get("airgap_mm", 1.0)
  391. current = params.get("current_a", 15.0)
  392. return {
  393. "tavg_nm": round(current * 2.5 / (airgap ** 0.5) + rng.gauss(0, 0.3), 4),
  394. "efficiency_pct": round(88 + rng.gauss(0, 2), 2),
  395. "total_losses_w": round(50 + rng.gauss(0, 10), 2),
  396. "winding_temp_c": round(90 + rng.gauss(0, 10), 1),
  397. "status": "OK",
  398. "source": "mock",
  399. }
  400. def _compute_metrics(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
  401. """Compute aggregated metrics from results."""
  402. ok_results = [r for r in results if r.get("status") == "OK"]
  403. if not ok_results:
  404. return {
  405. "total_points": len(results),
  406. "successful_points": 0,
  407. "failed_points": len(results),
  408. }
  409. metrics = {
  410. "total_points": len(results),
  411. "successful_points": len(ok_results),
  412. "failed_points": len(results) - len(ok_results),
  413. }
  414. for key in ["tavg_nm", "efficiency_pct", "total_losses_w", "winding_temp_c"]:
  415. values = [r[key] for r in ok_results if key in r]
  416. if values:
  417. metrics[f"{key}_min"] = min(values)
  418. metrics[f"{key}_max"] = max(values)
  419. metrics[f"{key}_mean"] = round(sum(values) / len(values), 4)
  420. return metrics
  421. def _send_heartbeat(self) -> None:
  422. """Register this executor with the Web backend (online status)."""
  423. if requests is None:
  424. return
  425. try:
  426. status = "running" if self._current_task is not None else "idle"
  427. current_task = None
  428. progress = None
  429. if self._current_task is not None:
  430. current_task = self._current_task.get("task_id")
  431. total = self._current_task.get("total_points") or 0
  432. done = self._current_task.get("completed_points") or 0
  433. progress = {
  434. "completed_points": done,
  435. "total_points": total,
  436. }
  437. requests.post(
  438. f"{self.web_base_url}/api/executor/heartbeat",
  439. json={
  440. "executor_id": self.executor_id,
  441. "status": status,
  442. "current_task": current_task,
  443. "progress": progress,
  444. },
  445. timeout=5,
  446. )
  447. self.last_heartbeat_at = time.monotonic()
  448. except Exception:
  449. # Heartbeat failures are non-fatal
  450. pass
  451. def start_polling(self, interval: int = 5) -> List[threading.Thread]:
  452. """Start background threads: one polls/executes tasks, one heartbeats.
  453. Heartbeat runs on its own thread so a long-running Motor-CAD point
  454. (~2 min each) never starves the heartbeat - otherwise the backend
  455. would mark this executor offline mid-task (observed 2026-09-04).
  456. Returns BOTH threads so the caller can watch them; a silently dead
  457. thread must surface as a loud failure, not a zombie executor.
  458. """
  459. self._running = True
  460. self._stop_event.clear()
  461. def heartbeat_loop():
  462. while self._running and not self._stop_event.is_set():
  463. try:
  464. self._send_heartbeat()
  465. except Exception as e:
  466. # _send_heartbeat already swallows request errors; this
  467. # guards the unexpected so the thread never dies silently.
  468. if self.on_error:
  469. self.on_error(f"Heartbeat loop error: {str(e)}")
  470. self._stop_event.wait(interval)
  471. def poll_loop():
  472. while self._running and not self._stop_event.is_set():
  473. try:
  474. tasks = self.fetch_pending_tasks()
  475. for task in tasks:
  476. if self._stop_event.is_set():
  477. break
  478. self.execute_task(task)
  479. # Post-task GC breadcrumb: COM objects released at GC
  480. # time can hang while holding the GIL, freezing every
  481. # thread (zombie executor observed 2026-09-04). Collect
  482. # at a known point so a hang here is pinpointed by the
  483. # surrounding log lines instead of being invisible.
  484. if self.on_progress:
  485. self.on_progress("post-task-gc:begin")
  486. gc.collect()
  487. if self.on_progress:
  488. self.on_progress("post-task-gc:end")
  489. except Exception as e:
  490. if self.on_error:
  491. self.on_error(f"Poll loop error: {str(e)}")
  492. self._stop_event.wait(interval)
  493. hb_thread = threading.Thread(target=heartbeat_loop, daemon=True)
  494. hb_thread.start()
  495. self._hb_thread = hb_thread
  496. thread = threading.Thread(target=poll_loop, daemon=True)
  497. thread.start()
  498. return [thread, hb_thread]
  499. def stop(self):
  500. """Stop the executor."""
  501. self._running = False
  502. self._stop_event.set()
  503. class MotorCADTaskExecutor(TaskExecutor):
  504. """Task executor backed by a registered simulation-tool adapter.
  505. Uses afmcore.adapters.get_adapter(tool) so the executor never
  506. hard-codes a specific solver. Default tool "motorcad" wraps
  507. RobustMotorCADSolver (open_new_instance, set_visible, baseline
  508. reload per point, popup suppression, write-back verification,
  509. per-point disk flush).
  510. Result mapping: adapter returns {metrics, status, error, ...}; the
  511. metrics dict is flattened to the point's top level so downstream
  512. aggregation (TaskExecutor._compute_metrics) keeps working unchanged.
  513. """
  514. def __init__(self, *args, model_path: Optional[str] = None,
  515. tool: str = "motorcad",
  516. enable_thermal: bool = False,
  517. ambient_temperature: Optional[float] = None,
  518. **kwargs):
  519. # Mock fallback is disabled by default for real solver adapter.
  520. kwargs.setdefault("enable_mock", False)
  521. super().__init__(*args, **kwargs)
  522. self.model_path = model_path
  523. self.tool = tool
  524. # P5-M6: pass through to the adapter so each EM point can also run a
  525. # steady-state thermal solve and merge thermal metrics.
  526. self.enable_thermal = bool(enable_thermal)
  527. # P5-M6 thermal boundary: Ambient_Temperature override (degC).
  528. self.ambient_temperature = ambient_temperature
  529. self._adapter = None
  530. def _ensure_adapter(self):
  531. """Lazily create the tool adapter via the platform registry."""
  532. if self._adapter is not None:
  533. return self._adapter
  534. _root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  535. _src = os.path.join(_root, "src")
  536. if _src not in sys.path:
  537. sys.path.insert(0, _src)
  538. from afmcore.adapters import get_adapter
  539. # Dynamically import the adapter module matching self.tool so it
  540. # self-registers in ADAPTER_REGISTRY. Unknown tools rely on
  541. # pre-registered adapters (caller may have imported them).
  542. if self.tool == "motorcad":
  543. import afmcore.adapters.motorcad # noqa: F401
  544. elif self.tool == "maxwell":
  545. import afmcore.adapters.maxwell # noqa: F401
  546. elif self.tool == "jmag":
  547. import afmcore.adapters.jmag # noqa: F401
  548. if not self.model_path:
  549. raise RuntimeError("model_path is required for MotorCADTaskExecutor")
  550. output_dir = os.path.join(
  551. _root, "output", "task_%s" % datetime.now().strftime("%Y%m%d_%H%M%S")
  552. )
  553. self._adapter = get_adapter(
  554. self.tool, model_path=self.model_path, output_dir=output_dir,
  555. enable_thermal=self.enable_thermal,
  556. ambient_temperature=self.ambient_temperature,
  557. )
  558. self._adapter.connect()
  559. return self._adapter
  560. def _run_simulation_point(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
  561. """Run one point through the adapter and flatten metrics to top level.
  562. The adapter owns the robust protocol (baseline reload, write-back
  563. verification, export parsing). A non-OK point raises so execute_task
  564. records status=FAILED (no mock fallback).
  565. When enable_mock=True the base-class mock implementation is used
  566. instead, so no Motor-CAD instance is launched at all (P5-M2).
  567. """
  568. if self.enable_mock:
  569. return super()._run_simulation_point(params, index)
  570. adapter = self._ensure_adapter()
  571. result = adapter.run_point(
  572. self.model_path, params=params,
  573. output_dir=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
  574. tag=str(index),
  575. thermal_mode=getattr(self, "_current_thermal_mode", None),
  576. )
  577. if result.get("status") != "OK":
  578. raise RuntimeError(
  579. result.get("error") or ("Simulation failed (adapter status=%s)"
  580. % result.get("status"))
  581. )
  582. metrics = result.get("metrics") or {}
  583. point = dict(metrics)
  584. point["status"] = "OK"
  585. point["metrics"] = metrics
  586. point["error"] = result.get("error")
  587. point["solve_time_s"] = result.get("solve_time_s")
  588. return point
  589. def cleanup(self):
  590. """Disconnect the adapter and release the tool instance."""
  591. if self._adapter is not None:
  592. try:
  593. self._adapter.disconnect()
  594. except Exception:
  595. pass
  596. self._adapter = None
  597. if __name__ == "__main__":
  598. # Standalone test: run executor with mock data (explicit)
  599. executor = TaskExecutor(
  600. web_base_url=os.environ.get("WEB_BASE_URL", "http://127.0.0.1:8000"),
  601. on_progress=lambda tid, cur, tot: print(f"[{tid}] Progress: {cur}/{tot}"),
  602. on_complete=lambda tid, res, met: print(f"[{tid}] Complete: {len(res)} points"),
  603. on_error=lambda msg: print(f"ERROR: {msg}"),
  604. enable_mock=True,
  605. )
  606. print("Task executor started (mock mode). Press Ctrl+C to stop.")
  607. try:
  608. threads = executor.start_polling(interval=5)
  609. while any(t.is_alive() for t in threads):
  610. time.sleep(1)
  611. except KeyboardInterrupt:
  612. executor.stop()
  613. print("Executor stopped.")