Skip to content
Closed
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
33 changes: 30 additions & 3 deletions PyPSA/pypsa_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -667,14 +667,41 @@ def _import_case_to_netcdf(case, output_path: str, overwrite_zero_s_nom: Optiona
)
if (ppc["bus"][:, 9] == 0).any():
warnings.append("buses with base_kv 0 are assigned v_nom 1 by PyPSA")
in_service = ppc["branch"][:, 10] == 1.0
if overwrite_zero_s_nom is None and (ppc["branch"][in_service, 5] == 0).any():

# PyPSA's import_from_pypower_ppc ignores the ppc status columns (branch
# col 10, gen col 7), so out-of-service elements would import as fully
# active and silently change topology. Drop them before import and report
# the count. (pandapower's from_ppc honors status, so its bridge does not
# need this — keep that asymmetry in mind when syncing the shared helper.)
br_oos = ppc["branch"][:, 10] == 0.0
if br_oos.any():
ppc["branch"] = ppc["branch"][~br_oos]
warnings.append(
f"{int(br_oos.sum())} out-of-service branch(es) dropped "
"(PyPSA's ppc import does not model branch status)"
)
gen_oos = ppc["gen"][:, 7] == 0.0
if gen_oos.any():
ppc["gen"] = ppc["gen"][~gen_oos]
if "gencost" in ppc: # keep gencost rows aligned with gen rows
ppc["gencost"] = ppc["gencost"][~gen_oos]
warnings.append(
f"{int(gen_oos.sum())} out-of-service generator(s) dropped "
"(PyPSA's ppc import does not model generator status)"
)

# Only in-service branches remain now, so the rating-0 check is exact (and
# an out-of-service rating-0 branch no longer slips in unwarned).
if overwrite_zero_s_nom is None and (ppc["branch"][:, 5] == 0).any():
warnings.append(
"branches with rating 0 imported with s_nom 0; pass overwrite_zero_s_nom to set a value"
)
network = Network()
network.import_from_pypower_ppc(ppc, overwrite_zero_s_nom=overwrite_zero_s_nom)
network.export_to_netcdf(output_path)
try:
network.export_to_netcdf(output_path)
except OSError as exc:
raise OSError(f"failed to write network to {output_path}: {exc}") from exc
info = {
"buses": len(network.buses),
"generators": len(network.generators),
Expand Down
24 changes: 23 additions & 1 deletion powerio/powerio_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@

mcp = FastMCP("PowerIO Conversion Server")

# Fail fast if `import powerio` resolved to something without the real API — e.g.
# this server's own powerio/ directory shadowing the package as a PEP 420
# namespace (editable installs / PYTHONPATH / pytest rootdir), where the import
# silently binds the empty dir. The shipped wheel (force-include relocation) and
# `powermcp run powerio` (probe origin check) are already guarded; this covers
# the dev paths where tool calls would otherwise die with a cryptic AttributeError.
if not hasattr(powerio, "parse_file"): # pragma: no cover
raise ImportError(
"the 'powerio' package is not importable (the repo's powerio/ directory "
"may be shadowing it); install it: pip install 'powerio[mcp,matrix]'"
)

# Format name (and alias) → file extension, for staging inline content to a temp
# file. `convert_file` is path-only, so inline conversion writes the text to disk
# first; a matching extension keeps the format obvious even though we always
Expand Down Expand Up @@ -108,6 +120,10 @@ def _load(
return powerio.from_json(json)
except powerio.PowerIOError as exc:
raise ValueError(f"parse failed: {exc}") from exc
except (ValueError, KeyError, TypeError) as exc:
# Wrong-schema JSON may raise these instead of PowerIOError; keep the one
# documented error shape (a JSONDecodeError is itself a ValueError).
raise ValueError(f"parse failed: {exc}") from exc


def _summary(case: "powerio.Network") -> Dict[str, Any]:
Expand Down Expand Up @@ -162,6 +178,10 @@ def convert_case(
raise ValueError(f"conversion failed: {exc}") from exc
except FileNotFoundError as exc:
raise ValueError(f"file not found: {exc}") from exc
except OSError as exc:
# e.g. staging the inline content to a temp file failed (read-only temp
# dir, disk full); normalize to the module's single error shape.
raise ValueError(f"conversion failed: {exc}") from exc
return {"text": conv.text, "warnings": list(conv.warnings)}


Expand Down Expand Up @@ -348,8 +368,10 @@ def compute_matrix(
m = case.lodf(convention)
elif kind == "lacpf":
m = case.lacpf()
else:
elif kind == "laplacian":
m = case.weighted_laplacian(convention)
else: # pragma: no cover - unreachable; guarded by the _MATRIX_KINDS check
raise ValueError(f"unhandled matrix kind {kind!r}")
except ImportError as exc:
raise ValueError(str(exc)) from exc
except powerio.PowerIOError as exc:
Expand Down
77 changes: 77 additions & 0 deletions tests/test_powerio_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,26 @@
];
"""

# 3-bus case with an out-of-service branch (2-3, status 0) and an out-of-service
# generator (at PQ bus 3, status 0), for the PyPSA/pandapower status tests.
OOS_CASE = """function mpc = oos
mpc.version = '2';
mpc.baseMVA = 100.0;
mpc.bus = [
\t1 3 0 0 0 0 1 1.0 0.0 230.0 1 1.1 0.9;
\t2 1 50 10 0 0 1 1.0 0.0 230.0 1 1.1 0.9;
\t3 1 30 5 0 0 1 1.0 0.0 230.0 1 1.1 0.9;
];
mpc.gen = [
\t1 80 0 50 -50 1.0 100 1 200 0 0 0 0 0 0 0 0 0 0 0 0;
\t3 20 0 50 -50 1.0 100 0 100 0 0 0 0 0 0 0 0 0 0 0 0;
];
mpc.branch = [
\t1 2 0.01 0.05 0.0 250 0 0 0 0 1 -360 360;
\t2 3 0.01 0.05 0.0 250 0 0 0 0 0 -360 360;
];
"""


def test_parse_case_json_round_trips():
r = powerio_mcp.parse_case(path=str(CASE9))
Expand Down Expand Up @@ -233,6 +253,63 @@ def test_pypsa_import_missing_file(tmp_path):
assert "not found" in r["message"].lower()


def test_pypsa_import_drops_out_of_service_branch(tmp_path):
# PyPSA's ppc import ignores branch status, so the bridge must drop the
# out-of-service branch (2-3) rather than import it as an active line.
src = tmp_path / "oos.m"
src.write_text(OOS_CASE)
out = tmp_path / "oos.nc"
r = pypsa_mcp.import_case_from_any(str(src), str(out))
assert r["status"] == "success", r
assert pypsa.Network(str(out)).lines.shape[0] == 1 # only the in-service 1-2
assert any("out-of-service branch" in w for w in r["warnings"]), r["warnings"]


def test_pypsa_import_warns_out_of_service_generator(tmp_path):
src = tmp_path / "oos.m"
src.write_text(OOS_CASE)
r = pypsa_mcp.import_case_from_any(str(src), str(tmp_path / "g.nc"))
assert r["status"] == "success", r
assert any("out-of-service generator" in w for w in r["warnings"]), r["warnings"]


def test_pandapower_bridge_honors_branch_status(tmp_path):
# Contrast with PyPSA: pandapower's from_ppc models status, so the bridge
# keeps the out-of-service branch as an inactive line (not dropped).
panda_dir = str(TOOLS["pandapower"].resolve_server_dir())
if panda_dir not in sys.path:
sys.path.insert(0, panda_dir)
import panda_mcp # noqa: E402

src = tmp_path / "oos.m"
src.write_text(OOS_CASE)
res = panda_mcp.load_network_from_any(str(src))
assert res["status"] == "success", res
in_service = panda_mcp._current_net.line["in_service"].tolist()
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))
assert m["format"] == "coo"
assert m["shape"] == [9, 9]


def test_compute_matrix_bad_json_raises_valueerror():
with pytest.raises(ValueError):
powerio_mcp.compute_matrix("bprime", json="{not valid json")


def test_convert_case_normalizes_stage_oserror(monkeypatch):
# a failure staging inline content surfaces as ValueError, not a raw OSError
def boom(content, fmt):
raise OSError("disk full")

monkeypatch.setattr(powerio_mcp, "_stage", boom)
with pytest.raises(ValueError):
powerio_mcp.convert_case(to="psse", content="x", from_="matpower")


def test_registry_entry():
t = TOOLS["powerio"]
assert t.kind == "open-source"
Expand Down
Loading