Skip to content

ANDES: fix run_eigenvalue_analysis + add PSS/E raw+dyr dynamic-model loading - #52

Open
elasticdotventures wants to merge 2 commits into
Power-Agent:mainfrom
fungible-farm:feat/andes-raw-dyr-loading
Open

ANDES: fix run_eigenvalue_analysis + add PSS/E raw+dyr dynamic-model loading#52
elasticdotventures wants to merge 2 commits into
Power-Agent:mainfrom
fungible-farm:feat/andes-raw-dyr-loading

Conversation

@elasticdotventures

Copy link
Copy Markdown

Summary

Two combined fixes/additions to the ANDES MCP server (ANDES/andes_mcp.py), motivated by wanting to use ANDES's small-signal/eigenvalue analysis for oscillation-risk review (per AEMO's 2026 General Power System Risk Review, which names small-signal stability and inter-area oscillation damping as active industry concerns, and separately notes system operators moving off proprietary small-signal tooling).

1. Fix run_eigenvalue_analysis (real bug, not a new feature)

run_eigenvalue_analysis read ss.EIG.vectors and ss.EIG.state_desc after calling ss.EIG.run(). Neither attribute exists on ANDES's real EIG routine object — the code's own hasattr() guards meant eigenvectors/state_variables have silently returned [] since the tool was written, with no error and no test coverage catching it.

Verified two ways: read andes/routines/eig.py directly (GitHub + a local install), and installed andes 2.0.0 into a venv and printed hasattr(ss.EIG, ...) for old vs. new attribute names directly. Real attributes: mu (eigenvalues, complex array), N/W (right/left eigenvector matrices), pfactors (participation factors), x_name (state labels).

Fix: reads the real attributes and derives frequency_hz/damping_ratio_pct per mode using the exact 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|

New return shape (documented in ANDES/README.md): n_modes, modes (list of {eigenvalue: [re, im], frequency_hz, damping_ratio_pct, is_oscillatory}, sorted least-damped/most-concerning first), participation_factors, state_names, success.

2. Add PSS/E raw+dyr dynamic-model loading to run_power_flow

run_power_flow previously only loaded static topology — no way to attach a PSS/E .dyr dynamic-model file (generators, exciters, governors) to a .raw case, so run_time_domain_simulation/run_eigenvalue_analysis had no real dynamics to operate on except via the one bundled all-JSON ANDES/kundur_full.json fixture.

Added an optional dyr_path: Optional[str] = None parameter, following the function's existing scaffolding exactly (abspath/exists-check, copy into the run directory, conditional addfile kwarg to andes.run(...) only when supplied — the no-dyr call path is byte-identical to before). Two new, purely additive return fields: dynamic_models_loaded and n_dynamic_generators (from ANDES's SynGen model group, not get_system_info's existing num_generators, which is already non-zero from static PV buses alone even with no .dyr loaded).

No new fixture file is vendored into this repo. Tests resolve ANDES's own bundled ieee14 PSS/E example case at test-call time via andes.get_case("ieee14/ieee14.raw") / andes.get_case("ieee14/ieee14.dyr") — a real, documented top-level function in andes 2.0.0 (declared as package-data in andes's own pyproject.toml), confirmed live: ss.groups["SynGen"].n is 0 loading ieee14.raw alone, 5 loading it with addfile=ieee14.dyr. This avoids authoring a new .dyr file by hand, which carries real risk given ANDES's own PSS/E dyr parser has known real-world compatibility gaps on some model types.

Tests

