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
12 changes: 8 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 4 additions & 4 deletions ANDES/andes_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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")
mcp.run(transport="stdio")
8 changes: 4 additions & 4 deletions Egret/egret_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -283,4 +283,4 @@ def load_model_from_json(network_json: str) -> Dict[str, Any]:


if __name__ == "__main__":
mcp.run(transport="stdio")
mcp.run(transport="stdio")
10 changes: 5 additions & 5 deletions PyPSA/pypsa_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -807,4 +807,4 @@ def import_case_from_json(


if __name__ == "__main__":
mcp.run(transport="stdio")
mcp.run(transport="stdio")
25 changes: 17 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
16 changes: 8 additions & 8 deletions pandapower/panda_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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")
if __name__ == "__main__":
mcp.run(transport="stdio")
109 changes: 34 additions & 75 deletions powerio/powerio_mcp.py
Original file line number Diff line number Diff line change
@@ -1,88 +1,47 @@
"""PowerMCP's powerio conversion servera 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": <int>,
"canvas_height": <int>, "stamp": <int>, "substations":
[{"number": <int>, "name": <str>, "x": <float>, "y": <float>}, ...]}``.
"""
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__":
Expand Down
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading