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 diff --git a/ANDES/andes_mcp.py b/ANDES/andes_mcp.py index 5c81321..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_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 + 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 bfcdf28..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_case or - case_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/PyPSA/pypsa_mcp.py b/PyPSA/pypsa_mcp.py index 1455034..782254a 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 - 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 + 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 always available. @@ -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..9d383cd 100644 --- a/README.md +++ b/README.md @@ -124,24 +124,33 @@ 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_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_format="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. 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. + +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 573ad3d..ff90ddc 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_case / case_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,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 - 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 + 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 888666c..3e21c68 100644 --- a/powerio/powerio_mcp.py +++ b/powerio/powerio_mcp.py @@ -1,88 +1,47 @@ -"""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. +this server keeps no copy of the conversion/summary/matrix tools. PowerIO is the +cross-server compiler layer for transmission and distribution cases: -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. +- ``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. 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``). """ 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, - compute_matrix, - dense_view, - read_pypsa_csv_folder, - write_pypsa_csv_folder, - read_gridfm, - write_gridfm, -) - - -@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 - ], - } +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 a9bbad6..8ef659e 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,10 @@ 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 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", "questionary>=2.0", diff --git a/tests/test_powerio_server.py b/tests/test_powerio_server.py index 37cca9a..f1db6c2 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 @@ -44,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 @@ -64,25 +68,65 @@ """ -def test_parse_case_json_round_trips(): - r = powerio_mcp.parse_case(path=str(CASE9)) - assert r["summary"]["n_buses"] == 9 +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 -def test_normalize_case_returns_dense_one_based_ids(): - r = powerio_mcp.normalize_case(path=str(CASE9)) +def test_tool_surface_is_canonical(): + tools = {tool.name: tool for tool in asyncio.run(powerio_mcp.mcp.list_tools())} + names = set(tools) + assert names == { + "convert", + "save", + "summary", + "parse", + "normalize", + "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(): + 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_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["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 @@ -93,68 +137,58 @@ def test_compute_matrix_bprime(): assert type(m["col"][0]) is int -def test_compute_matrix_accepts_json_transport(): - transport = powerio_mcp.parse_case(path=str(CASE9))["json"] - from_json = powerio_mcp.compute_matrix("bprime", json=transport) - from_path = powerio_mcp.compute_matrix("bprime", path=str(CASE9)) +def test_matrix_accepts_json_transport(): + transport = powerio_mcp.parse(path=str(CASE9))["json"] + 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)) - + powerio_mcp.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 - -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_format="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)) - assert s["n_buses"] == 9 +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" - 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(): 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") + powerio_mcp.matrix("bprime") with pytest.raises(ValueError): - powerio_mcp.compute_matrix("bprime", path=str(CASE9), json="{}") - 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_case(to="psse", content=CASE9.read_text()) +def test_inline_matpower_content_defaults_to_matpower(): + assert powerio_mcp.convert(to_format="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 @@ -162,39 +196,41 @@ 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_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) 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( - to="matpower", out_path=str(out), path=str(CASE9), overwrite=True + powerio_mcp.save(out_path=str(out), path=str(CASE9)) + r = powerio_mcp.save( + 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(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(out_path=str(out)) with pytest.raises(ValueError): - powerio_mcp.save_case(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): @@ -208,7 +244,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 +315,9 @@ 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_format="psse", content=CASE9.read_text(), from_format="matpower" + ) assert r["text"] @@ -338,18 +376,18 @@ 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_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 +395,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_format="psse", content="x", from_format="matpower") def test_unreadable_file_maps_cleanly(tmp_path): @@ -372,10 +410,10 @@ 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"): - powerio_mcp.convert_case(to="psse", path=str(locked)) - with pytest.raises(ValueError, match="cannot read file"): - powerio_mcp.case_summary(path=str(locked)) + with pytest.raises(ValueError, match="cannot read input"): + powerio_mcp.convert(to_format="psse", path=str(locked)) + with pytest.raises(ValueError, match="cannot read input"): + powerio_mcp.summary(path=str(locked)) finally: locked.chmod(0o644) @@ -385,7 +423,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") # --------------------------------------------------------------------------- @@ -415,7 +453,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 @@ -431,74 +469,83 @@ 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(): - r = powerio_mcp.convert_case(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 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"], - format="pandapower-json") + # save/parse tools with no dedicated tool. + transport = powerio_mcp.parse(path=str(CASE9))["json"] + out = powerio_mcp.parse( + 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_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.write_pypsa_csv_folder(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.read_pypsa_csv_folder(str(out_dir)) - assert r["summary"]["n_buses"] == 9 + r = powerio_mcp.parse(path=str(out_dir)) + assert r["summary"]["elements"]["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_format="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): out_dir = tmp_path / "gfm" - w = powerio_mcp.write_gridfm(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.read_gridfm(str(out_dir)) - assert r["summary"]["n_buses"] == 9 - assert r["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_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"), from_format="gridfm") # --------------------------------------------------------------------------- -# 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. 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["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"] assert subs, "expected at least one substation" assert all(set(s) == {"number", "name", "x", "y"} for s in subs) @@ -511,11 +558,72 @@ 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 consumes DSS files produced by PowerIO. +# --------------------------------------------------------------------------- + +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_opendss_registration_excludes_distribution_wrapper(monkeypatch): + configuration = _load_opendss_configuration(monkeypatch) + from mcp.server.fastmcp import FastMCP + + 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 + + +def test_powerio_to_opendss_composition(monkeypatch, tmp_path): + configuration = _load_opendss_configuration(monkeypatch) + + 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() + + result = configuration.compile_opendss_file(str(dss_path)) + assert result["success"] is True + assert result["payload"]["dss_file"] == str(dss_path)