|
@@ -1,7 +1,7 @@
|
|
|
"""Task executor for local simulation system (P4-M2).
|
|
"""Task executor for local simulation system (P4-M2).
|
|
|
|
|
|
|
|
Listens for tasks dispatched from Web backend, executes Motor-CAD
|
|
Listens for tasks dispatched from Web backend, executes Motor-CAD
|
|
|
-simulations, reports progress and results back to Web backend.
|
|
|
|
|
|
|
+simulations via RobustMotorCADSolver, reports progress and results.
|
|
|
|
|
|
|
|
NOTE: All strings must be ASCII only. Chinese text uses \\uXXXX escapes.
|
|
NOTE: All strings must be ASCII only. Chinese text uses \\uXXXX escapes.
|
|
|
"""
|
|
"""
|
|
@@ -20,6 +20,11 @@ try:
|
|
|
except ImportError:
|
|
except ImportError:
|
|
|
requests = None
|
|
requests = None
|
|
|
|
|
|
|
|
|
|
+# Add scripts directory to path for robust_motorcad import
|
|
|
|
|
+_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
+if _SCRIPTS_DIR not in sys.path:
|
|
|
|
|
+ sys.path.insert(0, _SCRIPTS_DIR)
|
|
|
|
|
+
|
|
|
|
|
|
|
|
class TaskExecutor:
|
|
class TaskExecutor:
|
|
|
"""Executes simulation tasks dispatched from Web backend."""
|
|
"""Executes simulation tasks dispatched from Web backend."""
|
|
@@ -31,6 +36,7 @@ class TaskExecutor:
|
|
|
on_progress: Optional[Callable] = None,
|
|
on_progress: Optional[Callable] = None,
|
|
|
on_complete: Optional[Callable] = None,
|
|
on_complete: Optional[Callable] = None,
|
|
|
on_error: Optional[Callable] = None,
|
|
on_error: Optional[Callable] = None,
|
|
|
|
|
+ enable_mock: bool = False,
|
|
|
):
|
|
):
|
|
|
self.web_base_url = web_base_url.rstrip("/")
|
|
self.web_base_url = web_base_url.rstrip("/")
|
|
|
self.task_dir = task_dir or os.path.join(
|
|
self.task_dir = task_dir or os.path.join(
|
|
@@ -41,6 +47,7 @@ class TaskExecutor:
|
|
|
self.on_progress = on_progress
|
|
self.on_progress = on_progress
|
|
|
self.on_complete = on_complete
|
|
self.on_complete = on_complete
|
|
|
self.on_error = on_error
|
|
self.on_error = on_error
|
|
|
|
|
+ self.enable_mock = enable_mock
|
|
|
self._running = False
|
|
self._running = False
|
|
|
self._current_task: Optional[Dict[str, Any]] = None
|
|
self._current_task: Optional[Dict[str, Any]] = None
|
|
|
self._stop_event = threading.Event()
|
|
self._stop_event = threading.Event()
|
|
@@ -64,10 +71,14 @@ class TaskExecutor:
|
|
|
return []
|
|
return []
|
|
|
|
|
|
|
|
def _scan_local_task_files(self) -> List[Dict[str, Any]]:
|
|
def _scan_local_task_files(self) -> List[Dict[str, Any]]:
|
|
|
- """Scan local task directory for task files (fallback mode)."""
|
|
|
|
|
|
|
+ """Scan local task directory for task files (fallback mode).
|
|
|
|
|
+
|
|
|
|
|
+ Only picks up *_task.json files. Completed tasks are renamed
|
|
|
|
|
+ to *_task.done.json to prevent infinite re-execution (B7 fix).
|
|
|
|
|
+ """
|
|
|
tasks = []
|
|
tasks = []
|
|
|
for fname in os.listdir(self.task_dir):
|
|
for fname in os.listdir(self.task_dir):
|
|
|
- if fname.endswith("_task.json"):
|
|
|
|
|
|
|
+ if fname.endswith("_task.json") and not fname.endswith("_task.done.json"):
|
|
|
fpath = os.path.join(self.task_dir, fname)
|
|
fpath = os.path.join(self.task_dir, fname)
|
|
|
try:
|
|
try:
|
|
|
with open(fpath, "r", encoding="utf-8") as f:
|
|
with open(fpath, "r", encoding="utf-8") as f:
|
|
@@ -78,6 +89,17 @@ class TaskExecutor:
|
|
|
continue
|
|
continue
|
|
|
return tasks
|
|
return tasks
|
|
|
|
|
|
|
|
|
|
+ def _mark_local_task_done(self, task: Dict[str, Any]) -> None:
|
|
|
|
|
+ """Rename completed local task file to prevent re-execution (B7 fix)."""
|
|
|
|
|
+ fpath = task.get("_local_file")
|
|
|
|
|
+ if fpath and os.path.exists(fpath):
|
|
|
|
|
+ done_path = fpath.replace("_task.json", "_task.done.json")
|
|
|
|
|
+ try:
|
|
|
|
|
+ os.rename(fpath, done_path)
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ if self.on_error:
|
|
|
|
|
+ self.on_error(f"Failed to mark task done: {str(e)}")
|
|
|
|
|
+
|
|
|
def dispatch_task(self, task_id: str) -> bool:
|
|
def dispatch_task(self, task_id: str) -> bool:
|
|
|
"""Mark task as dispatched on Web backend."""
|
|
"""Mark task as dispatched on Web backend."""
|
|
|
if requests is None:
|
|
if requests is None:
|
|
@@ -185,6 +207,7 @@ class TaskExecutor:
|
|
|
point_result["params"] = params
|
|
point_result["params"] = params
|
|
|
results.append(point_result)
|
|
results.append(point_result)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
|
|
+ # A2 fix: failed points are recorded as failed, NOT mock data
|
|
|
results.append({
|
|
results.append({
|
|
|
"point_index": idx,
|
|
"point_index": idx,
|
|
|
"params": params,
|
|
"params": params,
|
|
@@ -196,11 +219,23 @@ class TaskExecutor:
|
|
|
|
|
|
|
|
duration = time.time() - start_time
|
|
duration = time.time() - start_time
|
|
|
metrics = self._compute_metrics(results)
|
|
metrics = self._compute_metrics(results)
|
|
|
- status = "completed" if not self._stop_event.is_set() else "cancelled"
|
|
|
|
|
|
|
+ # Status reflects actual outcome: completed/cancelled/failed
|
|
|
|
|
+ if self._stop_event.is_set():
|
|
|
|
|
+ status = "cancelled"
|
|
|
|
|
+ elif any(r.get("status") == "failed" for r in results):
|
|
|
|
|
+ status = "completed_with_errors" if any(
|
|
|
|
|
+ r.get("status") == "ok" for r in results
|
|
|
|
|
+ ) else "failed"
|
|
|
|
|
+ else:
|
|
|
|
|
+ status = "completed"
|
|
|
|
|
|
|
|
self.report_results(task_id, results, metrics, None, duration, status)
|
|
self.report_results(task_id, results, metrics, None, duration, status)
|
|
|
self.report_progress(task_id, total_points, total_points, None, duration)
|
|
self.report_progress(task_id, total_points, total_points, None, duration)
|
|
|
|
|
|
|
|
|
|
+ # B7 fix: mark local task file as done to prevent re-execution
|
|
|
|
|
+ if requests is None:
|
|
|
|
|
+ self._mark_local_task_done(task)
|
|
|
|
|
+
|
|
|
self._current_task = None
|
|
self._current_task = None
|
|
|
if self.on_complete:
|
|
if self.on_complete:
|
|
|
self.on_complete(task_id, results, metrics)
|
|
self.on_complete(task_id, results, metrics)
|
|
@@ -208,8 +243,16 @@ class TaskExecutor:
|
|
|
def _run_simulation_point(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
def _run_simulation_point(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
|
"""Run a single simulation point. Override in subclass.
|
|
"""Run a single simulation point. Override in subclass.
|
|
|
|
|
|
|
|
- Template implementation returns mock data.
|
|
|
|
|
|
|
+ Mock data is ONLY returned when enable_mock=True (explicit opt-in).
|
|
|
|
|
+ Mock results are tagged with source="mock" so they can never be
|
|
|
|
|
+ confused with real simulation data (A2 fix).
|
|
|
"""
|
|
"""
|
|
|
|
|
+ if not self.enable_mock:
|
|
|
|
|
+ raise RuntimeError(
|
|
|
|
|
+ "No simulation backend configured. "
|
|
|
|
|
+ "Use MotorCADTaskExecutor for real Motor-CAD simulation, "
|
|
|
|
|
+ "or set enable_mock=True for testing."
|
|
|
|
|
+ )
|
|
|
import random
|
|
import random
|
|
|
rng = random.Random(index + hash(json.dumps(params, sort_keys=True)) % 10000)
|
|
rng = random.Random(index + hash(json.dumps(params, sort_keys=True)) % 10000)
|
|
|
airgap = params.get("airgap_mm", 1.0)
|
|
airgap = params.get("airgap_mm", 1.0)
|
|
@@ -220,16 +263,22 @@ class TaskExecutor:
|
|
|
"total_losses_w": round(50 + rng.gauss(0, 10), 2),
|
|
"total_losses_w": round(50 + rng.gauss(0, 10), 2),
|
|
|
"winding_temp_c": round(90 + rng.gauss(0, 10), 1),
|
|
"winding_temp_c": round(90 + rng.gauss(0, 10), 1),
|
|
|
"status": "ok",
|
|
"status": "ok",
|
|
|
|
|
+ "source": "mock",
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
def _compute_metrics(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
def _compute_metrics(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
"""Compute aggregated metrics from results."""
|
|
"""Compute aggregated metrics from results."""
|
|
|
ok_results = [r for r in results if r.get("status") == "ok"]
|
|
ok_results = [r for r in results if r.get("status") == "ok"]
|
|
|
if not ok_results:
|
|
if not ok_results:
|
|
|
- return {"total_points": len(results), "successful_points": 0}
|
|
|
|
|
|
|
+ return {
|
|
|
|
|
+ "total_points": len(results),
|
|
|
|
|
+ "successful_points": 0,
|
|
|
|
|
+ "failed_points": len(results),
|
|
|
|
|
+ }
|
|
|
metrics = {
|
|
metrics = {
|
|
|
"total_points": len(results),
|
|
"total_points": len(results),
|
|
|
"successful_points": len(ok_results),
|
|
"successful_points": len(ok_results),
|
|
|
|
|
+ "failed_points": len(results) - len(ok_results),
|
|
|
}
|
|
}
|
|
|
for key in ["tavg_nm", "efficiency_pct", "total_losses_w", "winding_temp_c"]:
|
|
for key in ["tavg_nm", "efficiency_pct", "total_losses_w", "winding_temp_c"]:
|
|
|
values = [r[key] for r in ok_results if key in r]
|
|
values = [r[key] for r in ok_results if key in r]
|
|
@@ -268,70 +317,78 @@ class TaskExecutor:
|
|
|
|
|
|
|
|
|
|
|
|
|
class MotorCADTaskExecutor(TaskExecutor):
|
|
class MotorCADTaskExecutor(TaskExecutor):
|
|
|
- """Task executor that uses actual Motor-CAD for simulation.
|
|
|
|
|
-
|
|
|
|
|
- Overrides _run_simulation_point to call Motor-CAD via pymotorcad.
|
|
|
|
|
- Falls back to mock data if Motor-CAD is not available.
|
|
|
|
|
|
|
+ """Task executor that uses RobustMotorCADSolver for real Motor-CAD simulation.
|
|
|
|
|
+
|
|
|
|
|
+ A3/A4/A5 fixes:
|
|
|
|
|
+ - Reuses RobustMotorCADSolver (open_new_instance=True, set_visible,
|
|
|
|
|
+ baseline reload per point, popup suppression, write-back verification)
|
|
|
|
|
+ - Write-back verification failures propagate (no silent except:pass)
|
|
|
|
|
+ - Results extracted via export file parsing (not bogus get_variable names)
|
|
|
|
|
+ - Failed points raise exception -> recorded as status=failed (no mock fallback)
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self, *args, model_path: Optional[str] = None, **kwargs):
|
|
def __init__(self, *args, model_path: Optional[str] = None, **kwargs):
|
|
|
|
|
+ # Mock fallback is disabled by default for real Motor-CAD executor
|
|
|
|
|
+ kwargs.setdefault("enable_mock", False)
|
|
|
super().__init__(*args, **kwargs)
|
|
super().__init__(*args, **kwargs)
|
|
|
self.model_path = model_path
|
|
self.model_path = model_path
|
|
|
- self._mc = None
|
|
|
|
|
|
|
+ self._solver = None
|
|
|
|
|
+
|
|
|
|
|
+ def _ensure_solver(self):
|
|
|
|
|
+ """Lazily create RobustMotorCADSolver instance."""
|
|
|
|
|
+ if self._solver is not None:
|
|
|
|
|
+ return self._solver
|
|
|
|
|
+ from robust_motorcad import RobustMotorCADSolver
|
|
|
|
|
+ if not self.model_path:
|
|
|
|
|
+ raise RuntimeError("model_path is required for MotorCADTaskExecutor")
|
|
|
|
|
+ # Output directory under task dir
|
|
|
|
|
+ output_dir = os.path.join(
|
|
|
|
|
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
|
|
|
+ "output", f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
|
|
|
+ )
|
|
|
|
|
+ self._solver = RobustMotorCADSolver(
|
|
|
|
|
+ model_path=self.model_path,
|
|
|
|
|
+ output_dir=output_dir,
|
|
|
|
|
+ )
|
|
|
|
|
+ self._solver.connect()
|
|
|
|
|
+ return self._solver
|
|
|
|
|
|
|
|
def _run_simulation_point(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
def _run_simulation_point(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
|
- """Run simulation using Motor-CAD. Falls back to mock if unavailable."""
|
|
|
|
|
- try:
|
|
|
|
|
- return self._run_motorcad(params, index)
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- if self.on_error:
|
|
|
|
|
- self.on_error(f"MotorCAD failed for point {index}, using mock: {str(e)}")
|
|
|
|
|
- return super()._run_simulation_point(params, index)
|
|
|
|
|
-
|
|
|
|
|
- def _run_motorcad(self, params: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
|
|
|
- """Actual Motor-CAD simulation. Requires pymotorcad and Motor-CAD."""
|
|
|
|
|
- try:
|
|
|
|
|
- from ansys.motorcad.core import MotorCAD
|
|
|
|
|
- except ImportError:
|
|
|
|
|
- raise RuntimeError("pymotorcad not installed")
|
|
|
|
|
-
|
|
|
|
|
- if self._mc is None:
|
|
|
|
|
- self._mc = MotorCAD()
|
|
|
|
|
- if self.model_path and os.path.exists(self.model_path):
|
|
|
|
|
- self._mc.load_from_file(self.model_path)
|
|
|
|
|
-
|
|
|
|
|
- # Write parameters
|
|
|
|
|
- for key, value in params.items():
|
|
|
|
|
|
|
+ """Run a single simulation point via RobustMotorCADSolver.
|
|
|
|
|
+
|
|
|
|
|
+ A2 fix: No mock fallback on failure. Exception propagates to
|
|
|
|
|
+ execute_task which records status=failed.
|
|
|
|
|
+ A3 fix: RobustMotorCADSolver handles open_new_instance, set_visible,
|
|
|
|
|
+ baseline reload, popup suppression.
|
|
|
|
|
+ A4 fix: Write-back verification inside solver raises on mismatch.
|
|
|
|
|
+ A5 fix: Results from export file parsing, not get_variable.
|
|
|
|
|
+ """
|
|
|
|
|
+ solver = self._ensure_solver()
|
|
|
|
|
+ # run_single_point handles baseline reload, write-verify, calculation,
|
|
|
|
|
+ # export, parsing, and per-point disk flush.
|
|
|
|
|
+ point_result = solver.run_single_point(params, point_index=index)
|
|
|
|
|
+ return point_result
|
|
|
|
|
+
|
|
|
|
|
+ def cleanup(self):
|
|
|
|
|
+ """Disconnect solver and release Motor-CAD instance."""
|
|
|
|
|
+ if self._solver is not None:
|
|
|
try:
|
|
try:
|
|
|
- self._mc.set_variable(key, value)
|
|
|
|
|
- applied = float(self._mc.get_variable(key))
|
|
|
|
|
- if abs(applied - value) > 1e-6:
|
|
|
|
|
- raise RuntimeError(f"Variable {key} write mismatch: {applied} != {value}")
|
|
|
|
|
|
|
+ self._solver.disconnect()
|
|
|
except Exception:
|
|
except Exception:
|
|
|
pass
|
|
pass
|
|
|
-
|
|
|
|
|
- # Run simulation
|
|
|
|
|
- self._mc.do_magnetic_calculation()
|
|
|
|
|
-
|
|
|
|
|
- # Read results
|
|
|
|
|
- result = {
|
|
|
|
|
- "tavg_nm": float(self._mc.get_variable("Torque_Avg")),
|
|
|
|
|
- "efficiency_pct": float(self._mc.get_variable("Efficiency")),
|
|
|
|
|
- "total_losses_w": float(self._mc.get_variable("Total_Losses")),
|
|
|
|
|
- "status": "ok",
|
|
|
|
|
- }
|
|
|
|
|
- return result
|
|
|
|
|
|
|
+ self._solver = None
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if __name__ == "__main__":
|
|
|
- # Standalone test: run executor with mock data
|
|
|
|
|
|
|
+ # Standalone test: run executor with mock data (explicit)
|
|
|
executor = TaskExecutor(
|
|
executor = TaskExecutor(
|
|
|
web_base_url=os.environ.get("WEB_BASE_URL", "http://127.0.0.1:8000"),
|
|
web_base_url=os.environ.get("WEB_BASE_URL", "http://127.0.0.1:8000"),
|
|
|
on_progress=lambda tid, cur, tot: print(f"[{tid}] Progress: {cur}/{tot}"),
|
|
on_progress=lambda tid, cur, tot: print(f"[{tid}] Progress: {cur}/{tot}"),
|
|
|
on_complete=lambda tid, res, met: print(f"[{tid}] Complete: {len(res)} points"),
|
|
on_complete=lambda tid, res, met: print(f"[{tid}] Complete: {len(res)} points"),
|
|
|
on_error=lambda msg: print(f"ERROR: {msg}"),
|
|
on_error=lambda msg: print(f"ERROR: {msg}"),
|
|
|
|
|
+ enable_mock=True,
|
|
|
)
|
|
)
|
|
|
- print("Task executor started. Press Ctrl+C to stop.")
|
|
|
|
|
|
|
+ print("Task executor started (mock mode). Press Ctrl+C to stop.")
|
|
|
try:
|
|
try:
|
|
|
thread = executor.start_polling(interval=5)
|
|
thread = executor.start_polling(interval=5)
|
|
|
while thread.is_alive():
|
|
while thread.is_alive():
|