tests/test_andes_server.py (new), using a shared andes_mcp fixture in tests/conftest.py (factored out of tests/test_powerio_server.py's prior ad hoc bootstrap): power flow convergence, system info, time-domain simulation, and the eigenvalue-analysis regression check (non-empty state_names/numeric frequency_hz/damping_ratio_pct per mode — would have been empty before the fix) on kundur_full.json; plus four raw+dyr tests on ANDES's bundled ieee14 case (dynamics absent without dyr_path, present with it, enabling time-domain simulation, and a missing-file error path).

All tests are pytest.importorskip("andes")-guarded, matching this repo's existing convention for the optional andes extra — they run and pass against a real andes install (verified locally: 9/9 passed) and skip cleanly (not fail) when andes isn't installed (verified locally in a clean venv with no extras: 106 passed, 13 skipped, 0 failed).

Docs

ANDES/README.md: documented both new/changed tool signatures and return shapes, added a license note (ANDES is GPL-3.0; installed only as an optional pip extra, never vendored into this MIT-licensed repo — including the raw+dyr test fixture, which is referenced from the installed package, not vendored), added a raw+dyr prompt example. powermcp/README.md: filled in the previously-empty Notes column for the andes extras-table row.

ss.EIG.vectors and ss.EIG.state_desc do not exist on ANDES's real EIG
routine object; the hasattr() guards around them meant eigenvectors
and state_variables have silently returned empty lists since this
tool was written. Verified against andes/routines/eig.py's source and
a live andes 2.0.0 install run against the bundled Kundur case
(ANDES/kundur_full.json):

  ss.EIG.mu        eigenvalues (complex array)
  ss.EIG.N, .W     right/left eigenvector matrices
  ss.EIG.pfactors  participation factors
  ss.EIG.x_name    state labels

run_eigenvalue_analysis now reads these directly and computes
frequency_hz/damping_ratio_pct per mode using the same formula
EIG.post_process() uses internally for its own text report:

  freq_hz = |Im(mu)| / (2*pi)
  damping_pct = -100 * Re(mu) / |mu|

Modes are returned sorted least-damped (most concerning) first.

Also:
- Factor tests/test_powerio_server.py's ANDES-bootstrap helper into a
  shared andes_mcp fixture in tests/conftest.py.
- Add tests/test_andes_server.py exercising run_power_flow,
  get_system_info, run_time_domain_simulation, and the fixed
  run_eigenvalue_analysis against the bundled Kundur case. Guarded by
  pytest.importorskip("andes") like the existing ANDES bridge tests,
  so they skip (not fail) in CI, which installs no extras. Verified
  locally with a real andes 2.0.0 install: all pass.
- Document the new run_eigenvalue_analysis return shape and the
  load_network_from_json/load_network_from_any tools (already in the
  code, missing from the README) in ANDES/README.md; add a GPL-3.0
  license note there and fill in powermcp/README.md's andes extras
  row, which had an empty Notes column.

No CI workflow changes; PSS/E raw+dyr dynamic-model loading is
explicitly out of scope here (tracked separately).

Part of #1
run_power_flow(file_path) only loaded static topology -- no way to attach
a PSS/E .dyr dynamic-model file (generators, exciters, governors) to a
.raw case, so run_time_domain_simulation/run_eigenvalue_analysis had no
real dynamics to work with except on the one bundled all-JSON
ANDES/kundur_full.json fixture.

Adds an optional dyr_path: Optional[str] = None parameter, following the
function's own existing scaffolding exactly: resolved to an absolute
path and existence-checked before any run-dir/chdir work (same
error shape as the existing file_path check), copied into run_dir
alongside the main input via shutil.copy2 (same pattern), and passed as
addfile=<copied path> to andes.run(...) only when supplied -- the no-dyr
call path is byte-identical to before.

Adds two additive fields to the power_flow result:
- dynamic_models_loaded: dyr_path is not None
- n_dynamic_generators: ss.groups["SynGen"].n, read defensively via
  getattr(..., 0) -- deliberately not get_system_info's num_generators
  field, which sums PV.idx + GENROU.idx and is already non-zero from
  static PV buses alone even with no .dyr loaded.

Tests (tests/test_andes_server.py) resolve ANDES's own bundled ieee14
PSS/E raw+dyr example case via andes.get_case(...) at test-call time
(inside each test body, after the andes_mcp fixture's
pytest.importorskip("andes") has already run) -- no new fixture file
is authored or vendored into this repo. andes.get_case() is a real,
documented top-level function (verified live against andes 2.0.0) that
resolves paths under the installed package's own andes/cases/
directory, declared as package-data in andes's own pyproject.toml.

Verified live (andes 2.0.0, real pip install):
- ieee14.raw alone: ss.groups['SynGen'].n == 0
- ieee14.raw + addfile=ieee14.dyr: ss.groups['SynGen'].n == 5
- All 9 tests in tests/test_andes_server.py pass (5 pre-existing + 4 new)
- Full suite in a clean venv with no extras: new tests skip cleanly,
  106 passed / 13 skipped, no regressions
- Manually inspected the actual returned dict shape with and without
  dyr_path, and for a missing dyr_path

Stacked on fix/andes-eigenvalue-analysis (PR #3): depends on its
run_eigenvalue_analysis fix and tests/conftest.py andes_mcp fixture.

Closes #2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant