Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
182 changes: 44 additions & 138 deletions powerio/powerio_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``).
"""
Expand All @@ -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": <transport string>, "summary": <case_summary fields>,
"warnings": [<read fidelity notes>]}``.
Returns ``{"kind": "powerworld", "canvas_width": <int>,
"canvas_height": <int>, "stamp": <int>, "substations":
[{"number": <int>, "name": <str>, "x": <float>, "y": <float>}, ...]}``.
"""
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": <folder written>, "files": [<csv paths>],
"warnings": [<fidelity notes>]}``.
"""
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 ``<case>/`` 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": <transport string>, "summary": <case_summary fields>,
"scenario": <int>, "warnings": [<fidelity notes>]}``. 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
(``<case>/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")
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file added tests/data/powerworld/ACTIVSg200.pwd
Binary file not shown.
39 changes: 38 additions & 1 deletion tests/test_powerio_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,7 +21,7 @@

import pytest

pytest.importorskip("powerio")
pytest.importorskip("powerio", minversion="0.2.2")

import powerio # noqa: E402

Expand All @@ -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
Expand Down Expand Up @@ -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))
Loading