diff --git a/PSCAD/pscad_mcp/core/connection_manager.py b/PSCAD/pscad_mcp/core/connection_manager.py index 5e2d0ed..37025b8 100644 --- a/PSCAD/pscad_mcp/core/connection_manager.py +++ b/PSCAD/pscad_mcp/core/connection_manager.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import psutil import logging from typing import Optional, TYPE_CHECKING @@ -54,6 +55,15 @@ def is_process_running(self) -> bool: async def attach_local(self) -> str: """Robustly attach to any local PSCAD instance or launch a new one.""" + # PSCAD's generated EMTDC run batch launches the compiled solver by a + # bare name from the build directory (``pushd `` then + # ``.exe``). If ``NoDefaultCurrentDirectoryInExePath`` is set in + # our environment (some launchers set it), PSCAD inherits it and Windows + # refuses to find the exe in the current directory -- the run dies + # immediately with "'.exe' is not recognized as an internal or + # external command". Scrub it so the PSCAD instance we launch, and the + # EMTDC processes it spawns, can run normally. + os.environ.pop("NoDefaultCurrentDirectoryInExePath", None) try: import mhi.pscad except ImportError as e: @@ -63,7 +73,17 @@ async def attach_local(self) -> str: "Original import error: " + repr(e) ) try: - self._pscad = await robust_executor.run_safe(mhi.pscad.application) + # Cold-launching PSCAD can take well over the default 30 s watchdog; + # give the attach/launch a generous timeout. + try: + self._pscad = await robust_executor.run_safe(mhi.pscad.application, _timeout=120) + except Exception as attach_err: + # application() only catches ConnectionRefusedError before + # launching; a stale PSCAD process with no automation listener + # raises ProcessLookupError instead, so it never falls back to + # launching. Start a fresh instance explicitly in that case. + logger.info("Could not attach to a running PSCAD (%s); launching a new instance.", attach_err) + self._pscad = await robust_executor.run_safe(mhi.pscad.launch, _timeout=180) return f"Successfully attached to PSCAD {self._pscad.version} (Local)." except Exception as e: logger.error(f"Attach failed: {str(e)}") diff --git a/PSCAD/pscad_mcp/core/executor.py b/PSCAD/pscad_mcp/core/executor.py index 8da14cd..c2b2de4 100644 --- a/PSCAD/pscad_mcp/core/executor.py +++ b/PSCAD/pscad_mcp/core/executor.py @@ -6,6 +6,24 @@ logger = logging.getLogger("pscad-mcp.executor") + +def _init_worker_com(): + """Initialize COM once for the single executor worker thread. + + PSCAD's automation discovery uses WMI (SWbemServices), which caches a COM + object on first use. Initializing and uninitializing COM around every call + tears that cached object down, so the next WMI-using call fails with + "object invoked has disconnected from its clients". Because the executor + uses a single long-lived worker, COM is initialized exactly once here (when + the thread starts) and left initialized for the thread's lifetime. + """ + try: + import pythoncom + pythoncom.CoInitialize() + except ImportError: + pass + + class RobustExecutor: """ Implements the Command/Proxy pattern to wrap PSCAD calls @@ -14,39 +32,38 @@ class RobustExecutor: def __init__(self, timeout: float = 30.0): self.timeout = timeout self.lock = threading.Lock() - # PSCAD is single-threaded via COM; use a single worker executor - self.executor = ThreadPoolExecutor(max_workers=1) + # PSCAD is single-threaded via COM; use a single worker whose COM + # apartment is initialized once for its lifetime (see _init_worker_com). + self.executor = ThreadPoolExecutor(max_workers=1, initializer=_init_worker_com) + + async def run_safe(self, func: Callable, *args, _timeout: float | None = None, **kwargs) -> Any: + """Execute a PSCAD call in a separate thread with a watchdog timeout. - async def run_safe(self, func: Callable, *args, **kwargs) -> Any: - """Execute a PSCAD call in a separate thread with a watchdog timeout.""" + The watchdog defaults to ``self.timeout`` (30 s) so a frozen PSCAD or a + modal dialog cannot hang the server. Long-running calls (loading a large + project, a clean build) can override it via the ``_timeout`` keyword: + a larger value extends the watchdog, while ``0``/``None`` disables it + entirely. The leading underscore keeps the name from colliding with any + keyword argument forwarded to ``func``. + """ loop = asyncio.get_running_loop() func_name = getattr(func, "__name__", str(func)) + effective_timeout = self.timeout if _timeout is None else _timeout + wait_timeout = None if not effective_timeout or effective_timeout <= 0 else effective_timeout def wrapped_call(): - # Initialize COM for this thread (required for WMI/PSCAD on Windows) - try: - import pythoncom - pythoncom.CoInitialize() - except ImportError: - pass - - try: - with self.lock: - return func(*args, **kwargs) - finally: - try: - import pythoncom - pythoncom.CoUninitialize() - except ImportError: - pass + # COM is initialized once for the worker thread (see _init_worker_com); + # do not re-init/uninit per call, or cached WMI/COM objects break. + with self.lock: + return func(*args, **kwargs) try: return await asyncio.wait_for( - loop.run_in_executor(self.executor, wrapped_call), - timeout=self.timeout + loop.run_in_executor(self.executor, wrapped_call), + timeout=wait_timeout ) except asyncio.TimeoutError: - logger.error(f"PSCAD Command {func_name} timed out after {self.timeout}s.") + logger.error(f"PSCAD Command {func_name} timed out after {wait_timeout}s.") raise RuntimeError(f"PSCAD timed out during {func_name}. It might be frozen or showing a dialog.") except Exception as e: logger.error(f"Error in {func_name}: {str(e)}") diff --git a/PSCAD/pscad_mcp/tools/data_tools.py b/PSCAD/pscad_mcp/tools/data_tools.py index 17da10a..53f685b 100644 --- a/PSCAD/pscad_mcp/tools/data_tools.py +++ b/PSCAD/pscad_mcp/tools/data_tools.py @@ -1,34 +1,314 @@ -from typing import Dict, Any +from typing import Dict, Any, List, Optional, Union import os +import glob +import math import logging from mcp.server.fastmcp import FastMCP from pscad_mcp.core.connection_manager import pscad_manager from pscad_mcp.core.executor import robust_executor +logger = logging.getLogger("pscad-mcp.data") + + async def get_project_output(project_name: str) -> str: """Get the text output messages from the PSCAD project's runtime.""" pscad = pscad_manager.pscad project = await robust_executor.run_safe(pscad.project, project_name) return await robust_executor.run_safe(project.output) -async def read_output_file(file_path: str) -> Dict[str, Any]: - """Read results from a .psout or .out file using mhi.psout.""" + +async def _resolve_psout(name_or_file: str) -> str: + """Resolve a project name or path to an absolute ``.psout`` file path. + + Accepts either a direct path to a ``.psout`` file or a loaded project's + name. For a project name, the newest ``.psout`` in the project's + compiler-dependent temp folder is returned (that is where EMTDC writes the + consolidated binary output). Raises ``FileNotFoundError`` with the searched + locations if nothing is found. + """ + candidate = os.path.abspath(name_or_file) + if os.path.isfile(candidate): + if candidate.lower().endswith(".psout"): + return candidate + if candidate.lower().endswith(".out"): + # The legacy .out ASCII format is not readable by mhi.psout; prefer a + # sibling .psout written by the same run, if present. + siblings = glob.glob(os.path.join(os.path.dirname(candidate), "*.psout")) + if siblings: + return max(siblings, key=os.path.getmtime) + return candidate # let mhi.psout.File raise a clear error + + # Treat as a project name: locate the newest .psout in its temp folder. + pscad = pscad_manager.pscad + project = await robust_executor.run_safe(pscad.project, name_or_file) + searched: List[str] = [] + temp_folder: Optional[str] = None + try: + temp_folder = await robust_executor.run_safe(lambda: project.temp_folder) + except Exception as e: # pragma: no cover - depends on live PSCAD + logger.warning("Could not read temp_folder for %s: %s", name_or_file, e) + + for folder in filter(None, [temp_folder]): + searched.append(folder) + matches = glob.glob(os.path.join(folder, "*.psout")) + if matches: + return max(matches, key=os.path.getmtime) + + raise FileNotFoundError( + f"No .psout file found for project '{name_or_file}'. " + f"Searched: {searched or '[temp folder unavailable]'}. " + "Ensure the run has finished and the project's output format is PSOUT." + ) + + +def _meta(call, key): + """Read a variable from a call node (Name/Unit/Group live on the PGB call).""" + if call is None: + return None + try: + return call.get(key) + except Exception: # pragma: no cover - defensive + return None + + +def _iter_traces(run): + """Yield (pgb_call, trace, path) triples for every data trace in the run. + + PSCAD .psout files organise output as ``PGB`` nodes (the user-facing + channel, carrying Name/Unit/Group) each with a ``Data`` record whose + children are the actual ``Trace`` data columns (a multi-phase signal has + several traces under one PGB). The library's ``[@Source='Trace']`` filter + does not match this nesting, so we walk the whole call tree once, index the + PGB nodes, and attach each trace to its PGB grandparent via the path. The + friendly metadata therefore comes from ``pgb_call``; the samples come from + ``trace``. Falls back to positional enumeration if no traces are found. + """ + f = run.file + sep = getattr(f, "_sep", "/") + pgb_by_path = {} + trace_calls = [] + try: + for call, path in f.call_paths("**"): + source = call.get("Source") + if source == "PGB": + pgb_by_path[path] = call + elif source == "Trace": + trace_calls.append((call, path)) + except Exception as e: # pragma: no cover - defensive + logger.warning("call_paths('**') failed (%s); falling back to positional traces", e) + + if trace_calls: + for call, path in trace_calls: + parts = path.split(sep) + # trace path is //; drop the last two. + parent = sep.join(parts[:-2]) if len(parts) > 2 else path + yield pgb_by_path.get(parent), run.trace(call), path + return + + for i, trace in enumerate(run.traces()): + yield None, trace, f"#{i}" + + +def _summarize(values: List[float]) -> Dict[str, Any]: + n = len(values) + if n == 0: + return {"count": 0} + fvals = [float(v) for v in values] + total = math.fsum(fvals) + sq = math.fsum(v * v for v in fvals) + return { + "count": n, + "min": min(fvals), + "max": max(fvals), + "mean": total / n, + "final": fvals[-1], + "rms": math.sqrt(sq / n), + } + + +def _match_channel(path: str, name: Optional[str], wanted: List[str]) -> Optional[str]: + """Return the requested identifier that matches this trace, or None. + + Matching, in priority order: exact path, exact name, exact name + (case-insensitive), path endswith identifier. + """ + sname = None if name is None else str(name) + for w in wanted: + if w == path or (sname is not None and w == sname): + return w + for w in wanted: + if sname is not None and w.lower() == sname.lower(): + return w + if path and path.endswith(w): + return w + return None + + +async def list_output_channels(name_or_file: str, run_index: int = 0) -> Dict[str, Any]: + """List the output channels (traces) available in a project's .psout file. + + Returns lightweight metadata only (path, name, description, unit, group, + sample count) -- not the data itself -- so it is safe to call on files with + hundreds of channels. Pass a loaded project's name or a direct path to a + ``.psout`` file. Use the returned ``path`` values with + ``read_output_channels`` to fetch data. + """ + try: + import mhi.psout + except ImportError: + return {"error": "mhi-psout package not installed."} + + try: + path = await _resolve_psout(name_or_file) + except Exception as e: + return {"error": str(e)} + + channels: List[Dict[str, Any]] = [] + try: + with mhi.psout.File(path) as f: + num_runs = f.num_runs + run = f.run(run_index) + for call, trace, pth in _iter_traces(run): + channels.append({ + "path": pth, + "name": _meta(call, "Name"), + "desc": _meta(call, "Desc"), + "unit": _meta(call, "Unit"), + "group": _meta(call, "Group"), + "samples": getattr(trace, "size", None), + }) + except Exception as e: + return {"error": str(e), "file": path} + + return { + "file": path, + "num_runs": num_runs, + "channel_count": len(channels), + "channels": channels, + } + + +async def read_output_channels( + name_or_file: str, + channels: Union[List[str], str], + run_index: int = 0, + summary: bool = True, + max_points: int = 2000, +) -> Dict[str, Any]: + """Read selected output channels from a project's .psout file. + + ``channels`` must be an explicit list of identifiers (channel ``path`` or + ``name`` from ``list_output_channels``) -- this guards against accidentally + pulling every channel (a file may hold hundreds of channels with tens of + thousands of samples each). For each channel this returns summary statistics + (``min/max/mean/final/rms``) and a downsampled preview of at most + ``max_points`` (time, value) points. + """ try: import mhi.psout except ImportError: return {"error": "mhi-psout package not installed."} + if isinstance(channels, str): + channels = [channels] + if not channels: + return {"error": "Specify a 'channels' list (see list_output_channels); " + "refusing to read all channels at once."} + if len(channels) > 30: + return {"error": f"Too many channels requested ({len(channels)}); cap is 30. " + "Read in smaller batches."} + max_points = max(1, min(int(max_points or 2000), 5000)) + try: - abs_path = os.path.abspath(file_path) - with mhi.psout.open(abs_path) as psout: - data = {} - for channel in psout.channels(): - data[channel.name] = psout.channel(channel.name).values().tolist() - return {"channels": list(data.keys()), "data": data} + path = await _resolve_psout(name_or_file) except Exception as e: return {"error": str(e)} + wanted = list(channels) + result: Dict[str, Any] = {} + matched = set() + sample_count: Optional[int] = None + try: + with mhi.psout.File(path) as f: + run = f.run(run_index) + for call, trace, pth in _iter_traces(run): + name = _meta(call, "Name") + key = _match_channel(pth, name, wanted) + if key is None or key in matched: + continue + matched.add(key) + y = list(trace.data) + try: + domain = trace.domain + t = list(domain.data) if domain is not None else [] + except Exception: + t = [] + sample_count = len(y) + step = max(1, len(y) // max_points) + entry: Dict[str, Any] = { + "path": pth, + "name": name, + "unit": _meta(call, "Unit"), + "preview": { + "step": step, + "time": [float(v) for v in t[::step]] if t else [], + "values": [float(v) for v in y[::step]], + }, + } + if summary: + entry["summary"] = _summarize(y) + result[key] = entry + except Exception as e: + return {"error": str(e), "file": path} + + not_found = [c for c in wanted if c not in matched] + return { + "file": path, + "run_index": run_index, + "sample_count": sample_count, + "channels": result, + "not_found": not_found, + } + + +async def read_output_file(file_path: str, summary: bool = True) -> Dict[str, Any]: + """Read a .psout results file (back-compatible direct-path entry point). + + Returns per-channel metadata, and summary statistics when ``summary`` is + True. It deliberately never returns the full sample arrays; use + ``read_output_channels`` to fetch (downsampled) data for selected channels. + """ + try: + import mhi.psout + except ImportError: + return {"error": "mhi-psout package not installed."} + + try: + path = await _resolve_psout(file_path) + except Exception as e: + return {"error": str(e)} + + channels: Dict[str, Any] = {} + try: + with mhi.psout.File(path) as f: + run = f.run(0) + for call, trace, pth in _iter_traces(run): + entry: Dict[str, Any] = { + "name": _meta(call, "Name"), + "unit": _meta(call, "Unit"), + } + if summary: + entry["summary"] = _summarize(list(trace.data)) + channels[pth] = entry + except Exception as e: + return {"error": str(e), "file": path} + + return {"file": path, "channel_count": len(channels), "channels": channels} + + def register_data_tools(mcp: FastMCP): """Register tools for reading simulation results and output.""" mcp.tool()(get_project_output) + mcp.tool()(list_output_channels) + mcp.tool()(read_output_channels) mcp.tool()(read_output_file) diff --git a/PSCAD/pscad_mcp/tools/project_tools.py b/PSCAD/pscad_mcp/tools/project_tools.py index 316c5ea..c6b416d 100644 --- a/PSCAD/pscad_mcp/tools/project_tools.py +++ b/PSCAD/pscad_mcp/tools/project_tools.py @@ -8,7 +8,9 @@ async def load_projects(filenames: List[str]) -> str: """Load projects or workspace into PSCAD.""" pscad = pscad_manager.pscad abs_paths = [os.path.abspath(f) for f in filenames] - await robust_executor.run_safe(pscad.load, *abs_paths) + # Parsing a large .pscx (the MMC case is ~1.1 MB) can exceed the default + # 30 s watchdog, so give loading a generous timeout. + await robust_executor.run_safe(pscad.load, *abs_paths, _timeout=120) return f"Loaded: {', '.join(abs_paths)}" async def list_projects() -> List[Dict[str, str]]: @@ -16,22 +18,91 @@ async def list_projects() -> List[Dict[str, str]]: pscad = pscad_manager.pscad return await robust_executor.run_safe(pscad.projects) -async def run_project(project_name: str) -> str: - """Start simulation for a given project.""" +async def run_project(project_name: str) -> Dict[str, Any]: + """Start a (build &) simulation for a given project, without blocking. + + Uses the non-blocking ``Project.start()`` (focuses the project, then fires + the run) so this call returns immediately rather than holding the single + PSCAD worker for the entire multi-minute build+run. Poll ``get_run_status`` + to follow progress: it transitions ``building`` -> ``running`` -> + ``idle_or_finished``. If it returns to ``idle_or_finished`` without ever + reporting ``running``, the build failed -- call ``get_build_messages`` and + ``get_project_output`` to see why (e.g. a missing Fortran compiler). + """ + pscad = pscad_manager.pscad + if not await robust_executor.run_safe(pscad.licensed): + return {"started": False, "error": "PSCAD is not licensed."} + + project = await robust_executor.run_safe(pscad.project, project_name) + await robust_executor.run_safe(project.start) + return { + "started": True, + "project": project_name, + "note": "Build+run started. Poll get_run_status until state is 'idle_or_finished'.", + } + +async def build_project(project_name: str, clean: bool = True) -> Dict[str, Any]: + """Build (compile) a project without running it, and report any messages. + + This is a blocking compile used as a pre-flight check: it surfaces compiler + and link errors (for example a missing/misconfigured Fortran compiler) + before committing to a long simulation. The watchdog is disabled for the + build, so no other PSCAD call can run until it finishes. + """ pscad = pscad_manager.pscad - if not pscad.licensed(): - return "Error: PSCAD is not licensed." - project = await robust_executor.run_safe(pscad.project, project_name) - await robust_executor.run_safe(project.run) - return f"Simulation started for '{project_name}'." + builder = project.build if clean else project.build_modified + await robust_executor.run_safe(builder, _timeout=0) + messages = await robust_executor.run_safe(project.messages) + errors, warnings = _split_messages(messages) + return { + "built": len(errors) == 0, + "errors": errors, + "warnings": warnings, + } async def get_run_status(project_name: str) -> Dict[str, Any]: - """Get simulation progress and state.""" + """Get simulation progress and a derived high-level state. + + ``state`` is one of ``building`` (compiling), ``running`` (simulating, with + ``progress`` 0-100), or ``idle_or_finished`` (not building or running -- + i.e. completed, not yet started, or failed). + """ pscad = pscad_manager.pscad project = await robust_executor.run_safe(pscad.project, project_name) status, progress = await robust_executor.run_safe(project.run_status) - return {"status": status, "progress": progress} + if status == "Build": + state = "building" + elif status == "Run": + state = "running" + else: + state = "idle_or_finished" + return {"state": state, "raw_status": status, "progress": progress} + +async def get_build_messages(project_name: str) -> Dict[str, Any]: + """Return the load/build messages for a project, split into errors/warnings.""" + pscad = pscad_manager.pscad + project = await robust_executor.run_safe(pscad.project, project_name) + messages = await robust_executor.run_safe(project.messages) + errors, warnings = _split_messages(messages) + info_count = len(messages) - len(errors) - len(warnings) + return {"errors": errors, "warnings": warnings, "info_count": info_count} + +def _split_messages(messages: Any) -> tuple: + """Split PSCAD build messages into (errors, warnings) lists of dicts.""" + errors, warnings = [], [] + for msg in messages or []: + status = str(getattr(msg, "status", "")).lower() + entry = { + "text": getattr(msg, "text", str(msg)), + "component": getattr(msg, "name", None), + "status": getattr(msg, "status", None), + } + if "error" in status: + errors.append(entry) + elif "warn" in status: + warnings.append(entry) + return errors, warnings async def find_components( project_name: str, @@ -77,17 +148,26 @@ async def validate_component_parameters(project_name: str, component_id: int, pa return validation_results async def get_project_settings(project_name: str) -> Dict[str, Any]: - """Get all settings for a project.""" + """Get all settings (run parameters) for a project. + + Includes keys such as ``time_step``, ``time_duration``, ``sample_step``, + ``PlotType`` ("NONE"/"OUT"/"PSOUT"), ``StartType``, ``SnapType`` and + ``MrunType``. + """ pscad = pscad_manager.pscad project = await robust_executor.run_safe(pscad.project, project_name) - settings = await robust_executor.run_safe(project.settings) + settings = await robust_executor.run_safe(project.parameters) return settings if settings else {} async def set_project_settings(project_name: str, settings: Dict[str, Any]) -> str: - """Update project settings.""" + """Update project settings (run parameters). + + For example ``{"PlotType": "PSOUT"}`` to write a consolidated .psout output + file, or ``{"time_duration": 5.0}`` to change the run length. + """ pscad = pscad_manager.pscad project = await robust_executor.run_safe(pscad.project, project_name) - await robust_executor.run_safe(project.settings, **settings) + await robust_executor.run_safe(project.parameters, **settings) return f"Settings updated for project '{project_name}'." def register_project_tools(mcp: FastMCP): @@ -95,7 +175,9 @@ def register_project_tools(mcp: FastMCP): mcp.tool()(load_projects) mcp.tool()(list_projects) mcp.tool()(run_project) + mcp.tool()(build_project) mcp.tool()(get_run_status) + mcp.tool()(get_build_messages) mcp.tool()(find_components) mcp.tool()(get_component_parameters) mcp.tool()(set_component_parameters) diff --git a/PSCAD/tests/test_enhanced_tools.py b/PSCAD/tests/test_enhanced_tools.py index ed4d62f..e9d97a0 100644 --- a/PSCAD/tests/test_enhanced_tools.py +++ b/PSCAD/tests/test_enhanced_tools.py @@ -96,12 +96,12 @@ async def test_run_simulation_set(self): async def test_get_project_settings(self): """Test retrieving project settings.""" - self.mock_project.settings.return_value = {"Duration": "0.5", "TimeStep": "50"} - + self.mock_project.parameters.return_value = {"Duration": "0.5", "TimeStep": "50"} + result = await get_project_settings("TestProj") - + self.assertEqual(result["Duration"], "0.5") - self.mock_project.settings.assert_called_once() + self.mock_project.parameters.assert_called_once() if __name__ == '__main__': unittest.main() diff --git a/PSCAD/tests/test_tools.py b/PSCAD/tests/test_tools.py index c060504..9b5a4d8 100644 --- a/PSCAD/tests/test_tools.py +++ b/PSCAD/tests/test_tools.py @@ -48,7 +48,8 @@ async def test_run_unlicensed_project(self): """Edge case: Attempting simulation without a valid license.""" self.mock_pscad.licensed.return_value = False result = await run_project(project_name="test") - self.assertIn("not licensed", result) + self.assertFalse(result["started"]) + self.assertIn("not licensed", result["error"]) async def test_find_no_components(self): """Edge case: Searching for components that don't exist.""" diff --git a/pyproject.toml b/pyproject.toml index ddbf695..a9bbad6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "hatchling.build" [project] name = "powermcp" -version = "0.1.3" +version = "0.1.4" description = "MCP servers for power-system software (PowerWorld, OpenDSS, PSS/E, pandapower, PyPSA, and more)" readme = "README.md" license = { file = "LICENSE" }