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
28 changes: 26 additions & 2 deletions ANDES/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,41 @@ Configure in your MCP client (e.g., Cursor, Claude Desktop):

## Available Tools

- **run_power_flow(file_path: str)**: Run power flow analysis on a power system case file.
- **run_power_flow(file_path: str, dyr_path: Optional[str] = None)**: Run power flow analysis on a power system case file.
- `dyr_path`: optional path to a PSS/E `.dyr` dynamic-model file (generators, exciters, governors) to attach to a PSS/E `.raw` case via ANDES's `addfile` loading. Without it, `run_time_domain_simulation`/`run_eigenvalue_analysis` have no real dynamics to work with beyond static topology.
- Adds two fields to the returned `power_flow` dict:
- `n_dynamic_generators`: how many dynamic generator models the loaded system actually carries, summed over ANDES's `SynGen`, `RenGen`, and `DG` groups (synchronous machines and inverter-based resources).
- `dynamic_models_loaded`: `n_dynamic_generators > 0`. Derived from what attached, not from whether `dyr_path` was passed — ANDES's PSS/E dyr parser skips model types it does not support, so a supplied `.dyr` can still leave the system with no dynamics. A case with dynamics embedded in the file itself (like `kundur_full.json`) reports `True` with no `.dyr` at all.
- Purely additive: omitting `dyr_path` behaves exactly as before.
- **run_time_domain_simulation(step_size: float = 0.01, t_end: float = 10.0)**: Run time domain simulation on the currently loaded power system.
- **run_eigenvalue_analysis(file_path: str)**: Run eigenvalue analysis on a power system case.
- **run_eigenvalue_analysis(file_path: str)**: Run eigenvalue (small-signal) analysis on a power system case. Reloads the case fresh from `file_path` and returns:
- `n_modes`: number of eigenvalues/modes.
- `modes`: a list, one entry per mode, each with:
- `index`: the mode's position in ANDES's native eigenvalue ordering.
- `eigenvalue`: `[real, imag]` parts of the eigenvalue.
- `frequency_hz`: oscillation frequency in Hz (`0.0` for non-oscillatory/real modes).
- `damping_ratio_pct`: damping ratio as a percentage. Real modes get `+100`/`-100`, same as ANDES's own report — a real mode at `-100` is monotonic instability.
- `is_oscillatory`: whether the mode has a non-zero imaginary part.

The list is sorted **least-damped (most concerning) first**.
- `participation_factors`: the raw participation-factor matrix from ANDES, in ANDES's native ordering. Use a mode's `index`, not its position in `modes`, to look up its row/column — the `modes` list is re-sorted and this matrix is not.
- `state_names`: state variable labels, in the same native ordering as `participation_factors`.
- `success`: whether ANDES's `EIG.run()` reported success.
- `n_eigenvalues`, `eigenvalues`: the pre-0.3.0 fields, retained so existing callers keep working. The old `eigenvectors` and `state_variables` fields are gone: they read attributes the `EIG` routine has never had, so they only ever returned `[]`.
- **get_system_info()**: Get information about the currently loaded power system.
- **load_network_from_any(...)**: Convert any PowerIO-readable case or one selected `.pio.json` package state into the ANDES run format.
- **load_network_from_json(...)**: Convert PowerIO model JSON or one selected `.pio.json` package state without staging the source input.

## License note

[ANDES](https://github.com/curent/andes) is GPL-3.0; it is installed only as an optional pip extra (`andes = ["andes"]` in `pyproject.toml`), never vendored into this MIT-licensed repo. The raw+dyr example/test case (`ieee14.raw`/`ieee14.dyr`) is likewise referenced at call time via `andes.get_case(...)` from the installed `andes` package's own bundled `andes/cases/` directory -- never vendored into this repo either.

## Prompt Example

Could you run power flow on the Kundur case at `yourpath\PowerMCP\ANDES\kundur_full.json` using ANDES and summarize the results? Then call `get_system_info` to show the system details.

Or, with a PSS/E raw+dyr case: run power flow on `ieee14.raw` with `dyr_path` set to `ieee14.dyr` (e.g. via `andes.get_case("ieee14/ieee14.raw")` / `andes.get_case("ieee14/ieee14.dyr")` for ANDES's own bundled example), confirm `dynamic_models_loaded` and `n_dynamic_generators` in the result, then run a time-domain simulation against the loaded dynamics.

## Resources

- [ANDES Documentation](https://andes.readthedocs.io/)
138 changes: 118 additions & 20 deletions ANDES/andes_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import sys
import shutil
import json
from math import pi
from pathlib import Path
from contextlib import redirect_stdout, redirect_stderr
from mcp.server.mcpserver import MCPServer as FastMCP
Expand Down Expand Up @@ -52,6 +53,24 @@ def _prepare_run_dir(name: str, purpose: str) -> str:
os.makedirs(run_dir, exist_ok=True)
return checked_read_tree(run_dir, purpose=purpose)

# ANDES groups its dynamic generator models by technology: SynGen holds the
# synchronous machines, RenGen and DG the inverter-based resources. A case can
# carry dynamics in any of them, so "does this system have dynamics?" has to
# look past SynGen alone.
_DYNAMIC_GENERATOR_GROUPS = ("SynGen", "RenGen", "DG")


def _count_dynamic_generators(ss) -> int:
"""Number of dynamic generator models attached to a loaded system."""
groups = getattr(ss, "groups", None)
if not groups:
return 0
return sum(
int(getattr(groups.get(name), "n", 0) or 0)
for name in _DYNAMIC_GENERATOR_GROUPS
)


# Configure logging (stream only at import; file handler attached lazily)
logging.basicConfig(
level=logging.INFO,
Expand Down Expand Up @@ -95,17 +114,24 @@ def _ensure_file_logging():
system_state: Dict[str, Any] = {}

@mcp.tool()
def run_power_flow(file_path: str) -> Dict[str, Any]:
def run_power_flow(file_path: str, dyr_path: Optional[str] = None) -> Dict[str, Any]:
"""Run power flow analysis on a power system case

Args:
file_path: Path to the case file

dyr_path: Optional path to a PSS/E .dyr dynamic-model file (generators,
exciters, governors) to attach to a PSS/E .raw case. When given,
it is loaded alongside file_path via ANDES's addfile mechanism,
enabling run_time_domain_simulation/run_eigenvalue_analysis to
operate on real dynamics instead of static topology only.

Returns:
Dict containing power flow results and output information
"""
try:
file_path = checked_path(file_path, purpose="file_path")
if dyr_path is not None:
dyr_path = checked_path(dyr_path, purpose="dyr_path")
except PathNotAllowed as exc:
return {"status": "error", "message": str(exc)}
try:
Expand All @@ -118,42 +144,79 @@ def run_power_flow(file_path: str) -> Dict[str, Any]:
"message": f"Input file not found: {abs_file_path}"
}

# Resolve and validate the optional .dyr file before any run-dir/chdir
# work happens, same pattern as the main input file check above.
abs_dyr_path = None
if dyr_path is not None:
abs_dyr_path = os.path.abspath(dyr_path)
if not os.path.exists(abs_dyr_path):
return {
"status": "error",
"message": f"Dynamic model file not found: {abs_dyr_path}"
}

# Create a unique directory for this run
run_dir = _prepare_run_dir(
f"pf_{Path(abs_file_path).stem}",
"generated power flow output directory",
)

# Copy input file to run directory
input_file = checked_path(
os.path.join(run_dir, os.path.basename(abs_file_path)),
purpose="generated ANDES input copy",
for_write=True,
)
shutil.copy2(abs_file_path, input_file)


# Copy the .dyr file into run_dir alongside the main input, and use
# the copied path as addfile -- keeps everything this run touched
# under output_dir.
dyr_file = None
if abs_dyr_path is not None:
dyr_file = checked_path(
os.path.join(run_dir, os.path.basename(abs_dyr_path)),
purpose="generated ANDES dynamic model copy",
for_write=True,
)
shutil.copy2(abs_dyr_path, dyr_file)

# Save current directory and change to run directory
original_dir = os.getcwd()
os.chdir(run_dir)

try:
# Capture stdout/stderr
f_out = io.StringIO()
f_err = io.StringIO()

with redirect_stdout(f_out), redirect_stderr(f_err):
# Run power flow with minimal output
ss = andes.run(input_file, no_output=True, verbose=50)

# Run power flow with minimal output. addfile is only passed
# when a .dyr was supplied, so the no-dyr call path is
# byte-identical to before.
run_kwargs = {"no_output": True, "verbose": 50}
if dyr_file is not None:
run_kwargs["addfile"] = dyr_file
ss = andes.run(input_file, **run_kwargs)

# Store system state for other tools
system_state['current_system'] = ss


# Count what actually attached rather than trusting the
# argument: ANDES's PSS/E dyr parser silently skips model
# types it does not support, so a supplied .dyr can leave the
# system with no dynamics at all. SynGen covers synchronous
# machines, RenGen and DG the inverter-based resources.
n_dyn_gen = _count_dynamic_generators(ss)

# Extract key power flow results
pflow_results = {
"converged": ss.PFlow.converged,
"iterations": ss.PFlow.niter if hasattr(ss.PFlow, 'niter') else 0,
"max_mis": float(ss.PFlow.mis[-1]) if hasattr(ss.PFlow, 'mis') and len(ss.PFlow.mis) > 0 else 0.0,
"time": float(ss.PFlow.t) if hasattr(ss.PFlow, 't') else 0.0
"time": float(ss.PFlow.t) if hasattr(ss.PFlow, 't') else 0.0,
"dynamic_models_loaded": n_dyn_gen > 0,
"n_dynamic_generators": n_dyn_gen,
}

# Get list of output files
Expand Down Expand Up @@ -309,15 +372,50 @@ def run_eigenvalue_analysis(file_path: str) -> Dict[str, Any]:

# Run eigenvalue analysis
success = ss.EIG.run()

# Extract eigenvalue results

# Extract eigenvalue results. ss.EIG.mu holds the eigenvalues
# (complex array); frequency and damping ratio are derived
# from mu using the same formula ANDES's own EIG.post_process()
# uses internally for its text report:
# freq_hz = |Im(mu)| / (2*pi)
# damping_pct = -100 * Re(mu) / |mu|
# That damping formula applies to every mode, real ones
# included: a real eigenvalue yields -100% or +100%, and the
# negative case is monotonic instability -- the most serious
# small-signal result there is. Reporting 0% for it would
# bury it mid-list once the modes are sorted.
eigenvalues = ss.EIG.mu
modes = []
for index, mu in enumerate(eigenvalues):
magnitude = abs(mu)
freq_hz = abs(mu.imag) / (2 * pi) if mu.imag else 0.0
# A mode at the origin has no defined damping ratio.
damping_pct = -100.0 * mu.real / magnitude if magnitude else 0.0
modes.append({
# ANDES's native position for this eigenvalue. The
# list below is re-sorted, but participation_factors
# and state_names keep this ordering, so the index is
# what ties a mode back to its participation column.
"index": index,
"eigenvalue": [float(mu.real), float(mu.imag)],
"frequency_hz": float(freq_hz),
"damping_ratio_pct": float(damping_pct),
"is_oscillatory": bool(mu.imag != 0),
})
modes.sort(key=lambda m: m["damping_ratio_pct"]) # least-damped (most concerning) first

eig_results = {
"n_eigenvalues": len(ss.EIG.mu) if hasattr(ss.EIG, 'mu') else 0,
"eigenvalues": ss.EIG.mu.tolist() if hasattr(ss.EIG, 'mu') else [],
"eigenvectors": ss.EIG.vectors.tolist() if hasattr(ss.EIG, 'vectors') else [],
"participation_factors": ss.EIG.pfactors.tolist() if hasattr(ss.EIG, 'pfactors') else [],
"state_variables": ss.EIG.state_desc if hasattr(ss.EIG, 'state_desc') else [],
"success": success
"n_modes": len(modes),
"modes": modes,
"participation_factors": ss.EIG.pfactors.tolist() if getattr(ss.EIG, "pfactors", None) is not None else [],
"state_names": list(ss.EIG.x_name) if getattr(ss.EIG, "x_name", None) is not None else [],
"success": success,
# Retained from the pre-0.3.0 shape so existing callers
# keep working. `eigenvectors`/`state_variables` are gone
# for good: they read attributes the EIG routine has never
# had, so they only ever returned [].
"n_eigenvalues": len(modes),
"eigenvalues": eigenvalues.tolist(),
}

# Get list of output files
Expand Down
2 changes: 1 addition & 1 deletion powermcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pip install "powermcp[all]" # everything
| Extra | Tool(s) | Notes |
|---|---|---|
| *(none / core)* | pandapower, PyPSA, PowerIO | always installed |
| `andes` | ANDES | |
| `andes` | ANDES | GPL-3.0; installed only as an optional pip extra, never vendored |
| `egret` | Egret | + needs an external solver (ipopt/Gurobi) |
| `opendss` | OpenDSS | |
| `surge` | surge | **Python 3.12–3.14 only** |
Expand Down
27 changes: 27 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import sys

import pytest


Expand All @@ -14,3 +16,28 @@ def isolated_config(tmp_path, monkeypatch):
if var.startswith("POWERMCP_") and var != "POWERMCP_HOME":
monkeypatch.delenv(var, raising=False)
return tmp_path


@pytest.fixture()
def andes_mcp(tmp_path, monkeypatch):
"""Import andes_mcp from the registry-resolved server dir, skipping if
andes is not installed.

Shared by test_powerio_server.py (the powerio/pandapower bridge tools)
and test_andes_server.py (the ANDES engine tools themselves).

``run_power_flow`` and friends name their run directory after the case
stem, so two tests on the same case share one directory. Pointing
POWERMCP_HOME at a per-test tmp_path keeps each test's artifacts to
itself and keeps the suite out of the developer's real ~/.powermcp.
"""
pytest.importorskip("andes")
monkeypatch.setenv("POWERMCP_HOME", str(tmp_path / "powermcp-home"))
from powermcp.registry import TOOLS

andes_dir = str(TOOLS["andes"].resolve_server_dir())
if andes_dir not in sys.path:
sys.path.insert(0, andes_dir)
import andes_mcp as _andes_mcp

return _andes_mcp
Loading
Loading