From 6acf2ced17ee9af41f0ae05014824c892afc7102 Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:46:46 -0400 Subject: [PATCH 1/5] powerio: track the 0.3.3 unified tool surface; add OpenDSS dist on-ramp - re-export the bare-verb tools (convert/save/summary/parse/...); drop the read_display_file overlay (upstreamed in powerio 0.3.3) - pin powerio >= 0.3.3 - OpenDSS: compile_distribution converts a BMOPF/PMD case to .dss via powerio and compiles it - point the pandapower/PyPSA/ANDES/Egret bridge docs at the new tool names --- ANDES/andes_mcp.py | 4 +- Egret/egret_mcp.py | 4 +- OpenDSS/opendss_tools/configuration.py | 73 +++++++++++++++++++- PyPSA/pypsa_mcp.py | 6 +- pandapower/panda_mcp.py | 8 +-- powerio/powerio_mcp.py | 94 ++++++++------------------ pyproject.toml | 11 +-- tests/test_powerio_server.py | 93 +++++++++++++------------ 8 files changed, 166 insertions(+), 127 deletions(-) diff --git a/ANDES/andes_mcp.py b/ANDES/andes_mcp.py index 5c81321..d10bbb6 100644 --- a/ANDES/andes_mcp.py +++ b/ANDES/andes_mcp.py @@ -351,8 +351,8 @@ def load_network_from_json( ) -> 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 + Accepts the ``json`` string returned by the powerio server's parse or + 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. powerio is a core dependency, so this is always available. diff --git a/Egret/egret_mcp.py b/Egret/egret_mcp.py index bfcdf28..3c5fc44 100644 --- a/Egret/egret_mcp.py +++ b/Egret/egret_mcp.py @@ -250,8 +250,8 @@ def load_model_from_any(file_path: str, source_format: Optional[str] = None) -> def load_model_from_json(network_json: str) -> Dict[str, Any]: """Convert a powerio JSON transport string into an egret model. - Accepts the `json` string returned by the powerio server's parse_case or - case_to_json tools, so a case parsed once there feeds egret without + Accepts the `json` string returned by the powerio server's parse or + to_json tools, so a case parsed once there feeds egret without re-reading the file. Converts it to egret JSON, validates it as an egret ModelData, and stages it to a temp file. Pass the returned `case_file` path to the solver tools. powerio is a core dependency, so this is always diff --git a/OpenDSS/opendss_tools/configuration.py b/OpenDSS/opendss_tools/configuration.py index 6a77261..56d8e27 100644 --- a/OpenDSS/opendss_tools/configuration.py +++ b/OpenDSS/opendss_tools/configuration.py @@ -1,7 +1,9 @@ """Configuration-domain MCP tools (compile, clear).""" +import os +import tempfile from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, Optional from mcp.server.fastmcp import FastMCP from py_dss_toolkit import dss_tools @@ -62,6 +64,74 @@ def compile_opendss_file(dss_file: str, force_recompile: bool = False) -> Dict[s return _err(str(e)) +def compile_distribution( + path: Optional[str] = None, + content: Optional[str] = None, + source_format: Optional[str] = None, + force_recompile: bool = False, +) -> Dict[str, Any]: + """Compile a distribution case given in any powerio distribution format. + + The on-ramp into OpenDSS for the BMOPF and PowerModelsDistribution worlds: a + feeder held as IEEE BMOPF JSON (``bmopf-json``) or PowerModelsDistribution + ENGINEERING JSON (``pmd-json``) — or already as OpenDSS ``.dss`` — is + converted to a self-contained ``.dss`` file by powerio and compiled here, + so a case authored or solved in those toolchains can run in OpenDSS without + a hand translation. + + Provide exactly one of ``path`` (a file on disk) or ``content`` (inline file + text). ``source_format`` is the distribution format name (``dss``, + ``pmd-json``, ``bmopf-json``); for a ``path`` it is inferred from the + extension when omitted, but it is REQUIRED for inline ``content``. + + Returns the ``compile_opendss_file`` result plus ``staged_dss_file`` (the + converted ``.dss`` path on disk) and ``conversion_warnings`` (powerio's + fidelity notes for the BMOPF/PMD → OpenDSS conversion — things OpenDSS could + not represent, e.g. solver-only metadata). + """ + try: + import powerio.dist as _dist + except ImportError as exc: # pragma: no cover - powerio is a core dependency + return _err(f"powerio is required to convert distribution cases: {exc}") + + if (path is None) == (content is None): + return _err("provide exactly one of `path` or `content`") + if content is not None and not source_format: + return _err("`source_format` is required when compiling inline `content`") + + try: + if path is not None: + conv = _dist.convert_file(path, "dss", source_format) + else: + conv = _dist.convert_str(content, "dss", source_format) + except FileNotFoundError as exc: + return _err(f"file not found: {exc}") + except OSError as exc: + return _err(f"cannot read file: {exc}") + except Exception as exc: # powerio.PowerIOError and friends → one error shape + return _err(f"distribution conversion failed: {exc}") + + # newline="" keeps the converter output byte-identical across platforms. + fd, staged = tempfile.mkstemp(suffix=".dss", prefix="powerio_dist_") + try: + with open(fd, "w", encoding="utf-8", newline="") as fh: + fh.write(conv.text) + except OSError as exc: + try: # don't leave a 0-byte temp behind on a failed stage + os.unlink(staged) + except OSError: + pass + return _err(f"failed to stage .dss file: {exc}") + + result = compile_opendss_file(staged, force_recompile=force_recompile) + if isinstance(result, dict): + # Sit beside the rest of the compile result inside the _ok payload. + target = result["payload"] if isinstance(result.get("payload"), dict) else result + target["staged_dss_file"] = staged + target["conversion_warnings"] = list(conv.warnings) + return result + + def clear_all_opendss_memory() -> Dict[str, Any]: """Clear OpenDSS engine memory (ClearAll); resets circuit_loaded, solution_available, and last compiled path.""" try: @@ -78,4 +148,5 @@ def clear_all_opendss_memory() -> Dict[str, Any]: def register_configuration_tools(mcp: FastMCP) -> None: mcp.tool()(compile_opendss_file) + mcp.tool()(compile_distribution) mcp.tool()(clear_all_opendss_memory) diff --git a/PyPSA/pypsa_mcp.py b/PyPSA/pypsa_mcp.py index 1455034..469880f 100644 --- a/PyPSA/pypsa_mcp.py +++ b/PyPSA/pypsa_mcp.py @@ -771,10 +771,10 @@ def import_case_from_json( """Import a powerio JSON transport string as a PyPSA network saved to a NetCDF file. - Accepts the `json` string returned by the powerio server's parse_case or - case_to_json tools, so a case parsed once there loads here without passing + Accepts the `json` string returned by the powerio server's parse or + to_json tools, so a case parsed once there loads here without passing a file around or re-parsing it. Expects source-valued tables (MW, degrees) - as parse_case emits them, not the per-unit normalize_case form. Writes the + as parse emits them, not the per-unit normalize form. Writes the network to output_path (use a .nc extension); pass that path as network_name to the other tools. powerio is a core dependency, so this is always available. diff --git a/pandapower/panda_mcp.py b/pandapower/panda_mcp.py index 573ad3d..ccae10e 100644 --- a/pandapower/panda_mcp.py +++ b/pandapower/panda_mcp.py @@ -257,7 +257,7 @@ def get_network_info() -> Dict[str, Any]: # --------------------------------------------------------------------------- # powerio bridge: exchange cases with the powerio conversion server. -# Its parse_case / case_to_json tools emit a JSON transport string that +# Its parse / to_json tools emit a JSON transport string that # load_network_from_json ingests directly, so a case parsed once there loads # here without re-reading the file; export_network_to_format sends the current # network back out through powerio. powerio is an optional extra, so each tool @@ -418,10 +418,10 @@ def load_network_from_any(file_path: str, source_format: Optional[str] = None) - def load_network_from_json(network_json: str) -> Dict[str, Any]: """Load a network from a powerio JSON transport string. - Accepts the `json` string returned by the powerio server's parse_case or - case_to_json tools, so a case parsed once there loads here without passing + Accepts the `json` string returned by the powerio server's parse or + to_json tools, so a case parsed once there loads here without passing a file around or re-parsing it. Expects source-valued tables (MW, degrees) - as parse_case emits them, not the per-unit normalize_case form. Replaces + as parse emits them, not the per-unit normalize form. Replaces the currently loaded network. powerio is a core dependency, so this is always available. diff --git a/powerio/powerio_mcp.py b/powerio/powerio_mcp.py index 888666c..6785dfc 100644 --- a/powerio/powerio_mcp.py +++ b/powerio/powerio_mcp.py @@ -1,89 +1,53 @@ -"""PowerMCP's powerio conversion server — a thin wrapper over the canonical +"""PowerMCP's powerio conversion server — a thin re-export of the canonical ``powerio.mcp.server`` that ships with the powerio package. powerio is a core dependency (see ``powermcp.registry`` / ``pyproject.toml``), so -the standalone server keeps no copy of the conversion/summary/matrix tools: it -re-exports the canonical FastMCP ``mcp`` instance and every tool registered on -it. As of powerio 0.2.2 that is twelve tools — the eight text-format tools -(``convert_case``, ``save_case``, ``case_summary``, ``parse_case``, -``normalize_case``, ``case_to_json``, ``compute_matrix``, ``dense_view``) plus -the four folder/Parquet tools (``read_pypsa_csv_folder`` / -``write_pypsa_csv_folder`` and ``read_gridfm`` / ``write_gridfm``) that moved -upstream in powerio #119. They stay in lockstep with powerio, nothing to -hand-sync. - -The one overlay below, ``read_display_file``, wraps powerio's ``.pwd`` display -API (``parse_display_file``, added in powerio #120) that the canonical server -does not expose as an MCP tool yet. It should migrate into ``powerio.mcp.server`` -upstream; once a powerio release includes it, delete it here and this module -becomes a pure re-export. +this server keeps no copy of the conversion/summary/matrix tools: it re-exports +the canonical FastMCP ``mcp`` instance and every tool registered on it. As of +powerio 0.3.3 the surface is the bare powerio verbs, one set for transmission +and distribution (routed by format): + +- ``convert`` / ``save`` / ``summary`` — any single-file format, either domain. + ``save(to="dss")`` stages an IEEE BMOPF or PowerModelsDistribution case as a + ``.dss`` file the OpenDSS server can compile (see its + ``compile_distribution`` tool); ``save(to="pypsa-csv")`` writes the CSV folder. +- ``parse`` / ``to_json`` / ``normalize`` / ``compute_matrix`` / ``dense_view`` — + transmission only (a distribution network has no JSON transport and isn't + positive-sequence). +- ``read_gridfm`` / ``write_gridfm`` — the gridfm Parquet dataset; + ``read_display_file`` — PowerWorld ``.pwd`` one-line geometry. + +These stay in lockstep with powerio, nothing to hand-sync. The previous +``read_display_file`` overlay was upstreamed in powerio 0.3.3 and is gone, so +this module is now a pure re-export. powerio also still registers the pre-0.3.3 +aliases (``*_case``, ``read_pypsa_csv_folder``, ``write_pypsa_csv_folder``), +deprecated and removed in powerio 0.4.0; they ride along on the ``mcp`` instance +but new PowerMCP code uses the bare verbs. Run over stdio with ``python powerio_mcp.py`` (or ``powermcp run powerio``). """ from __future__ import annotations -import powerio - # Re-export the canonical server and its tools verbatim. Importing # ``powerio.mcp.server`` also fails loudly if the repo's own ``powerio/`` # directory shadows the installed package (it has no ``mcp`` submodule), so no # separate shadow guard is needed. from powerio.mcp.server import ( # noqa: F401 (re-exported for `powermcp run` and tests) mcp, - convert_case, - save_case, - case_summary, - parse_case, - normalize_case, - case_to_json, + convert, + save, + summary, + parse, + to_json, + normalize, compute_matrix, dense_view, - read_pypsa_csv_folder, - write_pypsa_csv_folder, read_gridfm, write_gridfm, + read_display_file, ) -@mcp.tool() -def read_display_file(path: str) -> dict: - """Decode a PowerWorld ``.pwd`` display file into canvas + substation layout. - - A ``.pwd`` is the one-line *display* artifact (diagram geometry), separate - from the network case in a ``.pwb`` / ``.aux``. This reads the diagram's - canvas size, its stamp, and each substation's display coordinates, so a - client can place buses on a one-line or map without PowerWorld installed. - - Returns ``{"kind": "powerworld", "canvas_width": , - "canvas_height": , "stamp": , "substations": - [{"number": , "name": , "x": , "y": }, ...]}``. - """ - try: - display = powerio.parse_display_file(path) - except powerio.PowerIOError as exc: - 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: - raise ValueError(f"cannot read file: {exc}") from exc - # powerio's DisplayData is generic (kind + data); only "powerworld" yields a - # PwdDisplay. Reject any other kind with a clean error instead of an opaque - # AttributeError if a future powerio adds one (the pin is a >=0.2.2 floor). - if display.kind != "powerworld": - raise ValueError(f"unsupported display format: {display.kind!r}") - pwd = display.data - return { - "kind": display.kind, - "canvas_width": pwd.canvas_width, - "canvas_height": pwd.canvas_height, - "stamp": pwd.stamp, - "substations": [ - {"number": s.number, "name": s.name, "x": s.x, "y": s.y} - for s in pwd.substations - ], - } - - if __name__ == "__main__": mcp.run(transport="stdio") diff --git a/pyproject.toml b/pyproject.toml index a9bbad6..3572584 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "hatchling.build" [project] name = "powermcp" -version = "0.1.4" +version = "0.2.0" description = "MCP servers for power-system software (PowerWorld, OpenDSS, PSS/E, pandapower, PyPSA, and more)" readme = "README.md" license = { file = "LICENSE" } @@ -40,10 +40,11 @@ dependencies = [ # ANDES bridges build on its JSON transport), and is cheap to require: abi3 # wheels for five platforms, zero required runtime deps, [mcp,matrix] adding # only scipy (already transitive via pandapower) on top of mcp+numpy. - # >=0.2.2: the canonical powerio.mcp.server now registers the folder/Parquet - # tools too (powerio #119), so powerio_mcp.py re-exports all twelve tools and - # overlays only read_display_file over the new .pwd display API (powerio #120). - "powerio[mcp,matrix]>=0.2.2", + # >=0.3.3: the MCP tool surface was canonicalized to the powerio Python API + # (`*_network` names) and grew the distribution tools (convert_dist_network, + # dist_network_summary, save_dist_network) plus read_display_file, so + # powerio_mcp.py is a pure re-export of all sixteen tools — no local overlay. + "powerio[mcp,matrix]>=0.3.3", # CLI / installer toolkit: "typer>=0.12", "questionary>=2.0", diff --git a/tests/test_powerio_server.py b/tests/test_powerio_server.py index 37cca9a..55642c5 100644 --- a/tests/test_powerio_server.py +++ b/tests/test_powerio_server.py @@ -64,20 +64,20 @@ """ -def test_parse_case_json_round_trips(): - r = powerio_mcp.parse_case(path=str(CASE9)) +def test_parse_json_round_trips(): + r = powerio_mcp.parse(path=str(CASE9)) assert r["summary"]["n_buses"] == 9 assert powerio.from_json(r["json"]).n_buses == 9 -def test_normalize_case_returns_dense_one_based_ids(): - r = powerio_mcp.normalize_case(path=str(CASE9)) +def test_normalize_returns_dense_one_based_ids(): + r = powerio_mcp.normalize(path=str(CASE9)) case = powerio.from_json(r["json"]) assert [b["id"] for b in case.buses] == list(range(1, 10)) -def test_case_to_json_accepted_downstream(): - r = powerio_mcp.case_to_json(path=str(CASE9)) +def test_to_json_accepted_downstream(): + r = powerio_mcp.to_json(path=str(CASE9)) assert powerio.from_json(r["json"]).n_buses == 9 @@ -94,7 +94,7 @@ def test_compute_matrix_bprime(): def test_compute_matrix_accepts_json_transport(): - transport = powerio_mcp.parse_case(path=str(CASE9))["json"] + transport = powerio_mcp.parse(path=str(CASE9))["json"] from_json = powerio_mcp.compute_matrix("bprime", json=transport) from_path = powerio_mcp.compute_matrix("bprime", path=str(CASE9)) assert from_json["shape"] == from_path["shape"] @@ -116,14 +116,14 @@ def test_dense_view_counts(): assert type(d["is_radial"]) is bool -def test_convert_case_powermodels(): - r = powerio_mcp.convert_case(to="powermodels-json", path=str(CASE9)) +def test_convert_powermodels(): + r = powerio_mcp.convert(to="powermodels-json", path=str(CASE9)) assert isinstance(r["warnings"], list) assert len(json.loads(r["text"])["bus"]) == 9 -def test_case_summary_fields(): - s = powerio_mcp.case_summary(path=str(CASE9)) +def test_summary_fields(): + s = powerio_mcp.summary(path=str(CASE9)) assert s["n_buses"] == 9 assert s["base_mva"] == 100.0 assert s["source_format"] == "Matpower" @@ -137,9 +137,9 @@ def test_case_summary_fields(): def test_exactly_one_input_enforced(): with pytest.raises(ValueError): - powerio_mcp.case_summary() + powerio_mcp.summary() with pytest.raises(ValueError): - powerio_mcp.case_summary(path="x", content="y") + powerio_mcp.summary(path="x", content="y") with pytest.raises(ValueError): powerio_mcp.compute_matrix("bprime") with pytest.raises(ValueError): @@ -150,7 +150,7 @@ def test_exactly_one_input_enforced(): def test_inline_content_requires_from(): with pytest.raises(ValueError): - powerio_mcp.convert_case(to="psse", content=CASE9.read_text()) + powerio_mcp.convert(to="psse", content=CASE9.read_text()) def test_compute_matrix_lacpf(): @@ -162,39 +162,39 @@ def test_compute_matrix_lacpf(): assert type(m["row"][0]) is int -def test_save_case_writes_file(tmp_path): +def test_save_writes_file(tmp_path): out = tmp_path / "case9.json" - r = powerio_mcp.save_case(to="powermodels-json", out_path=str(out), path=str(CASE9)) + r = powerio_mcp.save(to="powermodels-json", out_path=str(out), path=str(CASE9)) assert r["path"] == str(out) assert r["bytes_written"] == out.stat().st_size assert isinstance(r["warnings"], list) assert len(json.loads(out.read_text())["bus"]) == 9 -def test_save_case_refuses_overwrite(tmp_path): +def test_save_refuses_overwrite(tmp_path): out = tmp_path / "case9.m" out.write_text("existing") with pytest.raises(ValueError, match="overwrite"): - powerio_mcp.save_case(to="matpower", out_path=str(out), path=str(CASE9)) - r = powerio_mcp.save_case( + powerio_mcp.save(to="matpower", out_path=str(out), path=str(CASE9)) + r = powerio_mcp.save( to="matpower", out_path=str(out), path=str(CASE9), overwrite=True ) assert r["bytes_written"] == out.stat().st_size -def test_save_case_accepts_json_transport(tmp_path): - transport = powerio_mcp.parse_case(path=str(CASE9))["json"] +def test_save_accepts_json_transport(tmp_path): + transport = powerio_mcp.parse(path=str(CASE9))["json"] out = tmp_path / "case9.m" - powerio_mcp.save_case(to="matpower", out_path=str(out), json=transport) + powerio_mcp.save(to="matpower", out_path=str(out), json=transport) assert powerio.parse_file(out).n_buses == 9 -def test_save_case_exactly_one_input(tmp_path): +def test_save_exactly_one_input(tmp_path): out = tmp_path / "x.m" with pytest.raises(ValueError): - powerio_mcp.save_case(to="matpower", out_path=str(out)) + powerio_mcp.save(to="matpower", out_path=str(out)) with pytest.raises(ValueError): - powerio_mcp.save_case(to="matpower", out_path=str(out), path="a", json="{}") + powerio_mcp.save(to="matpower", out_path=str(out), path="a", json="{}") def test_pypsa_import_case_from_any(tmp_path): @@ -208,7 +208,7 @@ def test_pypsa_import_case_from_any(tmp_path): def test_pypsa_import_case_from_json(tmp_path): - transport = powerio_mcp.parse_case(path=str(CASE9))["json"] + transport = powerio_mcp.parse(path=str(CASE9))["json"] out = tmp_path / "case9.nc" r = pypsa_mcp.import_case_from_json(transport, str(out)) assert r["status"] == "success", r @@ -279,7 +279,7 @@ def boom(*args, **kwargs): monkeypatch.setattr(tempfile, "mkstemp", boom) monkeypatch.setattr(tempfile, "NamedTemporaryFile", boom) - r = powerio_mcp.convert_case(to="psse", content=CASE9.read_text(), from_="matpower") + r = powerio_mcp.convert(to="psse", content=CASE9.read_text(), format="matpower") assert r["text"] @@ -349,7 +349,7 @@ def test_compute_matrix_bad_json_raises_valueerror(): powerio_mcp.compute_matrix("bprime", json="{not valid json") -def test_convert_case_oserror_normalizes_to_valueerror(monkeypatch): +def test_convert_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_): @@ -357,7 +357,7 @@ 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") + powerio_mcp.convert(to="psse", content="x", format="matpower") def test_unreadable_file_maps_cleanly(tmp_path): @@ -373,9 +373,9 @@ def test_unreadable_file_maps_cleanly(tmp_path): locked.chmod(0o000) try: with pytest.raises(ValueError, match="cannot read file"): - powerio_mcp.convert_case(to="psse", path=str(locked)) + powerio_mcp.convert(to="psse", path=str(locked)) with pytest.raises(ValueError, match="cannot read file"): - powerio_mcp.case_summary(path=str(locked)) + powerio_mcp.summary(path=str(locked)) finally: locked.chmod(0o644) @@ -415,7 +415,7 @@ def test_andes_load_network_from_any(tmp_path): def test_andes_load_network_from_json(tmp_path): andes_mcp = _load_andes_mcp() - transport = powerio_mcp.parse_case(path=str(CASE9))["json"] + transport = powerio_mcp.parse(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 @@ -436,42 +436,45 @@ def test_andes_load_missing_file(tmp_path): # --------------------------------------------------------------------------- def test_convert_to_pandapower_json(): - r = powerio_mcp.convert_case(to="pandapower-json", path=str(CASE9)) + r = powerio_mcp.convert(to="pandapower-json", path=str(CASE9)) assert r["text"] assert json.loads(r["text"]) # well-formed JSON def test_pandapower_json_round_trips_through_transport(): # pandapower-json is a plain text format, so it flows through the existing - # save_case/parse_case tools with no dedicated tool. - transport = powerio_mcp.parse_case(path=str(CASE9))["json"] - out = powerio_mcp.case_to_json(content= - powerio_mcp.convert_case(to="pandapower-json", path=str(CASE9))["text"], + # save/parse tools with no dedicated tool. + transport = powerio_mcp.parse(path=str(CASE9))["json"] + out = powerio_mcp.to_json(content= + powerio_mcp.convert(to="pandapower-json", path=str(CASE9))["text"], format="pandapower-json") assert json.loads(out["json"]) assert json.loads(transport) def test_pypsa_csv_folder_round_trip(tmp_path): + # pypsa-csv is a directory format: write through save(to="pypsa-csv"), read + # back through parse via a folder path (powerio 0.3.3 folded the dedicated + # read/write_pypsa_csv_folder tools into the bare verbs). out_dir = tmp_path / "pypsa_csv" - w = powerio_mcp.write_pypsa_csv_folder(str(out_dir), path=str(CASE9)) + w = powerio_mcp.save(to="pypsa-csv", out_path=str(out_dir), path=str(CASE9)) assert w["files"], w assert (out_dir / "buses.csv").exists() - r = powerio_mcp.read_pypsa_csv_folder(str(out_dir)) + r = powerio_mcp.parse(path=str(out_dir)) assert r["summary"]["n_buses"] == 9 assert json.loads(r["json"]) def test_pypsa_csv_folder_accepts_transport(tmp_path): - transport = powerio_mcp.parse_case(path=str(CASE9))["json"] + transport = powerio_mcp.parse(path=str(CASE9))["json"] out_dir = tmp_path / "from_json" - w = powerio_mcp.write_pypsa_csv_folder(str(out_dir), json=transport) + w = powerio_mcp.save(to="pypsa-csv", out_path=str(out_dir), json=transport) assert (out_dir / "generators.csv").exists(), w def test_read_pypsa_csv_missing_folder_maps_cleanly(tmp_path): with pytest.raises(ValueError): - powerio_mcp.read_pypsa_csv_folder(str(tmp_path / "nope")) + powerio_mcp.parse(path=str(tmp_path / "nope")) def test_gridfm_round_trip(tmp_path): @@ -490,9 +493,9 @@ def test_read_gridfm_missing_dir_maps_cleanly(tmp_path): # --------------------------------------------------------------------------- -# PowerWorld .pwd display files (powerio #120). read_display_file is the one -# remaining local overlay: it wraps powerio.parse_display_file until the -# canonical server exposes a display tool. +# PowerWorld .pwd display files. read_display_file is provided by the canonical +# powerio.mcp.server since powerio 0.3.3 (the previous local overlay is gone); +# these tests exercise the re-exported tool. # --------------------------------------------------------------------------- def test_read_display_file_decodes_pwd(): From 5199f33ceb8cf84ba4f23d4989bb982d43e9260d Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:01:39 -0400 Subject: [PATCH 2/5] Align PowerIO MCP bridge with 0.3.3 tools --- ANDES/andes_mcp.py | 8 +- Egret/egret_mcp.py | 8 +- OpenDSS/opendss_tools/configuration.py | 64 +++++-- PyPSA/pypsa_mcp.py | 8 +- README.md | 14 +- pandapower/panda_mcp.py | 14 +- powerio/powerio_mcp.py | 69 ++++---- pyproject.toml | 7 +- tests/test_powerio_server.py | 227 ++++++++++++++++++------- 9 files changed, 273 insertions(+), 146 deletions(-) diff --git a/ANDES/andes_mcp.py b/ANDES/andes_mcp.py index d10bbb6..c85a146 100644 --- a/ANDES/andes_mcp.py +++ b/ANDES/andes_mcp.py @@ -351,9 +351,9 @@ def load_network_from_json( ) -> 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 or - 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 + Accepts the ``json`` string returned by the powerio server's parse tool. + 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. powerio is a core dependency, so this is always available. @@ -441,4 +441,4 @@ def load_network_from_any( if __name__ == "__main__": print(f"Starting ANDES MCP Server") print(f"Using storage directory: {_andes_runs_dir()}") - mcp.run(transport="stdio") \ No newline at end of file + mcp.run(transport="stdio") diff --git a/Egret/egret_mcp.py b/Egret/egret_mcp.py index 3c5fc44..6bed0be 100644 --- a/Egret/egret_mcp.py +++ b/Egret/egret_mcp.py @@ -250,9 +250,9 @@ def load_model_from_any(file_path: str, source_format: Optional[str] = None) -> def load_model_from_json(network_json: str) -> Dict[str, Any]: """Convert a powerio JSON transport string into an egret model. - Accepts the `json` string returned by the powerio server's parse or - to_json tools, so a case parsed once there feeds egret without - re-reading the file. Converts it to egret JSON, validates it as an egret + Accepts the `json` string returned by the powerio server's parse tool, + so a case parsed once there feeds egret without re-reading the file. + Converts it to egret JSON, validates it as an egret ModelData, and stages it to a temp file. Pass the returned `case_file` path to the solver tools. powerio is a core dependency, so this is always available. @@ -283,4 +283,4 @@ def load_model_from_json(network_json: str) -> Dict[str, Any]: if __name__ == "__main__": - mcp.run(transport="stdio") \ No newline at end of file + mcp.run(transport="stdio") diff --git a/OpenDSS/opendss_tools/configuration.py b/OpenDSS/opendss_tools/configuration.py index 56d8e27..b042c18 100644 --- a/OpenDSS/opendss_tools/configuration.py +++ b/OpenDSS/opendss_tools/configuration.py @@ -68,26 +68,28 @@ def compile_distribution( path: Optional[str] = None, content: Optional[str] = None, source_format: Optional[str] = None, + format: Optional[str] = None, force_recompile: bool = False, ) -> Dict[str, Any]: """Compile a distribution case given in any powerio distribution format. The on-ramp into OpenDSS for the BMOPF and PowerModelsDistribution worlds: a - feeder held as IEEE BMOPF JSON (``bmopf-json``) or PowerModelsDistribution - ENGINEERING JSON (``pmd-json``) — or already as OpenDSS ``.dss`` — is - converted to a self-contained ``.dss`` file by powerio and compiled here, - so a case authored or solved in those toolchains can run in OpenDSS without - a hand translation. + feeder held as IEEE BMOPF JSON (``bmopf-json``), PowerModelsDistribution + ENGINEERING JSON (``pmd-json``), or already as OpenDSS ``.dss`` is converted + to a self-contained ``.dss`` file by powerio and compiled here, so a case + authored or solved in those toolchains can run in OpenDSS without a hand + translation. Provide exactly one of ``path`` (a file on disk) or ``content`` (inline file - text). ``source_format`` is the distribution format name (``dss``, - ``pmd-json``, ``bmopf-json``); for a ``path`` it is inferred from the - extension when omitted, but it is REQUIRED for inline ``content``. + text). ``source_format``/``format`` is the distribution format name + (``dss``, ``pmd-json``, ``bmopf-json``); for a ``path`` it is inferred from + the extension when omitted, but it is REQUIRED for inline ``content``. Returns the ``compile_opendss_file`` result plus ``staged_dss_file`` (the - converted ``.dss`` path on disk) and ``conversion_warnings`` (powerio's - fidelity notes for the BMOPF/PMD → OpenDSS conversion — things OpenDSS could - not represent, e.g. solver-only metadata). + converted ``.dss`` path on disk, or ``None`` when an original DSS path was + compiled directly) and ``conversion_warnings`` (powerio's fidelity notes for + the BMOPF/PMD -> OpenDSS conversion, such as solver metadata OpenDSS cannot + represent). """ try: import powerio.dist as _dist @@ -96,19 +98,44 @@ def compile_distribution( if (path is None) == (content is None): return _err("provide exactly one of `path` or `content`") - if content is not None and not source_format: - return _err("`source_format` is required when compiling inline `content`") + source_key = ( + source_format.strip().lower().replace("_", "-") if source_format else None + ) + format_key = format.strip().lower().replace("_", "-") if format else None + if source_key is not None and format_key is not None and source_key != format_key: + return _err("`source_format` and `format` disagree") + source_key = source_key or format_key + if content is not None and not source_key: + return _err("`format` is required when compiling inline `content`") + + direct_dss = ( + path is not None + and ( + source_key in ("dss", "opendss") + or (source_key is None and Path(path).suffix.lower() == ".dss") + ) + ) + if direct_dss: + result = compile_opendss_file(path, force_recompile=force_recompile) + if isinstance(result, dict): + target = ( + result["payload"] if isinstance(result.get("payload"), dict) else result + ) + target["staged_dss_file"] = None + target["distribution_source_file"] = path + target["conversion_warnings"] = [] + return result try: if path is not None: - conv = _dist.convert_file(path, "dss", source_format) + conv = _dist.convert_file(path, "dss", source_key) else: - conv = _dist.convert_str(content, "dss", source_format) + conv = _dist.convert_str(content, "dss", source_key) except FileNotFoundError as exc: return _err(f"file not found: {exc}") except OSError as exc: return _err(f"cannot read file: {exc}") - except Exception as exc: # powerio.PowerIOError and friends → one error shape + except Exception as exc: # powerio.PowerIOError and friends use one error shape return _err(f"distribution conversion failed: {exc}") # newline="" keeps the converter output byte-identical across platforms. @@ -126,8 +153,11 @@ def compile_distribution( result = compile_opendss_file(staged, force_recompile=force_recompile) if isinstance(result, dict): # Sit beside the rest of the compile result inside the _ok payload. - target = result["payload"] if isinstance(result.get("payload"), dict) else result + target = ( + result["payload"] if isinstance(result.get("payload"), dict) else result + ) target["staged_dss_file"] = staged + target["distribution_source_file"] = path target["conversion_warnings"] = list(conv.warnings) return result diff --git a/PyPSA/pypsa_mcp.py b/PyPSA/pypsa_mcp.py index 469880f..782254a 100644 --- a/PyPSA/pypsa_mcp.py +++ b/PyPSA/pypsa_mcp.py @@ -771,9 +771,9 @@ def import_case_from_json( """Import a powerio JSON transport string as a PyPSA network saved to a NetCDF file. - Accepts the `json` string returned by the powerio server's parse or - to_json tools, so a case parsed once there loads here without passing - a file around or re-parsing it. Expects source-valued tables (MW, degrees) + Accepts the `json` string returned by the powerio server's parse tool, + so a case parsed once there loads here without passing a file around or + re-parsing it. Expects source-valued tables (MW, degrees) as parse emits them, not the per-unit normalize form. Writes the network to output_path (use a .nc extension); pass that path as network_name to the other tools. powerio is a core dependency, so this is @@ -807,4 +807,4 @@ def import_case_from_json( if __name__ == "__main__": - mcp.run(transport="stdio") \ No newline at end of file + mcp.run(transport="stdio") diff --git a/README.md b/README.md index 1769e25..4319d1c 100644 --- a/README.md +++ b/README.md @@ -131,17 +131,21 @@ PowerMCP ships a conversion server backed by [powerio](https://github.com/eigene Its JSON transport is the exchange format between PowerMCP servers: parse a case once, pass the returned `json` string between tool calls, and load it anywhere. ``` -parse_case(path="case9.raw") # powerio server → {"json": ..., "summary": ...} +parse(path="case9.raw") # powerio server -> {"json": ..., "summary": ...} load_network_from_json(network_json=...) # pandapower server ingests the transport load_model_from_json(network_json=...) # egret server stages it as a solvable case file import_case_from_json(network_json=..., output_path="case9.nc") # PyPSA server writes a .nc for its tools -compute_matrix(kind="ptdf", json=...) # powerio server builds matrices from it -save_case(to="psse", out_path="case9.raw", json=...) # stage a file for path-only servers +matrix(kind="ptdf", json=...) # powerio server builds matrices from it +save(to="psse", out_path="case9.raw", json=...) # stage a file for path only servers ``` -`save_case` covers the servers without a bridge: write the converted case to disk and point their load tools at the file (e.g. convert PowerWorld `.aux` to MATPOWER `.m` for ANDES). +`summary` returns the canonical nested shape used by PowerIO and PowerMCP: counts live under `elements` (`elements.buses`, `elements.branches`, `elements.generators`) and topology metadata lives under `topology` (`topology.connected_components`, `topology.reference_buses`). -PowerWorld `.pwd` display files decode separately via `read_display_file(path=...)`, which returns the one-line diagram's canvas size and each substation's display coordinates — the diagram geometry, distinct from the `.pwb`/`.aux` case data. +`save` covers the servers without a bridge: write the converted case to disk and point their load tools at the file (e.g. convert PowerWorld `.aux` to MATPOWER `.m` for ANDES). + +PowerWorld `.pwd` display files decode separately via `display(path=...)`, which returns the diagram canvas and each substation's display coordinates. The display geometry is distinct from the `.pwb`/`.aux` case data. + +PowerIO MCP tools accept local paths and `file://` URIs. Nonlocal URI schemes are rejected. Set `POWERIO_MCP_ALLOWED_ROOTS` to an `os.pathsep` separated list of directories to constrain MCP reads and writes. ### Running from a clone (without installing) diff --git a/pandapower/panda_mcp.py b/pandapower/panda_mcp.py index ccae10e..2855655 100644 --- a/pandapower/panda_mcp.py +++ b/pandapower/panda_mcp.py @@ -257,8 +257,8 @@ def get_network_info() -> Dict[str, Any]: # --------------------------------------------------------------------------- # powerio bridge: exchange cases with the powerio conversion server. -# Its parse / to_json tools emit a JSON transport string that -# load_network_from_json ingests directly, so a case parsed once there loads +# Its parse tool emits a JSON transport string that load_network_from_json +# ingests directly, so a case parsed once there loads # here without re-reading the file; export_network_to_format sends the current # network back out through powerio. powerio is an optional extra, so each tool # degrades to a status dict when it is missing. @@ -418,9 +418,9 @@ def load_network_from_any(file_path: str, source_format: Optional[str] = None) - def load_network_from_json(network_json: str) -> Dict[str, Any]: """Load a network from a powerio JSON transport string. - Accepts the `json` string returned by the powerio server's parse or - to_json tools, so a case parsed once there loads here without passing - a file around or re-parsing it. Expects source-valued tables (MW, degrees) + Accepts the `json` string returned by the powerio server's parse tool, + so a case parsed once there loads here without passing a file around or + re-parsing it. Expects source-valued tables (MW, degrees) as parse emits them, not the per-unit normalize form. Replaces the currently loaded network. powerio is a core dependency, so this is always available. @@ -480,5 +480,5 @@ def export_network_to_format(to_format: str) -> Dict[str, Any]: return {"status": "success", "text": conv.text, "warnings": list(conv.warnings)} -if __name__ == "__main__": - mcp.run(transport="stdio") \ No newline at end of file +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/powerio/powerio_mcp.py b/powerio/powerio_mcp.py index 6785dfc..0fd0f53 100644 --- a/powerio/powerio_mcp.py +++ b/powerio/powerio_mcp.py @@ -1,52 +1,49 @@ -"""PowerMCP's powerio conversion server — a thin re-export of the canonical +"""PowerMCP's powerio conversion server: a thin re-export of the canonical ``powerio.mcp.server`` that ships with the powerio package. powerio is a core dependency (see ``powermcp.registry`` / ``pyproject.toml``), so -this server keeps no copy of the conversion/summary/matrix tools: it re-exports -the canonical FastMCP ``mcp`` instance and every tool registered on it. As of -powerio 0.3.3 the surface is the bare powerio verbs, one set for transmission -and distribution (routed by format): +this server keeps no copy of the conversion/summary/matrix tools. As of powerio +0.3.3 the canonical MCP surface is the bare powerio verbs, one set for +transmission and distribution (routed by format): -- ``convert`` / ``save`` / ``summary`` — any single-file format, either domain. +- ``convert`` / ``save`` / ``summary``: generic format and transport verbs. ``save(to="dss")`` stages an IEEE BMOPF or PowerModelsDistribution case as a ``.dss`` file the OpenDSS server can compile (see its ``compile_distribution`` tool); ``save(to="pypsa-csv")`` writes the CSV folder. -- ``parse`` / ``to_json`` / ``normalize`` / ``compute_matrix`` / ``dense_view`` — - transmission only (a distribution network has no JSON transport and isn't - positive-sequence). -- ``read_gridfm`` / ``write_gridfm`` — the gridfm Parquet dataset; - ``read_display_file`` — PowerWorld ``.pwd`` one-line geometry. - -These stay in lockstep with powerio, nothing to hand-sync. The previous -``read_display_file`` overlay was upstreamed in powerio 0.3.3 and is gone, so -this module is now a pure re-export. powerio also still registers the pre-0.3.3 -aliases (``*_case``, ``read_pypsa_csv_folder``, ``write_pypsa_csv_folder``), -deprecated and removed in powerio 0.4.0; they ride along on the ``mcp`` instance -but new PowerMCP code uses the bare verbs. +- ``parse`` / ``normalize``: JSON transport for parsed or normalized networks. +- ``matrix``: sparse transmission matrix outputs. +- ``display``: display artifacts such as PowerWorld ``.pwd`` geometry. + +PowerMCP deliberately re-exports only those canonical tools. GridFM and PyPSA +folders route through ``parse`` and ``save`` with format names, and display +artifacts route through ``display``. Run over stdio with ``python powerio_mcp.py`` (or ``powermcp run powerio``). """ from __future__ import annotations -# Re-export the canonical server and its tools verbatim. Importing -# ``powerio.mcp.server`` also fails loudly if the repo's own ``powerio/`` -# directory shadows the installed package (it has no ``mcp`` submodule), so no -# separate shadow guard is needed. -from powerio.mcp.server import ( # noqa: F401 (re-exported for `powermcp run` and tests) - mcp, - convert, - save, - summary, - parse, - to_json, - normalize, - compute_matrix, - dense_view, - read_gridfm, - write_gridfm, - read_display_file, -) +from powerio.mcp import server as _server + +mcp = _server.mcp +convert = _server.convert +save = _server.save +summary = _server.summary +parse = _server.parse +normalize = _server.normalize +matrix = _server.matrix +display = _server.display + +__all__ = [ + "mcp", + "convert", + "save", + "summary", + "parse", + "normalize", + "matrix", + "display", +] if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 3572584..8ef659e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,10 +40,9 @@ dependencies = [ # ANDES bridges build on its JSON transport), and is cheap to require: abi3 # wheels for five platforms, zero required runtime deps, [mcp,matrix] adding # only scipy (already transitive via pandapower) on top of mcp+numpy. - # >=0.3.3: the MCP tool surface was canonicalized to the powerio Python API - # (`*_network` names) and grew the distribution tools (convert_dist_network, - # dist_network_summary, save_dist_network) plus read_display_file, so - # powerio_mcp.py is a pure re-export of all sixteen tools — no local overlay. + # >=0.3.3: the MCP tool surface is the seven canonical verbs: + # convert, save, summary, parse, normalize, matrix, display. + # powerio_mcp.py re-exports exactly that surface with no local overlay. "powerio[mcp,matrix]>=0.3.3", # CLI / installer toolkit: "typer>=0.12", diff --git a/tests/test_powerio_server.py b/tests/test_powerio_server.py index 55642c5..737ea2a 100644 --- a/tests/test_powerio_server.py +++ b/tests/test_powerio_server.py @@ -15,13 +15,16 @@ from __future__ import annotations +import asyncio +import importlib import json import sys +import types from pathlib import Path import pytest -pytest.importorskip("powerio", minversion="0.2.2") +pytest.importorskip("powerio", minversion="0.3.3") import powerio # noqa: E402 @@ -66,23 +69,37 @@ def test_parse_json_round_trips(): r = powerio_mcp.parse(path=str(CASE9)) - assert r["summary"]["n_buses"] == 9 + assert r["json_format"] == "powerio-json" + assert r["summary"]["elements"]["buses"] == 9 assert powerio.from_json(r["json"]).n_buses == 9 +def test_tool_surface_is_canonical(): + names = {tool.name for tool in asyncio.run(powerio_mcp.mcp.list_tools())} + assert names == { + "convert", + "save", + "summary", + "parse", + "normalize", + "matrix", + "display", + } + + def test_normalize_returns_dense_one_based_ids(): r = powerio_mcp.normalize(path=str(CASE9)) case = powerio.from_json(r["json"]) assert [b["id"] for b in case.buses] == list(range(1, 10)) -def test_to_json_accepted_downstream(): - r = powerio_mcp.to_json(path=str(CASE9)) +def test_parse_transport_accepted_downstream(): + r = powerio_mcp.parse(path=str(CASE9)) assert powerio.from_json(r["json"]).n_buses == 9 -def test_compute_matrix_bprime(): - m = powerio_mcp.compute_matrix("bprime", path=str(CASE9)) +def test_matrix_bprime(): + m = powerio_mcp.matrix("bprime", path=str(CASE9)) assert m["format"] == "coo" assert m["shape"] == [9, 9] assert m["nnz"] > 0 @@ -93,27 +110,17 @@ def test_compute_matrix_bprime(): assert type(m["col"][0]) is int -def test_compute_matrix_accepts_json_transport(): +def test_matrix_accepts_json_transport(): transport = powerio_mcp.parse(path=str(CASE9))["json"] - from_json = powerio_mcp.compute_matrix("bprime", json=transport) - from_path = powerio_mcp.compute_matrix("bprime", path=str(CASE9)) + from_json = powerio_mcp.matrix("bprime", json=transport) + from_path = powerio_mcp.matrix("bprime", path=str(CASE9)) assert from_json["shape"] == from_path["shape"] assert from_json["nnz"] == from_path["nnz"] -def test_compute_matrix_unknown_kind(): +def test_matrix_unknown_kind(): with pytest.raises(ValueError): - powerio_mcp.compute_matrix("nope", path=str(CASE9)) - - -def test_dense_view_counts(): - d = powerio_mcp.dense_view(path=str(CASE9)) - assert d["n"] == 9 - assert d["m"] == 9 - assert d["base_mva"] == 100.0 - assert type(d["bus_ids"][0]) is int - assert type(d["branch"]["r"][0]) is float - assert type(d["is_radial"]) is bool + powerio_mcp.matrix("nope", path=str(CASE9)) def test_convert_powermodels(): @@ -124,15 +131,12 @@ def test_convert_powermodels(): def test_summary_fields(): s = powerio_mcp.summary(path=str(CASE9)) - assert s["n_buses"] == 9 + assert s["elements"]["buses"] == 9 assert s["base_mva"] == 100.0 assert s["source_format"] == "Matpower" - assert s["n_connected_components"] == 1 - for key in ( - "name", "n_branches", "n_gens", "n_loads", "n_shunts", - "is_radial", "connectivity_report", - ): - assert key in s + assert s["topology"]["connected_components"] == 1 + assert s["elements"]["branches"] == 9 + assert s["topology"]["connectivity_report"] def test_exactly_one_input_enforced(): @@ -141,20 +145,17 @@ def test_exactly_one_input_enforced(): with pytest.raises(ValueError): powerio_mcp.summary(path="x", content="y") with pytest.raises(ValueError): - powerio_mcp.compute_matrix("bprime") - with pytest.raises(ValueError): - powerio_mcp.compute_matrix("bprime", path=str(CASE9), json="{}") + powerio_mcp.matrix("bprime") with pytest.raises(ValueError): - powerio_mcp.dense_view(path="x", content="y") + powerio_mcp.matrix("bprime", path=str(CASE9), json="{}") -def test_inline_content_requires_from(): - with pytest.raises(ValueError): - powerio_mcp.convert(to="psse", content=CASE9.read_text()) +def test_inline_matpower_content_defaults_to_matpower(): + assert powerio_mcp.convert(to="psse", content=CASE9.read_text())["text"] -def test_compute_matrix_lacpf(): - m = powerio_mcp.compute_matrix("lacpf", path=str(CASE9)) +def test_matrix_lacpf(): + m = powerio_mcp.matrix("lacpf", path=str(CASE9)) assert m["format"] == "coo" assert m["shape"] == [18, 18] assert m["nnz"] > 0 @@ -338,15 +339,15 @@ def test_pandapower_bridge_honors_branch_status(tmp_path): 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)) +def test_matrix_laplacian(): + m = powerio_mcp.matrix("laplacian", path=str(CASE9)) assert m["format"] == "coo" assert m["shape"] == [9, 9] -def test_compute_matrix_bad_json_raises_valueerror(): +def test_matrix_bad_json_raises_valueerror(): with pytest.raises(ValueError): - powerio_mcp.compute_matrix("bprime", json="{not valid json") + powerio_mcp.matrix("bprime", json="{not valid json") def test_convert_oserror_normalizes_to_valueerror(monkeypatch): @@ -372,9 +373,9 @@ def test_unreadable_file_maps_cleanly(tmp_path): locked.write_text("function mpc = x\n") locked.chmod(0o000) try: - with pytest.raises(ValueError, match="cannot read file"): + with pytest.raises(ValueError, match="cannot read input"): powerio_mcp.convert(to="psse", path=str(locked)) - with pytest.raises(ValueError, match="cannot read file"): + with pytest.raises(ValueError, match="cannot read input"): powerio_mcp.summary(path=str(locked)) finally: locked.chmod(0o644) @@ -385,7 +386,7 @@ def test_wrong_schema_json_maps_cleanly(): # 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) + powerio_mcp.matrix("bprime", json=bad, json_format="powerio-json") # --------------------------------------------------------------------------- @@ -431,8 +432,7 @@ def test_andes_load_missing_file(tmp_path): # --------------------------------------------------------------------------- -# pandapower-json (a text format added in powerio 0.1.1) and the folder / -# Parquet formats that need their own read/write tools. +# pandapower-json plus folder and Parquet formats routed through generic verbs. # --------------------------------------------------------------------------- def test_convert_to_pandapower_json(): @@ -445,9 +445,10 @@ def test_pandapower_json_round_trips_through_transport(): # pandapower-json is a plain text format, so it flows through the existing # save/parse tools with no dedicated tool. transport = powerio_mcp.parse(path=str(CASE9))["json"] - out = powerio_mcp.to_json(content= - powerio_mcp.convert(to="pandapower-json", path=str(CASE9))["text"], - format="pandapower-json") + out = powerio_mcp.parse( + content=powerio_mcp.convert(to="pandapower-json", path=str(CASE9))["text"], + format="pandapower-json", + ) assert json.loads(out["json"]) assert json.loads(transport) @@ -461,7 +462,7 @@ def test_pypsa_csv_folder_round_trip(tmp_path): assert w["files"], w assert (out_dir / "buses.csv").exists() r = powerio_mcp.parse(path=str(out_dir)) - assert r["summary"]["n_buses"] == 9 + assert r["summary"]["elements"]["buses"] == 9 assert json.loads(r["json"]) @@ -479,29 +480,28 @@ def test_read_pypsa_csv_missing_folder_maps_cleanly(tmp_path): def test_gridfm_round_trip(tmp_path): out_dir = tmp_path / "gfm" - w = powerio_mcp.write_gridfm(str(out_dir), path=str(CASE9)) + w = powerio_mcp.save(to="gridfm", out_path=str(out_dir), path=str(CASE9)) assert w["files"], w - r = powerio_mcp.read_gridfm(str(out_dir)) - assert r["summary"]["n_buses"] == 9 - assert r["scenario"] == 0 + r = powerio_mcp.parse(path=str(out_dir), format="gridfm", options={"scenario": 0}) + assert r["summary"]["elements"]["buses"] == 9 assert json.loads(r["json"]) -def test_read_gridfm_missing_dir_maps_cleanly(tmp_path): +def test_gridfm_missing_dir_maps_cleanly(tmp_path): with pytest.raises(ValueError): - powerio_mcp.read_gridfm(str(tmp_path / "nope")) + powerio_mcp.parse(path=str(tmp_path / "nope"), format="gridfm") # --------------------------------------------------------------------------- -# PowerWorld .pwd display files. read_display_file is provided by the canonical -# powerio.mcp.server since powerio 0.3.3 (the previous local overlay is gone); -# these tests exercise the re-exported tool. +# PowerWorld .pwd display files. display is provided by the canonical +# powerio.mcp.server; these tests exercise the re-exported tool. # --------------------------------------------------------------------------- -def test_read_display_file_decodes_pwd(): - r = powerio_mcp.read_display_file(str(ACTIVSG200_PWD)) - assert r["kind"] == "powerworld" - assert r["canvas_width"] > 0 and r["canvas_height"] > 0 +def test_display_decodes_pwd(): + r = powerio_mcp.display(str(ACTIVSG200_PWD)) + assert r["domain"] == "display" + assert r["source_format"] == "powerworld-pwd" + assert r["canvas"]["width"] > 0 and r["canvas"]["height"] > 0 subs = r["substations"] assert subs, "expected at least one substation" assert all(set(s) == {"number", "name", "x", "y"} for s in subs) @@ -514,11 +514,108 @@ def test_read_display_file_decodes_pwd(): def test_read_display_missing_file_maps_cleanly(tmp_path): with pytest.raises(ValueError): - powerio_mcp.read_display_file(str(tmp_path / "nope.pwd")) + powerio_mcp.display(str(tmp_path / "nope.pwd")) def test_read_display_garbage_file_maps_cleanly(tmp_path): bad = tmp_path / "garbage.pwd" bad.write_bytes(b"not a real display file\x00\x01\x02") with pytest.raises(ValueError): - powerio_mcp.read_display_file(str(bad)) + powerio_mcp.display(str(bad)) + + +# --------------------------------------------------------------------------- +# OpenDSS distribution bridge +# --------------------------------------------------------------------------- + +def _load_opendss_configuration(monkeypatch): + opendss_dir = Path(__file__).resolve().parents[1] / "OpenDSS" + monkeypatch.syspath_prepend(str(opendss_dir)) + + fake_config = types.SimpleNamespace( + compile_dss=lambda _path: None, + circuit_readiness=lambda: {"ready": True}, + ) + fake_tools = types.SimpleNamespace( + update_dss=lambda _dss: None, + configuration=fake_config, + ) + fake_dss_interface = types.SimpleNamespace(DSS=lambda: object()) + monkeypatch.setitem( + sys.modules, "py_dss_toolkit", types.SimpleNamespace(dss_tools=fake_tools) + ) + monkeypatch.setitem(sys.modules, "py_dss_interface", fake_dss_interface) + + for name in ( + "opendss_tools.configuration", + "core.engine", + "core.state", + "utils.responses", + ): + sys.modules.pop(name, None) + return importlib.import_module("opendss_tools.configuration") + + +def test_compile_distribution_compiles_dss_path_directly(monkeypatch, tmp_path): + configuration = _load_opendss_configuration(monkeypatch) + dss_file = tmp_path / "master.dss" + dss_file.write_text("Redirect feeders/loads.dss\n") + calls = [] + + def fake_compile(path, force_recompile=False): + calls.append((path, force_recompile)) + return {"success": True, "payload": {"dss_file": path}} + + monkeypatch.setattr(configuration, "compile_opendss_file", fake_compile) + + result = configuration.compile_distribution( + path=str(dss_file), + source_format="opendss", + force_recompile=True, + ) + + assert calls == [(str(dss_file), True)] + assert result["success"] is True + assert result["payload"]["staged_dss_file"] is None + assert result["payload"]["distribution_source_file"] == str(dss_file) + assert result["payload"]["conversion_warnings"] == [] + + +def test_compile_distribution_stages_converted_json(monkeypatch, tmp_path): + configuration = _load_opendss_configuration(monkeypatch) + source = tmp_path / "network.json" + source.write_text('{"bus": {}}') + convert_calls = [] + compile_calls = [] + + class Conversion: + text = "Redirect feeders/loads.dss\n" + warnings = ["dropped solver metadata"] + + import powerio.dist as dist + + def fake_convert_file(path, to, source_format): + convert_calls.append((path, to, source_format)) + return Conversion() + + def fake_compile(path, force_recompile=False): + compile_calls.append((Path(path), force_recompile)) + assert Path(path).read_text() == Conversion.text + return {"success": True, "payload": {"dss_file": path}} + + monkeypatch.setattr(dist, "convert_file", fake_convert_file) + monkeypatch.setattr(configuration, "compile_opendss_file", fake_compile) + + result = configuration.compile_distribution( + path=str(source), + source_format="bmopf_json", + format="bmopf-json", + ) + + assert convert_calls == [(str(source), "dss", "bmopf-json")] + assert len(compile_calls) == 1 + assert compile_calls[0][0].name.startswith("powerio_dist_") + assert result["success"] is True + assert result["payload"]["staged_dss_file"] == str(compile_calls[0][0]) + assert result["payload"]["distribution_source_file"] == str(source) + assert result["payload"]["conversion_warnings"] == Conversion.warnings From 6aa6256eec1c9a29fee205d158dce477943c1c73 Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:03:24 -0400 Subject: [PATCH 3/5] Test against companion PowerIO draft --- .github/workflows/test.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4823ba..2d1194a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,12 +22,16 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip - - name: Install package with the powerio extra - # The powerio extra makes tests/test_powerio_server.py run instead of - # skip; on revisions without that extra, pip warns and proceeds. + - uses: dtolnay/rust-toolchain@stable + + - name: Install companion PowerIO draft run: | python -m pip install --upgrade pip - python -m pip install -e '.[powerio]' pytest + python -m pip install "powerio[mcp,matrix] @ git+https://github.com/eigenergy/powerio.git@feat/mcp-dist-display-tools" + + - name: Install package + run: | + python -m pip install -e . pytest - name: Run tests run: python -m pytest tests/ -q From 8457c8c9aea6e5289c10a1bc3a4383869e091314 Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Wed, 24 Jun 2026 04:25:26 -0400 Subject: [PATCH 4/5] Route OpenDSS distribution flows through PowerIO --- OpenDSS/opendss_tools/configuration.py | 103 +----------------- README.md | 15 ++- powerio/powerio_mcp.py | 20 ++-- tests/test_powerio_server.py | 138 +++++++++++-------------- 4 files changed, 82 insertions(+), 194 deletions(-) diff --git a/OpenDSS/opendss_tools/configuration.py b/OpenDSS/opendss_tools/configuration.py index b042c18..6a77261 100644 --- a/OpenDSS/opendss_tools/configuration.py +++ b/OpenDSS/opendss_tools/configuration.py @@ -1,9 +1,7 @@ """Configuration-domain MCP tools (compile, clear).""" -import os -import tempfile from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict from mcp.server.fastmcp import FastMCP from py_dss_toolkit import dss_tools @@ -64,104 +62,6 @@ def compile_opendss_file(dss_file: str, force_recompile: bool = False) -> Dict[s return _err(str(e)) -def compile_distribution( - path: Optional[str] = None, - content: Optional[str] = None, - source_format: Optional[str] = None, - format: Optional[str] = None, - force_recompile: bool = False, -) -> Dict[str, Any]: - """Compile a distribution case given in any powerio distribution format. - - The on-ramp into OpenDSS for the BMOPF and PowerModelsDistribution worlds: a - feeder held as IEEE BMOPF JSON (``bmopf-json``), PowerModelsDistribution - ENGINEERING JSON (``pmd-json``), or already as OpenDSS ``.dss`` is converted - to a self-contained ``.dss`` file by powerio and compiled here, so a case - authored or solved in those toolchains can run in OpenDSS without a hand - translation. - - Provide exactly one of ``path`` (a file on disk) or ``content`` (inline file - text). ``source_format``/``format`` is the distribution format name - (``dss``, ``pmd-json``, ``bmopf-json``); for a ``path`` it is inferred from - the extension when omitted, but it is REQUIRED for inline ``content``. - - Returns the ``compile_opendss_file`` result plus ``staged_dss_file`` (the - converted ``.dss`` path on disk, or ``None`` when an original DSS path was - compiled directly) and ``conversion_warnings`` (powerio's fidelity notes for - the BMOPF/PMD -> OpenDSS conversion, such as solver metadata OpenDSS cannot - represent). - """ - try: - import powerio.dist as _dist - except ImportError as exc: # pragma: no cover - powerio is a core dependency - return _err(f"powerio is required to convert distribution cases: {exc}") - - if (path is None) == (content is None): - return _err("provide exactly one of `path` or `content`") - source_key = ( - source_format.strip().lower().replace("_", "-") if source_format else None - ) - format_key = format.strip().lower().replace("_", "-") if format else None - if source_key is not None and format_key is not None and source_key != format_key: - return _err("`source_format` and `format` disagree") - source_key = source_key or format_key - if content is not None and not source_key: - return _err("`format` is required when compiling inline `content`") - - direct_dss = ( - path is not None - and ( - source_key in ("dss", "opendss") - or (source_key is None and Path(path).suffix.lower() == ".dss") - ) - ) - if direct_dss: - result = compile_opendss_file(path, force_recompile=force_recompile) - if isinstance(result, dict): - target = ( - result["payload"] if isinstance(result.get("payload"), dict) else result - ) - target["staged_dss_file"] = None - target["distribution_source_file"] = path - target["conversion_warnings"] = [] - return result - - try: - if path is not None: - conv = _dist.convert_file(path, "dss", source_key) - else: - conv = _dist.convert_str(content, "dss", source_key) - except FileNotFoundError as exc: - return _err(f"file not found: {exc}") - except OSError as exc: - return _err(f"cannot read file: {exc}") - except Exception as exc: # powerio.PowerIOError and friends use one error shape - return _err(f"distribution conversion failed: {exc}") - - # newline="" keeps the converter output byte-identical across platforms. - fd, staged = tempfile.mkstemp(suffix=".dss", prefix="powerio_dist_") - try: - with open(fd, "w", encoding="utf-8", newline="") as fh: - fh.write(conv.text) - except OSError as exc: - try: # don't leave a 0-byte temp behind on a failed stage - os.unlink(staged) - except OSError: - pass - return _err(f"failed to stage .dss file: {exc}") - - result = compile_opendss_file(staged, force_recompile=force_recompile) - if isinstance(result, dict): - # Sit beside the rest of the compile result inside the _ok payload. - target = ( - result["payload"] if isinstance(result.get("payload"), dict) else result - ) - target["staged_dss_file"] = staged - target["distribution_source_file"] = path - target["conversion_warnings"] = list(conv.warnings) - return result - - def clear_all_opendss_memory() -> Dict[str, Any]: """Clear OpenDSS engine memory (ClearAll); resets circuit_loaded, solution_available, and last compiled path.""" try: @@ -178,5 +78,4 @@ def clear_all_opendss_memory() -> Dict[str, Any]: def register_configuration_tools(mcp: FastMCP) -> None: mcp.tool()(compile_opendss_file) - mcp.tool()(compile_distribution) mcp.tool()(clear_all_opendss_memory) diff --git a/README.md b/README.md index 4319d1c..9d383cd 100644 --- a/README.md +++ b/README.md @@ -124,11 +124,11 @@ These tools wrap commercial or locally-installed software, so PowerMCP stores th > The Codex *Desktop* app on Windows has been reported to overwrite `~/.codex/config.toml`; the Codex *CLI* is unaffected. If you use both, re-run `powermcp install` after Desktop edits. -### Case conversion between servers (PowerIO) +### Case compilation between servers (PowerIO) -PowerMCP ships a conversion server backed by [powerio](https://github.com/eigenergy/powerio) as a **core dependency** (no extra needed). It parses MATPOWER `.m`, PSS/E `.raw`, PowerWorld `.aux`, PowerModels JSON, and egret JSON into one format neutral network, converts between those formats with fidelity warnings, and builds the sparse matrices solvers need (B', B'', Y_bus, PTDF, LODF, Laplacian, LACPF). +PowerMCP ships a compiler server backed by [powerio](https://github.com/eigenergy/powerio) as a **core dependency** (no extra needed). It parses transmission and distribution formats into canonical JSON transports, converts between target artifacts with fidelity warnings, and builds the sparse matrices solvers need (B', B'', Y_bus, PTDF, LODF, Laplacian, LACPF). -Its JSON transport is the exchange format between PowerMCP servers: parse a case once, pass the returned `json` string between tool calls, and load it anywhere. +Its JSON transport is the exchange format between PowerMCP servers: parse a case once, pass the returned `json` string between tool calls, and save runtime artifacts only when a backend needs a file. ``` parse(path="case9.raw") # powerio server -> {"json": ..., "summary": ...} @@ -136,12 +136,17 @@ load_network_from_json(network_json=...) # pandapower server ingests t load_model_from_json(network_json=...) # egret server stages it as a solvable case file import_case_from_json(network_json=..., output_path="case9.nc") # PyPSA server writes a .nc for its tools matrix(kind="ptdf", json=...) # powerio server builds matrices from it -save(to="psse", out_path="case9.raw", json=...) # stage a file for path only servers +save(to_format="psse", out_path="case9.raw", json=...) # stage a file for path only servers ``` `summary` returns the canonical nested shape used by PowerIO and PowerMCP: counts live under `elements` (`elements.buses`, `elements.branches`, `elements.generators`) and topology metadata lives under `topology` (`topology.connected_components`, `topology.reference_buses`). -`save` covers the servers without a bridge: write the converted case to disk and point their load tools at the file (e.g. convert PowerWorld `.aux` to MATPOWER `.m` for ANDES). +`save` covers the servers without a bridge: write the converted case to disk and point their load tools at the file. For OpenDSS, save a distribution transport as DSS, then compile that DSS file: + +``` +save(to_format="dss", out_path="feeder.dss", json=..., json_format="bmopf-json") +compile_opendss_file(dss_file="feeder.dss") +``` PowerWorld `.pwd` display files decode separately via `display(path=...)`, which returns the diagram canvas and each substation's display coordinates. The display geometry is distinct from the `.pwb`/`.aux` case data. diff --git a/powerio/powerio_mcp.py b/powerio/powerio_mcp.py index 0fd0f53..3e21c68 100644 --- a/powerio/powerio_mcp.py +++ b/powerio/powerio_mcp.py @@ -2,21 +2,19 @@ ``powerio.mcp.server`` that ships with the powerio package. powerio is a core dependency (see ``powermcp.registry`` / ``pyproject.toml``), so -this server keeps no copy of the conversion/summary/matrix tools. As of powerio -0.3.3 the canonical MCP surface is the bare powerio verbs, one set for -transmission and distribution (routed by format): - -- ``convert`` / ``save`` / ``summary``: generic format and transport verbs. - ``save(to="dss")`` stages an IEEE BMOPF or PowerModelsDistribution case as a - ``.dss`` file the OpenDSS server can compile (see its - ``compile_distribution`` tool); ``save(to="pypsa-csv")`` writes the CSV folder. -- ``parse`` / ``normalize``: JSON transport for parsed or normalized networks. +this server keeps no copy of the conversion/summary/matrix tools. PowerIO is the +cross-server compiler layer for transmission and distribution cases: + +- ``parse``: source format to canonical JSON transport. +- ``convert`` / ``save``: canonical transport or source format to target artifact. +- ``summary``: canonical network summary. +- ``normalize``: normalized transmission transport. - ``matrix``: sparse transmission matrix outputs. - ``display``: display artifacts such as PowerWorld ``.pwd`` geometry. PowerMCP deliberately re-exports only those canonical tools. GridFM and PyPSA -folders route through ``parse`` and ``save`` with format names, and display -artifacts route through ``display``. +folders route through ``parse`` and ``save`` with format names. OpenDSS compiles +DSS files produced by PowerIO when a distribution case needs that runtime. Run over stdio with ``python powerio_mcp.py`` (or ``powermcp run powerio``). """ diff --git a/tests/test_powerio_server.py b/tests/test_powerio_server.py index 737ea2a..1d4853c 100644 --- a/tests/test_powerio_server.py +++ b/tests/test_powerio_server.py @@ -47,6 +47,7 @@ ACTIVSG200_PWD = ( Path(__file__).resolve().parent / "data" / "powerworld" / "ACTIVSg200.pwd" ) +MINIMAL_BMOPF = '{"bus":{"a":{"terminal_names":["1"]}}}' # 3-bus case with rating 0 branches, for the overwrite_zero_s_nom tests. ZERO_RATE_CASE = """function mpc = zero_rate @@ -75,7 +76,8 @@ def test_parse_json_round_trips(): def test_tool_surface_is_canonical(): - names = {tool.name for tool in asyncio.run(powerio_mcp.mcp.list_tools())} + tools = {tool.name: tool for tool in asyncio.run(powerio_mcp.mcp.list_tools())} + names = set(tools) assert names == { "convert", "save", @@ -85,6 +87,18 @@ def test_tool_surface_is_canonical(): "matrix", "display", } + for name in ("parse", "summary", "normalize", "matrix", "display"): + props = tools[name].inputSchema["properties"] + assert "from_format" in props + assert "format" not in props + convert_props = tools["convert"].inputSchema["properties"] + assert "to_format" in convert_props and "from_format" in convert_props + assert "to" not in convert_props and "format" not in convert_props + save_schema = tools["save"].inputSchema + assert save_schema["required"] == ["out_path"] + save_props = save_schema["properties"] + assert "to_format" in save_props and "from_format" in save_props + assert "to" not in save_props and "format" not in save_props def test_normalize_returns_dense_one_based_ids(): @@ -124,7 +138,7 @@ def test_matrix_unknown_kind(): def test_convert_powermodels(): - r = powerio_mcp.convert(to="powermodels-json", path=str(CASE9)) + r = powerio_mcp.convert(to_format="powermodels-json", path=str(CASE9)) assert isinstance(r["warnings"], list) assert len(json.loads(r["text"])["bus"]) == 9 @@ -151,7 +165,7 @@ def test_exactly_one_input_enforced(): def test_inline_matpower_content_defaults_to_matpower(): - assert powerio_mcp.convert(to="psse", content=CASE9.read_text())["text"] + assert powerio_mcp.convert(to_format="psse", content=CASE9.read_text())["text"] def test_matrix_lacpf(): @@ -165,7 +179,9 @@ def test_matrix_lacpf(): def test_save_writes_file(tmp_path): out = tmp_path / "case9.json" - r = powerio_mcp.save(to="powermodels-json", out_path=str(out), path=str(CASE9)) + r = powerio_mcp.save( + to_format="powermodels-json", out_path=str(out), path=str(CASE9) + ) assert r["path"] == str(out) assert r["bytes_written"] == out.stat().st_size assert isinstance(r["warnings"], list) @@ -176,9 +192,9 @@ def test_save_refuses_overwrite(tmp_path): out = tmp_path / "case9.m" out.write_text("existing") with pytest.raises(ValueError, match="overwrite"): - powerio_mcp.save(to="matpower", out_path=str(out), path=str(CASE9)) + powerio_mcp.save(out_path=str(out), path=str(CASE9)) r = powerio_mcp.save( - to="matpower", out_path=str(out), path=str(CASE9), overwrite=True + out_path=str(out), path=str(CASE9), overwrite=True ) assert r["bytes_written"] == out.stat().st_size @@ -186,16 +202,16 @@ def test_save_refuses_overwrite(tmp_path): def test_save_accepts_json_transport(tmp_path): transport = powerio_mcp.parse(path=str(CASE9))["json"] out = tmp_path / "case9.m" - powerio_mcp.save(to="matpower", out_path=str(out), json=transport) + powerio_mcp.save(out_path=str(out), json=transport) assert powerio.parse_file(out).n_buses == 9 def test_save_exactly_one_input(tmp_path): out = tmp_path / "x.m" with pytest.raises(ValueError): - powerio_mcp.save(to="matpower", out_path=str(out)) + powerio_mcp.save(out_path=str(out)) with pytest.raises(ValueError): - powerio_mcp.save(to="matpower", out_path=str(out), path="a", json="{}") + powerio_mcp.save(out_path=str(out), path="a", json="{}") def test_pypsa_import_case_from_any(tmp_path): @@ -280,7 +296,9 @@ def boom(*args, **kwargs): monkeypatch.setattr(tempfile, "mkstemp", boom) monkeypatch.setattr(tempfile, "NamedTemporaryFile", boom) - r = powerio_mcp.convert(to="psse", content=CASE9.read_text(), format="matpower") + r = powerio_mcp.convert( + to_format="psse", content=CASE9.read_text(), from_format="matpower" + ) assert r["text"] @@ -358,7 +376,7 @@ def boom(content, to, from_): monkeypatch.setattr(powerio, "convert_str", boom) with pytest.raises(ValueError): - powerio_mcp.convert(to="psse", content="x", format="matpower") + powerio_mcp.convert(to_format="psse", content="x", from_format="matpower") def test_unreadable_file_maps_cleanly(tmp_path): @@ -374,7 +392,7 @@ def test_unreadable_file_maps_cleanly(tmp_path): locked.chmod(0o000) try: with pytest.raises(ValueError, match="cannot read input"): - powerio_mcp.convert(to="psse", path=str(locked)) + powerio_mcp.convert(to_format="psse", path=str(locked)) with pytest.raises(ValueError, match="cannot read input"): powerio_mcp.summary(path=str(locked)) finally: @@ -436,7 +454,7 @@ def test_andes_load_missing_file(tmp_path): # --------------------------------------------------------------------------- def test_convert_to_pandapower_json(): - r = powerio_mcp.convert(to="pandapower-json", path=str(CASE9)) + r = powerio_mcp.convert(to_format="pandapower-json", path=str(CASE9)) assert r["text"] assert json.loads(r["text"]) # well-formed JSON @@ -446,19 +464,21 @@ def test_pandapower_json_round_trips_through_transport(): # save/parse tools with no dedicated tool. transport = powerio_mcp.parse(path=str(CASE9))["json"] out = powerio_mcp.parse( - content=powerio_mcp.convert(to="pandapower-json", path=str(CASE9))["text"], - format="pandapower-json", + content=powerio_mcp.convert(to_format="pandapower-json", path=str(CASE9))[ + "text" + ], + from_format="pandapower-json", ) assert json.loads(out["json"]) assert json.loads(transport) def test_pypsa_csv_folder_round_trip(tmp_path): - # pypsa-csv is a directory format: write through save(to="pypsa-csv"), read + # pypsa-csv is a directory format: write through save(to_format="pypsa-csv"), read # back through parse via a folder path (powerio 0.3.3 folded the dedicated # read/write_pypsa_csv_folder tools into the bare verbs). out_dir = tmp_path / "pypsa_csv" - w = powerio_mcp.save(to="pypsa-csv", out_path=str(out_dir), path=str(CASE9)) + w = powerio_mcp.save(to_format="pypsa-csv", out_path=str(out_dir), path=str(CASE9)) assert w["files"], w assert (out_dir / "buses.csv").exists() r = powerio_mcp.parse(path=str(out_dir)) @@ -469,7 +489,7 @@ def test_pypsa_csv_folder_round_trip(tmp_path): def test_pypsa_csv_folder_accepts_transport(tmp_path): transport = powerio_mcp.parse(path=str(CASE9))["json"] out_dir = tmp_path / "from_json" - w = powerio_mcp.save(to="pypsa-csv", out_path=str(out_dir), json=transport) + w = powerio_mcp.save(to_format="pypsa-csv", out_path=str(out_dir), json=transport) assert (out_dir / "generators.csv").exists(), w @@ -480,16 +500,18 @@ def test_read_pypsa_csv_missing_folder_maps_cleanly(tmp_path): def test_gridfm_round_trip(tmp_path): out_dir = tmp_path / "gfm" - w = powerio_mcp.save(to="gridfm", out_path=str(out_dir), path=str(CASE9)) + w = powerio_mcp.save(to_format="gridfm", out_path=str(out_dir), path=str(CASE9)) assert w["files"], w - r = powerio_mcp.parse(path=str(out_dir), format="gridfm", options={"scenario": 0}) + r = powerio_mcp.parse( + path=str(out_dir), from_format="gridfm", options={"scenario": 0} + ) assert r["summary"]["elements"]["buses"] == 9 assert json.loads(r["json"]) def test_gridfm_missing_dir_maps_cleanly(tmp_path): with pytest.raises(ValueError): - powerio_mcp.parse(path=str(tmp_path / "nope"), format="gridfm") + powerio_mcp.parse(path=str(tmp_path / "nope"), from_format="gridfm") # --------------------------------------------------------------------------- @@ -525,7 +547,7 @@ def test_read_display_garbage_file_maps_cleanly(tmp_path): # --------------------------------------------------------------------------- -# OpenDSS distribution bridge +# OpenDSS consumes DSS files produced by PowerIO. # --------------------------------------------------------------------------- def _load_opendss_configuration(monkeypatch): @@ -556,66 +578,30 @@ def _load_opendss_configuration(monkeypatch): return importlib.import_module("opendss_tools.configuration") -def test_compile_distribution_compiles_dss_path_directly(monkeypatch, tmp_path): +def test_opendss_registration_excludes_distribution_wrapper(monkeypatch): configuration = _load_opendss_configuration(monkeypatch) - dss_file = tmp_path / "master.dss" - dss_file.write_text("Redirect feeders/loads.dss\n") - calls = [] - - def fake_compile(path, force_recompile=False): - calls.append((path, force_recompile)) - return {"success": True, "payload": {"dss_file": path}} + from mcp.server.fastmcp import FastMCP - monkeypatch.setattr(configuration, "compile_opendss_file", fake_compile) + mcp = FastMCP("opendss-test") + configuration.register_configuration_tools(mcp) + names = {tool.name for tool in asyncio.run(mcp.list_tools())} + assert "compile_opendss_file" in names + assert "clear_all_opendss_memory" in names + assert "compile_distribution" not in names - result = configuration.compile_distribution( - path=str(dss_file), - source_format="opendss", - force_recompile=True, - ) - assert calls == [(str(dss_file), True)] - assert result["success"] is True - assert result["payload"]["staged_dss_file"] is None - assert result["payload"]["distribution_source_file"] == str(dss_file) - assert result["payload"]["conversion_warnings"] == [] - - -def test_compile_distribution_stages_converted_json(monkeypatch, tmp_path): +def test_powerio_to_opendss_composition(monkeypatch, tmp_path): configuration = _load_opendss_configuration(monkeypatch) - source = tmp_path / "network.json" - source.write_text('{"bus": {}}') - convert_calls = [] - compile_calls = [] - - class Conversion: - text = "Redirect feeders/loads.dss\n" - warnings = ["dropped solver metadata"] - - import powerio.dist as dist - - def fake_convert_file(path, to, source_format): - convert_calls.append((path, to, source_format)) - return Conversion() - - def fake_compile(path, force_recompile=False): - compile_calls.append((Path(path), force_recompile)) - assert Path(path).read_text() == Conversion.text - return {"success": True, "payload": {"dss_file": path}} - - monkeypatch.setattr(dist, "convert_file", fake_convert_file) - monkeypatch.setattr(configuration, "compile_opendss_file", fake_compile) - result = configuration.compile_distribution( - path=str(source), - source_format="bmopf_json", - format="bmopf-json", + dss_path = tmp_path / "feeder.dss" + save_result = powerio_mcp.save( + out_path=str(dss_path), + json=MINIMAL_BMOPF, + json_format="bmopf-json", ) + assert save_result["path"] == str(dss_path) + assert dss_path.exists() - assert convert_calls == [(str(source), "dss", "bmopf-json")] - assert len(compile_calls) == 1 - assert compile_calls[0][0].name.startswith("powerio_dist_") + result = configuration.compile_opendss_file(str(dss_path)) assert result["success"] is True - assert result["payload"]["staged_dss_file"] == str(compile_calls[0][0]) - assert result["payload"]["distribution_source_file"] == str(source) - assert result["payload"]["conversion_warnings"] == Conversion.warnings + assert result["payload"]["dss_file"] == str(dss_path) From 0881009472b5ba523fce83b9018baf4c14edfbaf Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:40:02 -0400 Subject: [PATCH 5/5] Assert PowerIO MCP schema markers --- pandapower/panda_mcp.py | 2 +- tests/test_powerio_server.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/pandapower/panda_mcp.py b/pandapower/panda_mcp.py index 2855655..ff90ddc 100644 --- a/pandapower/panda_mcp.py +++ b/pandapower/panda_mcp.py @@ -421,7 +421,7 @@ def load_network_from_json(network_json: str) -> Dict[str, Any]: Accepts the `json` string returned by the powerio server's parse tool, so a case parsed once there loads here without passing a file around or re-parsing it. Expects source-valued tables (MW, degrees) - as parse emits them, not the per-unit normalize form. Replaces + as parse emits them, not the per-unit normalize form. Replaces the currently loaded network. powerio is a core dependency, so this is always available. diff --git a/tests/test_powerio_server.py b/tests/test_powerio_server.py index 1d4853c..f1db6c2 100644 --- a/tests/test_powerio_server.py +++ b/tests/test_powerio_server.py @@ -70,7 +70,13 @@ def test_parse_json_round_trips(): r = powerio_mcp.parse(path=str(CASE9)) + assert r["schema"] == "powerio.parse" + assert r["schema_version"] == "0.1" + assert r["domain"] == "transmission" + assert r["model"] == "balanced" assert r["json_format"] == "powerio-json" + assert r["source_format"] == "Matpower" + assert isinstance(r["warnings"], list) assert r["summary"]["elements"]["buses"] == 9 assert powerio.from_json(r["json"]).n_buses == 9 @@ -114,6 +120,13 @@ def test_parse_transport_accepted_downstream(): def test_matrix_bprime(): m = powerio_mcp.matrix("bprime", path=str(CASE9)) + assert m["schema"] == "powerio.matrix" + assert m["schema_version"] == "0.1" + assert m["domain"] == "transmission" + assert m["model"] == "balanced" + assert m["json_format"] == "powerio-json" + assert m["source_format"] == "Matpower" + assert isinstance(m["warnings"], list) assert m["format"] == "coo" assert m["shape"] == [9, 9] assert m["nnz"] > 0 @@ -145,6 +158,12 @@ def test_convert_powermodels(): def test_summary_fields(): s = powerio_mcp.summary(path=str(CASE9)) + assert s["schema"] == "powerio.summary" + assert s["schema_version"] == "0.1" + assert s["domain"] == "transmission" + assert s["model"] == "balanced" + assert s["json_format"] == "powerio-json" + assert isinstance(s["warnings"], list) assert s["elements"]["buses"] == 9 assert s["base_mva"] == 100.0 assert s["source_format"] == "Matpower" @@ -521,7 +540,10 @@ def test_gridfm_missing_dir_maps_cleanly(tmp_path): def test_display_decodes_pwd(): r = powerio_mcp.display(str(ACTIVSG200_PWD)) + assert r["schema"] == "powerio.display" + assert r["schema_version"] == "0.1" assert r["domain"] == "display" + assert r["model"] == "display" assert r["source_format"] == "powerworld-pwd" assert r["canvas"]["width"] > 0 and r["canvas"]["height"] > 0 subs = r["substations"]