From a54962f32cf3a3c98bd9cb606262f170a7eb4142 Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:34:19 -0400 Subject: [PATCH 1/4] feat: sync the powerio server with upstream 0.1.0; convert inline in memory powerio 0.1.0 ships convert_str, so the server's inline conversion no longer stages temp files; the _EXT/_stage/_unlink_quietly machinery is gone. The file is now a faithful copy of the canonical powerio.mcp.server (same eight tools, the docstring polish from its review, the save_case newline fix both copies already shared), with the standalone header pointing at upstream. The powerio extra pins >=0.1.0 and a regression test asserts inline conversion never touches tempfile. Co-Authored-By: Claude Fable 5 --- powerio/powerio_mcp.py | 222 +++++++++++++++-------------------- pyproject.toml | 2 +- tests/test_powerio_server.py | 14 +++ 3 files changed, 109 insertions(+), 129 deletions(-) diff --git a/powerio/powerio_mcp.py b/powerio/powerio_mcp.py index 469c608..e3325b3 100644 --- a/powerio/powerio_mcp.py +++ b/powerio/powerio_mcp.py @@ -1,21 +1,34 @@ -"""A standalone FastMCP server exposing powerio: case conversion, summaries, -the JSON transport, and sparse matrix views. - -powerio parses MATPOWER `.m`, PSS/E `.raw` (v33), PowerWorld `.aux`, -PowerModels JSON, and egret JSON into one format neutral network, writes back -byte exact, and converts between formats with fidelity warnings. - -The JSON transport returned by `parse_case` / `normalize_case` / `case_to_json` -is the exchange format between PowerMCP servers: parse a case once here, pass -the string between tool calls, and feed it to `compute_matrix`, `dense_view`, -or the powerio bridge tools in the pandapower and egret servers without -re-parsing the file. +"""A FastMCP server exposing powerio: case conversion, summaries, the JSON +transport, and sparse matrix views. + +Tools for LLM agents, accepting a filesystem ``path``, inline ``content``, or +(for ``save_case``, ``compute_matrix``, and ``dense_view``) the JSON transport +string: + +- ``convert_case``: convert a case between formats, returning the text and any + fidelity warnings. +- ``save_case``: convert and write the result to a file on disk, staging input + for path-only consumers. +- ``case_summary``: counts, base MVA, source format, and connectivity, with no + scipy/numpy in the loop. +- ``parse_case`` / ``normalize_case`` / ``case_to_json``: emit the JSON + transport (``Network.to_json``), the cheap handoff between tool calls. +- ``compute_matrix``: the sparse matrix views in COO form as plain lists. +- ``dense_view``: the dense table view as plain lists and dicts. + +Run over stdio with the ``powerio-mcp`` console script (or ``python -m +powerio.mcp``). The server is a thin wrapper over the powerio Python API; it +never reimplements parsing or math, and inline content converts in memory with +no temp file staging. + +This file is the standalone copy of the canonical ``powerio.mcp.server`` +(``python/powerio/mcp/server.py`` in eigenergy/powerio); land changes there +first and sync them here. Requires powerio >= 0.1.0 (convert_str). """ from __future__ import annotations import os -import tempfile from typing import Any, Dict, Optional import powerio @@ -23,67 +36,19 @@ mcp = FastMCP("PowerIO Conversion Server") -# Format name (and alias) → file extension, for staging inline content to a temp -# file. `convert_file` is path-only, so inline conversion writes the text to disk -# first; a matching extension keeps the format obvious even though we always -# pass `from_` explicitly for inline input. -_EXT = { - "matpower": ".m", - "m": ".m", - "powermodels-json": ".json", - "powermodels": ".json", - "pm": ".json", - "egret-json": ".json", - "egret": ".json", - "psse": ".raw", - "raw": ".raw", - "powerworld": ".aux", - "aux": ".aux", -} - _MATRIX_KINDS = ( "bprime", "bdoubleprime", "ybus_real", "ybus_imag", "adjacency", "ptdf", "lodf", "laplacian", "lacpf", ) -def _unlink_quietly(path: str) -> None: - """Remove `path`, ignoring a missing or locked file. Cleanup runs next to - an in-flight exception (a failed write, a conversion error), so it must - never raise and mask the error the caller actually cares about.""" - try: - os.unlink(path) - except OSError: - pass - - -def _stage(content: str, fmt: str) -> str: - """Write `content` to a temp file whose extension matches `fmt`. - - Returns the path; the caller is responsible for deleting it. Writes UTF-8 - regardless of the platform's default text encoding, because the case - readers decode as UTF-8. If the write fails, the temp file `mkstemp` - already created on disk is removed before re-raising; the caller only - learns the path on success, so it can't clean up after a failed stage. - """ - suffix = _EXT.get(fmt.strip().lower(), ".txt") - fd, path = tempfile.mkstemp(suffix=suffix) - try: - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(content) - except Exception: - _unlink_quietly(path) - raise - return path - - def _one_input(path: Optional[str], content: Optional[str]) -> None: if (path is None) == (content is None): raise ValueError("provide exactly one of `path` or `content`") def _parse(path: Optional[str], content: Optional[str], format: str) -> "powerio.Network": - """Parse from exactly one of `path` or inline `content`, mapping powerio + """Parse from exactly one of ``path`` or inline ``content``, mapping powerio and filesystem errors to ValueError so MCP clients see one error shape.""" _one_input(path, content) try: @@ -99,7 +64,7 @@ def _parse(path: Optional[str], content: Optional[str], format: str) -> "powerio def _load( path: Optional[str], content: Optional[str], json: Optional[str], format: str ) -> "powerio.Network": - """Like `_parse` but also accepts the JSON transport string.""" + """Like ``_parse`` but also accepts the JSON transport string.""" if sum(v is not None for v in (path, content, json)) != 1: raise ValueError("provide exactly one of `path`, `content`, or `json`") if json is None: @@ -132,19 +97,19 @@ def convert_case( path: Optional[str] = None, content: Optional[str] = None, from_: Optional[str] = None, -) -> Dict[str, Any]: +) -> dict: """Convert a power system case file to another format, losslessly where the target allows. - Provide exactly one of `path` (a file on disk) or `content` (inline file - text). `to`/`from_` are format names or aliases: `matpower` (`m`), - `powermodels-json` (`pm`), `egret-json` (`egret`), `psse` (`raw`), - `powerworld` (`aux`). The input format is inferred from the file extension - for `path`; `from_` is REQUIRED with inline `content`. + Provide exactly one of ``path`` (a file on disk) or ``content`` (inline file + text). ``to``/``from_`` are format names or aliases: ``matpower`` (``m``), + ``powermodels-json`` (``pm``), ``egret-json`` (``egret``), ``psse`` + (``raw``), ``powerworld`` (``aux``). The input format is inferred from the + file extension for ``path``; ``from_`` is REQUIRED with inline ``content``. - Returns `{"text": , "warnings": [, "warnings": []}` (empty for a faithful conversion). + supported target representation>]}`` (empty for a faithful conversion). """ _one_input(path, content) if content is not None and not from_: @@ -153,11 +118,7 @@ def convert_case( if path is not None: conv = powerio.convert_file(path, to, from_) else: - tmp = _stage(content, from_) - try: - conv = powerio.convert_file(tmp, to, from_) - finally: - _unlink_quietly(tmp) + conv = powerio.convert_str(content, to, from_) except powerio.PowerIOError as exc: raise ValueError(f"conversion failed: {exc}") from exc except FileNotFoundError as exc: @@ -174,23 +135,22 @@ def save_case( json: Optional[str] = None, format: str = "matpower", overwrite: bool = False, -) -> Dict[str, Any]: +) -> dict: """Convert a case and write the result to a file on disk. - Use this to stage input for the servers that only accept file paths - (PSS/E, PowerWorld, ANDES, surge, PyPSA): convert any case — or the JSON - transport from `parse_case` — to the target format and point the other - server's load tool at `out_path`. Pick an `out_path` extension matching - `to` (`.m`, `.json`, `.raw`, `.aux`). + Use this to stage input for consumers that only accept file paths: convert + any case (or the JSON transport from ``parse_case``) to the target format + and point the other program at ``out_path``. Pick an ``out_path`` extension + matching ``to`` (``.m``, ``.json``, ``.raw``, ``.aux``). - `to` is a format name or alias: `matpower` (`m`), `powermodels-json` - (`pm`), `egret-json` (`egret`), `psse` (`raw`), `powerworld` (`aux`). - Provide exactly one of `path`, `content` (with `format`), or `json` (the - transport string). An existing `out_path` is not overwritten unless - `overwrite` is true. + ``to`` is a format name or alias: ``matpower`` (``m``), ``powermodels-json`` + (``pm``), ``egret-json`` (``egret``), ``psse`` (``raw``), ``powerworld`` + (``aux``). Provide exactly one of ``path``, ``content`` (with ``format``), + or ``json`` (the transport string). An existing ``out_path`` is not + overwritten unless ``overwrite`` is true. - Returns `{"path": , "bytes_written": , - "warnings": []}`. + Returns ``{"path": , "bytes_written": , + "warnings": []}``. """ case = _load(path, content, json, format) try: @@ -222,12 +182,13 @@ def case_summary( path: Optional[str] = None, content: Optional[str] = None, format: str = "matpower", -) -> Dict[str, Any]: +) -> dict: """Summarize a power system case: name, base MVA, source format, element counts, and connectivity. - Provide exactly one of `path` or `content`. For inline `content`, `format` - names the input format (default `matpower`). Pulls in no scipy/numpy. + Provide exactly one of ``path`` or ``content``. For inline ``content``, + ``format`` names the input format (default ``matpower``). Pulls in no + scipy/numpy. """ return _summary(_parse(path, content, format)) @@ -237,19 +198,22 @@ def parse_case( path: Optional[str] = None, content: Optional[str] = None, format: str = "matpower", -) -> Dict[str, Any]: +) -> dict: """Parse a power system case once and return its JSON transport plus a summary. - Provide exactly one of `path` or `content`. For inline `content`, `format` - names the input format (default `matpower`); formats: `matpower`, - `powermodels-json`, `egret-json`, `psse`, `powerworld`. + Provide exactly one of ``path`` or ``content``. For inline ``content``, + ``format`` names the input format (default ``matpower``); formats: + ``matpower``, ``powermodels-json``, ``egret-json``, ``psse``, + ``powerworld``. - The returned `json` string is the cross server exchange format: pass it to - `compute_matrix` and `dense_view` here, or to `load_network_from_json` in - the pandapower server, instead of re-parsing the file on every call. + The returned ``json`` string is the exchange format between tool calls: + pass it to ``compute_matrix``, ``dense_view``, and ``save_case`` here, or + to any downstream tool that ingests the transport (e.g. PowerMCP's + pandapower, egret, and PyPSA bridges), instead of parsing the file again + on every call. - Returns `{"json": , "summary": }`. + Returns ``{"json": , "summary": }``. """ case = _parse(path, content, format) return {"json": case.to_json(), "summary": _summary(case)} @@ -260,17 +224,18 @@ def normalize_case( path: Optional[str] = None, content: Optional[str] = None, format: str = "matpower", -) -> Dict[str, Any]: +) -> dict: """Parse a case and return the JSON transport of its normalized form: per unit, radians, out of service elements filtered, buses densely reindexed (1-based), bus types canonicalized. - Use this instead of `parse_case` when downstream math wants a computation + Use this instead of ``parse_case`` when downstream math wants a computation ready case rather than the verbatim source tables. Provide exactly one of - `path` or `content` (with `format`). + ``path`` or ``content`` (with ``format``). - Returns `{"json": , "summary": }`; the `json` is accepted everywhere the `parse_case` transport is. + Returns ``{"json": , "summary": }``; the ``json`` is accepted everywhere the ``parse_case`` transport + is. """ case = _parse(path, content, format) try: @@ -285,16 +250,16 @@ def case_to_json( path: Optional[str] = None, content: Optional[str] = None, format: str = "matpower", -) -> Dict[str, Any]: +) -> dict: """Convert a case file (or inline text) to the powerio JSON transport string. - Provide exactly one of `path` or `content` (with `format`). The returned - `json` is accepted by `compute_matrix`, `dense_view`, and the powerio - bridge tools in the pandapower and egret servers. Use `parse_case` instead - if you also want the summary. + Provide exactly one of ``path`` or ``content`` (with ``format``). The + returned ``json`` is accepted by ``compute_matrix``, ``dense_view``, + ``save_case``, and any downstream tool that ingests the transport. Use + ``parse_case`` instead if you also want the summary. - Returns `{"json": }`. + Returns ``{"json": }``. """ return {"json": _parse(path, content, format).to_json()} @@ -308,24 +273,24 @@ def compute_matrix( format: str = "matpower", scheme: str = "bx", convention: str = "paper", -) -> Dict[str, Any]: +) -> dict: """Build a sparse matrix view of a case and return it in COO form. - `kind` is one of: `bprime` (FDPF B', shuntless), `bdoubleprime` (FDPF B'' - with shunts and taps), `ybus_real` / `ybus_imag` (Re/Im of Y_bus), - `adjacency` (0/1 bus adjacency), `ptdf` (DC PTDF, m×n), `lodf` (DC LODF, - m×m), `laplacian` (weighted Laplacian L = A diag(b) Aᵀ), `lacpf` + ``kind`` is one of: ``bprime`` (FDPF B', shuntless), ``bdoubleprime`` (FDPF + B'' with shunts and taps), ``ybus_real`` / ``ybus_imag`` (Re/Im of Y_bus), + ``adjacency`` (0/1 bus adjacency), ``ptdf`` (DC PTDF, m×n), ``lodf`` (DC + LODF, m×m), ``laplacian`` (weighted Laplacian L = A diag(b) Aᵀ), ``lacpf`` (linearized AC 2n×2n block [[G, -B], [-B, -G]], taps and shifts included). - `scheme` ("bx"|"xb") applies to bprime/bdoubleprime; `convention` + ``scheme`` ("bx"|"xb") applies to bprime/bdoubleprime; ``convention`` ("paper"|"matpower") to ptdf/lodf/laplacian. - Provide exactly one of `path`, `content` (with `format`), or `json` — the - transport string from `parse_case` / `normalize_case` / `case_to_json`, - which skips re-parsing. + Provide exactly one of ``path``, ``content`` (with ``format``), or + ``json``, the transport string from ``parse_case`` / ``normalize_case`` / + ``case_to_json``; passing it skips parsing again. - Returns `{"format": "coo", "shape": [rows, cols], "nnz": , - "data": [...], "row": [...], "col": [...]}` with plain Python lists. - Requires scipy (`pip install 'powerio[matrix]'`). + Returns ``{"format": "coo", "shape": [rows, cols], "nnz": , + "data": [...], "row": [...], "col": [...]}`` with plain Python lists. + Requires scipy (``pip install 'powerio[matrix]'``). """ if kind not in _MATRIX_KINDS: raise ValueError( @@ -371,15 +336,16 @@ def dense_view( content: Optional[str] = None, json: Optional[str] = None, format: str = "matpower", -) -> Dict[str, Any]: +) -> dict: """Dense table view of a case as plain lists and dicts: counts, base MVA, bus ids, branch arrays (from_id, to_id, r, x, b, tap, shift, in_service), generator arrays (bus, pg, pmax, pmin, in_service), nodal demand and shunt - arrays, the reference bus, connected component count, and radial flag. + arrays, the reference bus index, connected component count, and radial + flag. - Provide exactly one of `path`, `content` (with `format`), or `json` (the - transport string from `parse_case`). Requires numpy - (`pip install 'powerio[matrix]'`). + Provide exactly one of ``path``, ``content`` (with ``format``), or ``json`` + (the transport string from ``parse_case`` / ``normalize_case`` / + ``case_to_json``). Requires numpy (``pip install 'powerio[matrix]'``). """ case = _load(path, content, json, format) try: diff --git a/pyproject.toml b/pyproject.toml index db10808..dc8aaa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ opendss = ["py_dss_toolkit"] ltspice = ["PyLTSpice", "matplotlib"] surge = ["surge-py>=0.1.5; python_version >= '3.12' and python_version < '3.15'"] hope = ["PyYAML>=6.0"] -powerio = ["powerio[mcp,matrix]"] +powerio = ["powerio[mcp,matrix]>=0.1.0"] # --- closed-source / vendor tool engines --- powerworld = ["esa", "numba"] # esa auto-discovers a running Simulator via COM. # numba is required: esa 1.3.5's no-numba code path diff --git a/tests/test_powerio_server.py b/tests/test_powerio_server.py index 1df78ec..43abe9a 100644 --- a/tests/test_powerio_server.py +++ b/tests/test_powerio_server.py @@ -261,3 +261,17 @@ def test_launch_powerio_runs_once(record_mcp_run): assert len(record_mcp_run) == 1 _, kwargs = record_mcp_run[0] assert kwargs.get("transport") == "stdio" + + +def test_inline_convert_stages_no_temp_files(monkeypatch): + # Inline conversion goes through powerio.convert_str entirely in memory; + # touching tempfile would be a regression to the old staging path. + import tempfile + + def boom(*args, **kwargs): + raise AssertionError("inline conversion must not create temp files") + + monkeypatch.setattr(tempfile, "mkstemp", boom) + monkeypatch.setattr(tempfile, "NamedTemporaryFile", boom) + r = powerio_mcp.convert_case(to="psse", content=CASE9.read_text(), from_="matpower") + assert r["text"] From 73ee997a0c514190ee27c0840dacc27dc263c63d Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:32:41 -0400 Subject: [PATCH 2/4] fix: carry Qian's OOS fix and error hardening from PR #34 PyPSA bridge: drop out-of-service branches and generators before import_from_pypower_ppc, which ignores the ppc status columns; gencost rows are realigned after generator drops. pandapower's from_ppc honors status so its bridge is unchanged (documented by a new test). powerio server: shadow-import guard on startup; explicit laplacian elif with unreachable-else guard; OSError arm in convert_case; belt-and- braces (ValueError/KeyError/TypeError) arm in _load. tests: 7 new cases covering OOS branch/generator drops, pandapower status contrast, laplacian matrix, bad-JSON ValueError, and OSError normalization. Co-Authored-By: Claude Fable 5 --- PyPSA/pypsa_mcp.py | 32 +++++++++++++-- powerio/powerio_mcp.py | 17 +++++++- tests/test_powerio_server.py | 77 ++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/PyPSA/pypsa_mcp.py b/PyPSA/pypsa_mcp.py index 41136c4..bc8692d 100644 --- a/PyPSA/pypsa_mcp.py +++ b/PyPSA/pypsa_mcp.py @@ -667,14 +667,40 @@ def _import_case_to_netcdf(case, output_path: str, overwrite_zero_s_nom: Optiona ) if (ppc["bus"][:, 9] == 0).any(): warnings.append("buses with base_kv 0 are assigned v_nom 1 by PyPSA") - in_service = ppc["branch"][:, 10] == 1.0 - if overwrite_zero_s_nom is None and (ppc["branch"][in_service, 5] == 0).any(): + + # PyPSA's import_from_pypower_ppc ignores the ppc status columns (branch + # col 10, gen col 7), so out-of-service elements would import as fully + # active and silently change topology. Drop them before import and report + # the count. (pandapower's from_ppc honors status, so its bridge does not + # need this — keep that asymmetry in mind when syncing the shared helper.) + br_oos = ppc["branch"][:, 10] == 0.0 + if br_oos.any(): + ppc["branch"] = ppc["branch"][~br_oos] + warnings.append( + f"{int(br_oos.sum())} out-of-service branch(es) dropped " + "(PyPSA's ppc import does not model branch status)" + ) + gen_oos = ppc["gen"][:, 7] == 0.0 + if gen_oos.any(): + ppc["gen"] = ppc["gen"][~gen_oos] + if "gencost" in ppc: + ppc["gencost"] = ppc["gencost"][~gen_oos] + warnings.append( + f"{int(gen_oos.sum())} out-of-service generator(s) dropped " + "(PyPSA's ppc import does not model generator status)" + ) + + # Only in-service branches remain now, so the rating-0 check is exact. + if overwrite_zero_s_nom is None and (ppc["branch"][:, 5] == 0).any(): warnings.append( "branches with rating 0 imported with s_nom 0; pass overwrite_zero_s_nom to set a value" ) network = Network() network.import_from_pypower_ppc(ppc, overwrite_zero_s_nom=overwrite_zero_s_nom) - network.export_to_netcdf(output_path) + try: + network.export_to_netcdf(output_path) + except OSError as exc: + raise OSError(f"failed to write network to {output_path}: {exc}") from exc info = { "buses": len(network.buses), "generators": len(network.generators), diff --git a/powerio/powerio_mcp.py b/powerio/powerio_mcp.py index e3325b3..011cdcc 100644 --- a/powerio/powerio_mcp.py +++ b/powerio/powerio_mcp.py @@ -34,6 +34,15 @@ import powerio from mcp.server.fastmcp import FastMCP +# Fail fast if `import powerio` resolved to something without the real API — e.g. +# this server's own powerio/ directory shadowing the package as a PEP 420 +# namespace (editable installs / PYTHONPATH / pytest rootdir). +if not hasattr(powerio, "parse_file"): # pragma: no cover + raise ImportError( + "the 'powerio' package is not importable (the repo's powerio/ directory " + "may be shadowing it); install it: pip install 'powerio[mcp,matrix]'" + ) + mcp = FastMCP("PowerIO Conversion Server") _MATRIX_KINDS = ( @@ -73,6 +82,8 @@ def _load( return powerio.from_json(json) except powerio.PowerIOError as exc: raise ValueError(f"parse failed: {exc}") from exc + except (ValueError, KeyError, TypeError) as exc: + raise ValueError(f"parse failed: {exc}") from exc def _summary(case: "powerio.Network") -> Dict[str, Any]: @@ -123,6 +134,8 @@ def convert_case( raise ValueError(f"conversion failed: {exc}") from exc except FileNotFoundError as exc: raise ValueError(f"file not found: {exc}") from exc + except OSError as exc: + raise ValueError(f"conversion failed: {exc}") from exc return {"text": conv.text, "warnings": list(conv.warnings)} @@ -313,8 +326,10 @@ def compute_matrix( m = case.lodf(convention) elif kind == "lacpf": m = case.lacpf() - else: + elif kind == "laplacian": m = case.weighted_laplacian(convention) + else: # pragma: no cover - unreachable; guarded by the _MATRIX_KINDS check + raise ValueError(f"unhandled matrix kind {kind!r}") except ImportError as exc: raise ValueError(str(exc)) from exc except powerio.PowerIOError as exc: diff --git a/tests/test_powerio_server.py b/tests/test_powerio_server.py index 43abe9a..25b51be 100644 --- a/tests/test_powerio_server.py +++ b/tests/test_powerio_server.py @@ -275,3 +275,80 @@ def boom(*args, **kwargs): monkeypatch.setattr(tempfile, "NamedTemporaryFile", boom) r = powerio_mcp.convert_case(to="psse", content=CASE9.read_text(), from_="matpower") assert r["text"] + + +# 3-bus case with an out-of-service branch (2-3, status 0) and an out-of-service +# generator (at bus 3, status 0), for the PyPSA/pandapower status tests. +OOS_CASE = """function mpc = oos +mpc.version = '2'; +mpc.baseMVA = 100.0; +mpc.bus = [ +\t1 3 0 0 0 0 1 1.0 0.0 230.0 1 1.1 0.9; +\t2 1 50 10 0 0 1 1.0 0.0 230.0 1 1.1 0.9; +\t3 1 30 5 0 0 1 1.0 0.0 230.0 1 1.1 0.9; +]; +mpc.gen = [ +\t1 80 0 50 -50 1.0 100 1 200 0 0 0 0 0 0 0 0 0 0 0 0; +\t3 20 0 50 -50 1.0 100 0 100 0 0 0 0 0 0 0 0 0 0 0 0; +]; +mpc.branch = [ +\t1 2 0.01 0.05 0.0 250 0 0 0 0 1 -360 360; +\t2 3 0.01 0.05 0.0 250 0 0 0 0 0 -360 360; +]; +""" + + +def test_pypsa_import_drops_out_of_service_branch(tmp_path): + src = tmp_path / "oos.m" + src.write_text(OOS_CASE) + out = tmp_path / "oos.nc" + r = pypsa_mcp.import_case_from_any(str(src), str(out)) + assert r["status"] == "success", r + assert pypsa.Network(str(out)).lines.shape[0] == 1 # only the in-service 1-2 + assert any("out-of-service branch" in w for w in r["warnings"]), r["warnings"] + + +def test_pypsa_import_warns_out_of_service_generator(tmp_path): + src = tmp_path / "oos.m" + src.write_text(OOS_CASE) + r = pypsa_mcp.import_case_from_any(str(src), str(tmp_path / "g.nc")) + assert r["status"] == "success", r + assert any("out-of-service generator" in w for w in r["warnings"]), r["warnings"] + + +def test_pandapower_bridge_honors_branch_status(tmp_path): + # pandapower's from_ppc models branch status, so the OOS branch should be + # present but marked out-of-service (not dropped like PyPSA). + panda_dir = str(TOOLS["pandapower"].resolve_server_dir()) + if panda_dir not in sys.path: + sys.path.insert(0, panda_dir) + import panda_mcp # noqa: E402 + + src = tmp_path / "oos.m" + src.write_text(OOS_CASE) + res = panda_mcp.load_network_from_any(str(src)) + assert res["status"] == "success", res + in_service = panda_mcp._current_net.line["in_service"].tolist() + assert len(in_service) == 2 and in_service.count(False) == 1, in_service + + +def test_compute_matrix_laplacian(): + m = powerio_mcp.compute_matrix("laplacian", path=str(CASE9)) + assert m["format"] == "coo" + assert m["shape"] == [9, 9] + + +def test_compute_matrix_bad_json_raises_valueerror(): + with pytest.raises(ValueError): + powerio_mcp.compute_matrix("bprime", json="{not valid json") + + +def test_convert_case_oserror_normalizes_to_valueerror(monkeypatch): + # An OSError from convert_str (e.g. disk full) must surface as ValueError, + # not leak as a raw OSError. + def boom(content, to, from_): + raise OSError("disk full") + + monkeypatch.setattr(powerio, "convert_str", boom) + with pytest.raises(ValueError): + powerio_mcp.convert_case(to="psse", content="x", from_="matpower") From cb459b067aed0f1996bd20752b5f897675b50482 Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Thu, 11 Jun 2026 05:06:03 -0400 Subject: [PATCH 3/4] sync: refresh the powerio server copy from upstream 0.1.1 Upstream landed the error-shape hardening from the #34 review (eigenergy/powerio#93, released as 0.1.1): _parse maps an unreadable file (OSError) to the documented ValueError shape, convert_case says "cannot read file" instead of "conversion failed" for that case, and the defensive-arm comments came along. Ports the upstream suite's unreadable-file and wrong-schema tests. No new powerio API, so the >=0.1.0 floor stands. Co-Authored-By: Claude Fable 5 --- powerio/powerio_mcp.py | 11 +++++++++-- tests/test_powerio_server.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/powerio/powerio_mcp.py b/powerio/powerio_mcp.py index 011cdcc..3d8650f 100644 --- a/powerio/powerio_mcp.py +++ b/powerio/powerio_mcp.py @@ -68,6 +68,9 @@ def _parse(path: Optional[str], content: Optional[str], format: str) -> "powerio raise ValueError(f"parse failed: {exc}") from exc except FileNotFoundError as exc: raise ValueError(f"file not found: {exc}") from exc + except OSError as exc: + # e.g. an unreadable file (permissions); keep the one error shape. + raise ValueError(f"cannot read file: {exc}") from exc def _load( @@ -83,6 +86,9 @@ def _load( except powerio.PowerIOError as exc: raise ValueError(f"parse failed: {exc}") from exc except (ValueError, KeyError, TypeError) as exc: + # The Rust layer already maps malformed and wrong-schema JSON to + # PowerIOParseError; this guards future Python-side paths so the tool + # keeps its one error shape. raise ValueError(f"parse failed: {exc}") from exc @@ -135,7 +141,8 @@ def convert_case( except FileNotFoundError as exc: raise ValueError(f"file not found: {exc}") from exc except OSError as exc: - raise ValueError(f"conversion failed: {exc}") from exc + # e.g. an unreadable file (permissions); keep the one error shape. + raise ValueError(f"cannot read file: {exc}") from exc return {"text": conv.text, "warnings": list(conv.warnings)} @@ -328,7 +335,7 @@ def compute_matrix( m = case.lacpf() elif kind == "laplacian": m = case.weighted_laplacian(convention) - else: # pragma: no cover - unreachable; guarded by the _MATRIX_KINDS check + else: # pragma: no cover - unreachable; _MATRIX_KINDS is checked above raise ValueError(f"unhandled matrix kind {kind!r}") except ImportError as exc: raise ValueError(str(exc)) from exc diff --git a/tests/test_powerio_server.py b/tests/test_powerio_server.py index 25b51be..3067e41 100644 --- a/tests/test_powerio_server.py +++ b/tests/test_powerio_server.py @@ -352,3 +352,31 @@ def boom(content, to, from_): monkeypatch.setattr(powerio, "convert_str", boom) with pytest.raises(ValueError): powerio_mcp.convert_case(to="psse", content="x", from_="matpower") + + +def test_unreadable_file_maps_cleanly(tmp_path): + # PermissionError must surface as the documented ValueError shape, like + # FileNotFoundError, not leak raw through the tool. (Ported from the + # canonical server's suite at powerio 0.1.1.) + import os + + if sys.platform == "win32" or os.geteuid() == 0: + pytest.skip("permission bits are not enforceable here") + locked = tmp_path / "locked.m" + locked.write_text("function mpc = x\n") + locked.chmod(0o000) + try: + with pytest.raises(ValueError, match="cannot read file"): + powerio_mcp.convert_case(to="psse", path=str(locked)) + with pytest.raises(ValueError, match="cannot read file"): + powerio_mcp.case_summary(path=str(locked)) + finally: + locked.chmod(0o644) + + +def test_wrong_schema_json_maps_cleanly(): + # Wrong-schema (but well-formed) JSON keeps the one error shape too; the + # malformed-JSON case is covered above. + for bad in ("{}", "[]", "null", '{"buses": "nope"}'): + with pytest.raises(ValueError, match="parse failed"): + powerio_mcp.compute_matrix("bprime", json=bad) From ed0310da4c89ebfb3cef74bb46d16899c8b25e63 Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:01:11 -0400 Subject: [PATCH 4/4] feat: add ANDES powerio bridge and bump version pin to 0.1.1 Adds load_network_from_json and load_network_from_any to ANDES/andes_mcp.py, following the same pattern as the PyPSA bridge. Both tools accept a powerio JSON transport string or any powerio-readable file (MATPOWER, PSS/E, PowerWorld .aux, PowerModels JSON, egret JSON), convert to MATPOWER via powerio's case.to_format("matpower"), and write the result to out_path for ANDES to consume via run_power_flow. Bumps the powerio extra pin to >=0.1.1 to match the 0.1.1 server already on this branch. Co-Authored-By: Claude Fable 5 --- ANDES/andes_mcp.py | 107 ++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- tests/test_powerio_server.py | 42 ++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/ANDES/andes_mcp.py b/ANDES/andes_mcp.py index fb0b745..4f4f922 100644 --- a/ANDES/andes_mcp.py +++ b/ANDES/andes_mcp.py @@ -8,7 +8,7 @@ from pathlib import Path from contextlib import redirect_stdout, redirect_stderr from mcp.server.fastmcp import FastMCP -from typing import Dict, Any +from typing import Dict, Any, Optional # Storage directory resolved lazily (no filesystem writes at import time) def _andes_runs_dir(): @@ -333,6 +333,111 @@ def get_system_info() -> Dict[str, Any]: } +# --------------------------------------------------------------------------- +# powerio bridge: load any powerio readable case into ANDES. +# powerio parses MATPOWER .m, PSS/E .raw (v33), PowerWorld .aux, PowerModels +# JSON, and egret JSON; the case is staged as a MATPOWER file that ANDES loads +# natively via run_power_flow. powerio is an optional extra, so the tools +# degrade gracefully when it is missing. +# --------------------------------------------------------------------------- + +_POWERIO_HINT = "powerio not installed: pip install 'powerio[mcp,matrix]'" + + +@mcp.tool() +def load_network_from_json( + network_json: str, + out_path: str, +) -> Dict[str, Any]: + """Stage a powerio JSON transport string as a MATPOWER file for ANDES. + + Accepts the ``json`` string returned by the powerio server's parse_case or + case_to_json tools. Converts the network to MATPOWER format, writes it to + out_path (use a .m extension), and returns the path along with component + counts. Pass out_path to run_power_flow to run the simulation. Requires the + powerio extra (pip install 'powermcp[powerio]'). + + Args: + network_json: The JSON transport string from powerio + out_path: Destination for the MATPOWER case file (.m) + + Returns: + Dict with status, case_file path, component counts, and fidelity warnings + """ + try: + import powerio + except ImportError: + return {"status": "error", "message": _POWERIO_HINT} + try: + case = powerio.from_json(network_json) + conv = case.to_format("matpower") + abs_out = os.path.abspath(out_path) + with open(abs_out, "w") as fh: + fh.write(conv.text) + except Exception as e: + return {"status": "error", "message": str(e)} + return { + "status": "success", + "message": f"Case staged at {abs_out}; pass this path to run_power_flow", + "case_file": abs_out, + "info": { + "buses": case.n_buses, + "branches": case.n_branches, + "generators": case.n_gens, + }, + "warnings": list(conv.warnings), + } + + +@mcp.tool() +def load_network_from_any( + file_path: str, + out_path: str, + source_format: Optional[str] = None, +) -> Dict[str, Any]: + """Stage any powerio readable case as a MATPOWER file for ANDES. + + Reads MATPOWER .m, PSS/E .raw (v33), PowerWorld .aux, PowerModels JSON, or + egret JSON via powerio and writes a MATPOWER file to out_path (use a .m + extension). Pass out_path to run_power_flow to run the simulation. Requires + the powerio extra (pip install 'powermcp[powerio]'). + + Args: + file_path: Path to the source case file + out_path: Destination for the MATPOWER case file (.m) + source_format: Input format name (matpower, powermodels-json, egret-json, + psse, powerworld); inferred from the file extension when omitted + + Returns: + Dict with status, case_file path, component counts, and fidelity warnings + """ + try: + import powerio + except ImportError: + return {"status": "error", "message": _POWERIO_HINT} + try: + case = powerio.parse_file(file_path, source_format) + conv = case.to_format("matpower") + abs_out = os.path.abspath(out_path) + with open(abs_out, "w") as fh: + fh.write(conv.text) + except FileNotFoundError: + return {"status": "error", "message": f"File not found: {file_path}"} + except Exception as e: + return {"status": "error", "message": str(e)} + return { + "status": "success", + "message": f"Case staged at {abs_out}; pass this path to run_power_flow", + "case_file": abs_out, + "info": { + "buses": case.n_buses, + "branches": case.n_branches, + "generators": case.n_gens, + }, + "warnings": list(conv.warnings), + } + + if __name__ == "__main__": print(f"Starting ANDES MCP Server") print(f"Using storage directory: {_andes_runs_dir()}") diff --git a/pyproject.toml b/pyproject.toml index dc8aaa2..195018e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ opendss = ["py_dss_toolkit"] ltspice = ["PyLTSpice", "matplotlib"] surge = ["surge-py>=0.1.5; python_version >= '3.12' and python_version < '3.15'"] hope = ["PyYAML>=6.0"] -powerio = ["powerio[mcp,matrix]>=0.1.0"] +powerio = ["powerio[mcp,matrix]>=0.1.1"] # --- closed-source / vendor tool engines --- powerworld = ["esa", "numba"] # esa auto-discovers a running Simulator via COM. # numba is required: esa 1.3.5's no-numba code path diff --git a/tests/test_powerio_server.py b/tests/test_powerio_server.py index 3067e41..e17c2f5 100644 --- a/tests/test_powerio_server.py +++ b/tests/test_powerio_server.py @@ -380,3 +380,45 @@ def test_wrong_schema_json_maps_cleanly(): for bad in ("{}", "[]", "null", '{"buses": "nope"}'): with pytest.raises(ValueError, match="parse failed"): powerio_mcp.compute_matrix("bprime", json=bad) + + +# --------------------------------------------------------------------------- +# ANDES bridge tests +# --------------------------------------------------------------------------- + +def _load_andes_mcp(): + """Import andes_mcp from the registry-resolved server dir, skipping if + andes or powerio are not installed.""" + pytest.importorskip("andes") + andes_dir = str(TOOLS["andes"].resolve_server_dir()) + if andes_dir not in sys.path: + sys.path.insert(0, andes_dir) + import andes_mcp # noqa: E402 + return andes_mcp + + +def test_andes_load_network_from_any(tmp_path): + andes_mcp = _load_andes_mcp() + out = tmp_path / "case9.m" + r = andes_mcp.load_network_from_any(str(CASE9), str(out)) + assert r["status"] == "success", r + assert out.exists() + assert r["case_file"] == str(out) + assert r["info"]["buses"] == 9 + + +def test_andes_load_network_from_json(tmp_path): + andes_mcp = _load_andes_mcp() + transport = powerio_mcp.parse_case(path=str(CASE9))["json"] + out = tmp_path / "case9_from_json.m" + r = andes_mcp.load_network_from_json(transport, str(out)) + assert r["status"] == "success", r + assert out.exists() + assert r["info"]["buses"] == 9 + + +def test_andes_load_missing_file(tmp_path): + andes_mcp = _load_andes_mcp() + r = andes_mcp.load_network_from_any("/nope/missing.m", str(tmp_path / "x.m")) + assert r["status"] == "error" + assert "not found" in r["message"].lower()