From 69ff7e50ba6b04161dd6f886f381a6a65da5f51b Mon Sep 17 00:00:00 2001 From: Qian Zhang Date: Sun, 14 Jun 2026 14:24:40 -0400 Subject: [PATCH] feat(powerio): expose pandapower-json, PyPSA CSV, and gridfm Parquet formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit powerio 0.1.1 adds pandapower JSON (a text format, alias `pp`) plus the PyPSA static CSV folder and gridfm-datakit Parquet formats, which are folder/binary and don't fit the single-file convert_case/save_case shape. - Document pandapower-json/`pp` in convert_case, save_case, parse_case, and the module header — it already converted through the text pass-through, only the advertised format lists were stale. - Add read_pypsa_csv_folder / write_pypsa_csv_folder for the PyPSA CSV folder format, and read_gridfm / write_gridfm for the gridfm Parquet datasets; both bridge to/from the JSON transport like the existing tools. - Cover all three with round-trip and error-mapping tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- powerio/powerio_mcp.py | 167 ++++++++++++++++++++++++++++++++--- tests/test_powerio_server.py | 59 +++++++++++++ 2 files changed, 216 insertions(+), 10 deletions(-) diff --git a/powerio/powerio_mcp.py b/powerio/powerio_mcp.py index 3d8650f..595df33 100644 --- a/powerio/powerio_mcp.py +++ b/powerio/powerio_mcp.py @@ -15,6 +15,15 @@ transport (``Network.to_json``), the cheap handoff between tool calls. - ``compute_matrix``: the sparse matrix views in COO form as plain lists. - ``dense_view``: the dense table view as plain lists and dicts. +- ``read_pypsa_csv_folder`` / ``write_pypsa_csv_folder``: the PyPSA static CSV + folder format, which has no single-file text form. +- ``read_gridfm`` / ``write_gridfm``: the gridfm-datakit Parquet datasets. + +The single-file/text formats are ``matpower`` (``m``), ``powermodels-json`` +(``pm``), ``egret-json`` (``egret``), ``pandapower-json`` (``pp``), ``psse`` +(``raw``), and ``powerworld`` (``aux``); these flow through ``convert_case`` / +``save_case`` and the parse tools. PyPSA CSV and gridfm Parquet are folder / +binary formats, so they get their own read/write tools above. Run over stdio with the ``powerio-mcp`` console script (or ``python -m powerio.mcp``). The server is a thin wrapper over the powerio Python API; it @@ -23,7 +32,8 @@ This file is the standalone copy of the canonical ``powerio.mcp.server`` (``python/powerio/mcp/server.py`` in eigenergy/powerio); land changes there -first and sync them here. Requires powerio >= 0.1.0 (convert_str). +first and sync them here. Requires powerio >= 0.1.1 (pandapower-json, the PyPSA +CSV folder, and the gridfm Parquet readers/writers). """ from __future__ import annotations @@ -120,9 +130,11 @@ def convert_case( Provide exactly one of ``path`` (a file on disk) or ``content`` (inline file text). ``to``/``from_`` are format names or aliases: ``matpower`` (``m``), - ``powermodels-json`` (``pm``), ``egret-json`` (``egret``), ``psse`` - (``raw``), ``powerworld`` (``aux``). The input format is inferred from the - file extension for ``path``; ``from_`` is REQUIRED with inline ``content``. + ``powermodels-json`` (``pm``), ``egret-json`` (``egret``), + ``pandapower-json`` (``pp``), ``psse`` (``raw``), ``powerworld`` (``aux``). + The input format is inferred from the file extension for ``path``; ``from_`` + is REQUIRED with inline ``content``. (PyPSA CSV folders and gridfm Parquet + are not single files — use their dedicated read/write tools.) Returns ``{"text": , "warnings": [, "bytes_written": , "warnings": []}``. @@ -224,8 +237,8 @@ def parse_case( Provide exactly one of ``path`` or ``content``. For inline ``content``, ``format`` names the input format (default ``matpower``); formats: - ``matpower``, ``powermodels-json``, ``egret-json``, ``psse``, - ``powerworld``. + ``matpower``, ``powermodels-json``, ``egret-json``, ``pandapower-json``, + ``psse``, ``powerworld``. The returned ``json`` string is the exchange format between tool calls: pass it to ``compute_matrix``, ``dense_view``, and ``save_case`` here, or @@ -407,5 +420,139 @@ def dense_view( } +@mcp.tool() +def read_pypsa_csv_folder(folder: str) -> dict: + """Read a PyPSA static CSV folder into the JSON transport plus a summary. + + ``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. + + Returns ``{"json": , "summary": , + "warnings": []}``. + """ + try: + case = powerio.read_pypsa_csv_folder(folder) + 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: Optional[str] = None, + content: Optional[str] = None, + json: Optional[str] = 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 + return { + "dir": result.get("dir", os.path.abspath(out_dir)), + "files": list(result.get("files", [])), + "warnings": list(result.get("warnings", [])), + } + + +@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: Optional[str] = None, + content: Optional[str] = None, + json: Optional[str] = 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/tests/test_powerio_server.py b/tests/test_powerio_server.py index e17c2f5..f3754f9 100644 --- a/tests/test_powerio_server.py +++ b/tests/test_powerio_server.py @@ -422,3 +422,62 @@ def test_andes_load_missing_file(tmp_path): r = andes_mcp.load_network_from_any("/nope/missing.m", str(tmp_path / "x.m")) assert r["status"] == "error" assert "not found" in r["message"].lower() + + +# --------------------------------------------------------------------------- +# pandapower-json (a text format added in powerio 0.1.1) and the folder / +# Parquet formats that need their own read/write tools. +# --------------------------------------------------------------------------- + +def test_convert_to_pandapower_json(): + r = powerio_mcp.convert_case(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"], + format="pandapower-json") + assert json.loads(out["json"]) + assert json.loads(transport) + + +def test_pypsa_csv_folder_round_trip(tmp_path): + out_dir = tmp_path / "pypsa_csv" + w = powerio_mcp.write_pypsa_csv_folder(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 + assert json.loads(r["json"]) + + +def test_pypsa_csv_folder_accepts_transport(tmp_path): + transport = powerio_mcp.parse_case(path=str(CASE9))["json"] + out_dir = tmp_path / "from_json" + w = powerio_mcp.write_pypsa_csv_folder(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")) + + +def test_gridfm_round_trip(tmp_path): + out_dir = tmp_path / "gfm" + w = powerio_mcp.write_gridfm(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 + assert json.loads(r["json"]) + + +def test_read_gridfm_missing_dir_maps_cleanly(tmp_path): + with pytest.raises(ValueError): + powerio_mcp.read_gridfm(str(tmp_path / "nope"))