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
107 changes: 106 additions & 1 deletion ANDES/andes_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pathlib import Path
from contextlib import redirect_stdout, redirect_stderr
from mcp.server.fastmcp import FastMCP
from typing import Dict, Any
from typing import Dict, Any, Optional

# Storage directory resolved lazily (no filesystem writes at import time)
def _andes_runs_dir():
Expand Down Expand Up @@ -333,6 +333,111 @@ def get_system_info() -> Dict[str, Any]:
}


# ---------------------------------------------------------------------------
# powerio bridge: load any powerio readable case into ANDES.
# powerio parses MATPOWER .m, PSS/E .raw (v33), PowerWorld .aux, PowerModels
# JSON, and egret JSON; the case is staged as a MATPOWER file that ANDES loads
# natively via run_power_flow. powerio is an optional extra, so the tools
# degrade gracefully when it is missing.
# ---------------------------------------------------------------------------

_POWERIO_HINT = "powerio not installed: pip install 'powerio[mcp,matrix]'"


@mcp.tool()
def load_network_from_json(
network_json: str,
out_path: str,
) -> 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
counts. Pass out_path to run_power_flow to run the simulation. Requires the
powerio extra (pip install 'powermcp[powerio]').

Args:
network_json: The JSON transport string from powerio
out_path: Destination for the MATPOWER case file (.m)

Returns:
Dict with status, case_file path, component counts, and fidelity warnings
"""
try:
import powerio
except ImportError:
return {"status": "error", "message": _POWERIO_HINT}
try:
case = powerio.from_json(network_json)
conv = case.to_format("matpower")
abs_out = os.path.abspath(out_path)
with open(abs_out, "w") as fh:
fh.write(conv.text)
except Exception as e:
return {"status": "error", "message": str(e)}
return {
"status": "success",
"message": f"Case staged at {abs_out}; pass this path to run_power_flow",
"case_file": abs_out,
"info": {
"buses": case.n_buses,
"branches": case.n_branches,
"generators": case.n_gens,
},
"warnings": list(conv.warnings),
}


@mcp.tool()
def load_network_from_any(
file_path: str,
out_path: str,
source_format: Optional[str] = None,
) -> Dict[str, Any]:
"""Stage any powerio readable case as a MATPOWER file for ANDES.

Reads MATPOWER .m, PSS/E .raw (v33), PowerWorld .aux, PowerModels JSON, or
egret JSON via powerio and writes a MATPOWER file to out_path (use a .m
extension). Pass out_path to run_power_flow to run the simulation. Requires
the powerio extra (pip install 'powermcp[powerio]').

Args:
file_path: Path to the source case file
out_path: Destination for the MATPOWER case file (.m)
source_format: Input format name (matpower, powermodels-json, egret-json,
psse, powerworld); inferred from the file extension when omitted

Returns:
Dict with status, case_file path, component counts, and fidelity warnings
"""
try:
import powerio
except ImportError:
return {"status": "error", "message": _POWERIO_HINT}
try:
case = powerio.parse_file(file_path, source_format)
conv = case.to_format("matpower")
abs_out = os.path.abspath(out_path)
with open(abs_out, "w") as fh:
fh.write(conv.text)
except FileNotFoundError:
return {"status": "error", "message": f"File not found: {file_path}"}
except Exception as e:
return {"status": "error", "message": str(e)}
return {
"status": "success",
"message": f"Case staged at {abs_out}; pass this path to run_power_flow",
"case_file": abs_out,
"info": {
"buses": case.n_buses,
"branches": case.n_branches,
"generators": case.n_gens,
},
"warnings": list(conv.warnings),
}


if __name__ == "__main__":
print(f"Starting ANDES MCP Server")
print(f"Using storage directory: {_andes_runs_dir()}")
Expand Down
32 changes: 29 additions & 3 deletions PyPSA/pypsa_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -667,14 +667,40 @@ 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:
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.
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
Loading
Loading