diff --git a/.gitignore b/.gitignore index 9945080..f99b1ba 100644 --- a/.gitignore +++ b/.gitignore @@ -96,7 +96,9 @@ eig_*/ # PowerWorld *.pwd -# Note: .pwb files might be case files you want to keep (like IEEE 39 bus.pwb), +# ...but keep the vendored .pwd display fixture the powerio tests decode. +!tests/data/powerworld/ACTIVSg200.pwd +# Note: .pwb files might be case files you want to keep (like IEEE 39 bus.pwb), # so we don't ignore them globally. # Testing diff --git a/README.md b/README.md index f9f0f0d..1769e25 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ save_case(to="psse", out_path="case9.raw", json=...) # stage a file for path-on `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). +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. + ### Running from a clone (without installing) Every server is still a standalone script. Clone the repo and run any server directly for use in Claude Desktop: diff --git a/powerio/powerio_mcp.py b/powerio/powerio_mcp.py index 65fe5df..888666c 100644 --- a/powerio/powerio_mcp.py +++ b/powerio/powerio_mcp.py @@ -2,19 +2,21 @@ ``powerio.mcp.server`` that ships with the powerio package. powerio is a core dependency (see ``powermcp.registry`` / ``pyproject.toml``), so -the standalone server no longer keeps its own copy of the conversion/summary/ -matrix tools: it re-exports the canonical FastMCP ``mcp`` instance and every -tool registered on it, and adds the handful of folder/Parquet tools that are not -yet upstream. This keeps the eight text-format tools (``convert_case``, -``save_case``, ``case_summary``, ``parse_case``, ``normalize_case``, -``case_to_json``, ``compute_matrix``, ``dense_view``) in lockstep with powerio -with zero divergence to hand-sync. - -The overlay tools below (``read_pypsa_csv_folder`` / ``write_pypsa_csv_folder`` -and ``read_gridfm`` / ``write_gridfm``) wrap powerio library functions that the -canonical server does not expose as MCP tools yet. They should migrate into -``powerio.mcp.server`` upstream; once a powerio release includes them, delete -them here and this module becomes a pure re-export. +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. Run over stdio with ``python powerio_mcp.py`` (or ``powermcp run powerio``). """ @@ -37,147 +39,51 @@ case_to_json, compute_matrix, dense_view, - # Private helpers reused by the overlay tools below so they share the exact - # input-resolution and summary shape of the canonical tools. Pinned to a - # powerio version that exports them (see pyproject's powerio requirement). - _load, - _summary, + read_pypsa_csv_folder, + write_pypsa_csv_folder, + read_gridfm, + write_gridfm, ) @mcp.tool() -def read_pypsa_csv_folder(folder: str) -> dict: - """Read a PyPSA static CSV folder into the JSON transport plus a summary. +def read_display_file(path: str) -> dict: + """Decode a PowerWorld ``.pwd`` display file into canvas + substation layout. - ``folder`` is a directory of PyPSA component CSVs (``buses.csv``, - ``generators.csv``, ``lines.csv``, ...). PyPSA CSV is a folder format with - no single-file text form, so it can't go through ``parse_case`` / - ``convert_case``; use this to bring such a dataset into the transport, then - pass the returned ``json`` to any other tool. + 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 ``{"json": , "summary": , - "warnings": []}``. + Returns ``{"kind": "powerworld", "canvas_width": , + "canvas_height": , "stamp": , "substations": + [{"number": , "name": , "x": , "y": }, ...]}``. """ try: - case = powerio.read_pypsa_csv_folder(folder) + 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 folder: {exc}") from exc - return { - "json": case.to_json(), - "summary": _summary(case), - "warnings": list(getattr(case, "read_warnings", []) or []), - } - - -@mcp.tool() -def write_pypsa_csv_folder( - out_dir: str, - path: "str | None" = None, - content: "str | None" = None, - json: "str | None" = None, - format: str = "matpower", -) -> dict: - """Write a case out as a PyPSA static CSV folder. - - Converts any case — a file ``path``, inline ``content`` (with ``format``), - or the ``json`` transport from ``parse_case`` — to PyPSA's CSV component - tables under ``out_dir`` (created if needed). This is the PyPSA-CSV - counterpart of ``save_case`` for the folder format. - - Returns ``{"dir": , "files": [], - "warnings": []}``. - """ - case = _load(path, content, json, format) - try: - result = case.write_pypsa_csv_folder(out_dir) - except powerio.PowerIOError as exc: - raise ValueError(f"conversion failed: {exc}") from exc - except OSError as exc: - raise ValueError(f"write failed: {exc}") from 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 { - "dir": result.get("dir", out_dir), - "files": list(result.get("files", [])), - "warnings": list(result.get("warnings", [])), + "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 + ], } -@mcp.tool() -def read_gridfm(dir: str, scenario: int = 0) -> dict: - """Read one scenario of a gridfm-datakit Parquet dataset into the transport. - - ``dir`` is resolved leniently: the ``raw/`` directory holding the parquet - files, a ``/`` directory with a ``raw/`` child, or a parent with one - ``*/raw/`` child all work. ``scenario`` selects one snapshot from a batch - (``0``, the base case, by default). The read is lossy but recovers - everything a power flow needs; what it can't recover is in ``warnings``. - - Returns ``{"json": , "summary": , - "scenario": , "warnings": []}``. Requires a powerio - build with the native gridfm reader (published wheels include it). - """ - try: - result = powerio.read_gridfm(dir, scenario) - 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 ImportError as exc: - raise ValueError(str(exc)) from exc - except OSError as exc: - raise ValueError(f"cannot read dataset: {exc}") from exc - case = result.network - return { - "json": case.to_json(), - "summary": _summary(case), - "scenario": int(result.scenario), - "warnings": list(result.warnings), - } - - -@mcp.tool() -def write_gridfm( - out_dir: str, - path: "str | None" = None, - content: "str | None" = None, - json: "str | None" = None, - format: str = "matpower", - scenario: int = 0, - include_y_bus: bool = True, - include_taps: bool = True, - include_shifts: bool = True, -) -> dict: - """Write a case as a gridfm-datakit Parquet dataset under ``out_dir``. - - Converts any case — a file ``path``, inline ``content`` (with ``format``), - or the ``json`` transport — and writes the gridfm layout - (``/raw/*.parquet`` plus ``gridfm_meta.json``). ``scenario`` tags the - snapshot id; the ``include_*`` flags toggle the Y-bus, tap, and shift - columns. - - Returns the writer's report ``{"dir": ..., "files": [...], ...}``. Requires - a powerio build with the native gridfm writer (published wheels include it). - """ - case = _load(path, content, json, format) - try: - result = case.write_gridfm( - out_dir, - scenario, - include_y_bus=include_y_bus, - include_taps=include_taps, - include_shifts=include_shifts, - ) - except powerio.PowerIOError as exc: - raise ValueError(f"conversion failed: {exc}") from exc - except ImportError as exc: - raise ValueError(str(exc)) from exc - except OSError as exc: - raise ValueError(f"write failed: {exc}") from exc - return dict(result) - - if __name__ == "__main__": mcp.run(transport="stdio") diff --git a/pyproject.toml b/pyproject.toml index a5f81fd..61bdf2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,9 +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.1: powerio/powerio_mcp.py re-exports the canonical powerio.mcp.server - # (and its _load/_summary helpers) rather than vendoring a copy. - "powerio[mcp,matrix]>=0.2.1", + # >=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", # CLI / installer toolkit: "typer>=0.12", "questionary>=2.0", diff --git a/tests/data/powerworld/ACTIVSg200.pwd b/tests/data/powerworld/ACTIVSg200.pwd new file mode 100644 index 0000000..3d3fea4 Binary files /dev/null and b/tests/data/powerworld/ACTIVSg200.pwd differ diff --git a/tests/test_powerio_server.py b/tests/test_powerio_server.py index fc451c2..37cca9a 100644 --- a/tests/test_powerio_server.py +++ b/tests/test_powerio_server.py @@ -9,6 +9,8 @@ tests/data/case9.m is vendored verbatim from https://github.com/MATPOWER/matpower/tree/master/data (BSD-3). +tests/data/powerworld/ACTIVSg200.pwd is vendored from powerio's test suite +(eigenergy/powerio); ACTIVSg200 is a public Texas A&M synthetic grid. """ from __future__ import annotations @@ -19,7 +21,7 @@ import pytest -pytest.importorskip("powerio") +pytest.importorskip("powerio", minversion="0.2.2") import powerio # noqa: E402 @@ -39,6 +41,9 @@ import pypsa_mcp # noqa: E402 CASE9 = Path(__file__).resolve().parent / "data" / "case9.m" +ACTIVSG200_PWD = ( + Path(__file__).resolve().parent / "data" / "powerworld" / "ACTIVSg200.pwd" +) # 3-bus case with rating 0 branches, for the overwrite_zero_s_nom tests. ZERO_RATE_CASE = """function mpc = zero_rate @@ -482,3 +487,35 @@ def test_gridfm_round_trip(tmp_path): def test_read_gridfm_missing_dir_maps_cleanly(tmp_path): with pytest.raises(ValueError): powerio_mcp.read_gridfm(str(tmp_path / "nope")) + + +# --------------------------------------------------------------------------- +# 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. +# --------------------------------------------------------------------------- + +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 + subs = r["substations"] + assert subs, "expected at least one substation" + assert all(set(s) == {"number", "name", "x", "y"} for s in subs) + assert any(s["name"] for s in subs) + assert all( + isinstance(s["x"], (int, float)) and isinstance(s["y"], (int, float)) + for s in subs + ) + + +def test_read_display_missing_file_maps_cleanly(tmp_path): + with pytest.raises(ValueError): + powerio_mcp.read_display_file(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))