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" }