Skip to content

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

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

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
Contributor

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
@qian-harvard

Copy link
Copy Markdown
Contributor

Reviewed and integrated in #58 — thanks for this, the eigenvalue bug was a real one and well diagnosed. Since this branch was cut from 52deb67, before #57 landed the MCP 2 migration and checked_path containment, it conflicted in ANDES/andes_mcp.py and both READMEs; #58 carries your commits as a merge parent and resolves against main.

Conflict resolution

Kept main's sandboxed structure (_prepare_run_dir, checked_path) and re-applied your two features on top. ANDES/README.md keeps main's load_network_from_* lines — they document #57's operating_point / study_commit parameters, which your rewrite predates and would otherwise have dropped.

Findings addressed in #58

  1. dyr_path bypassed the sandbox. Not your doing — containment landed after you branched — but the merge would have added a second, unchecked path argument to a function main had just finished containing. dyr_path now goes through checked_path, and the .dyr copy into the run directory is a checked write.

  2. Sorting modes desynced them from participation_factors. modes.sort(...) reorders only the mode list, while pfactors and x_name keep ANDES's native ordering. So modes[0] and participation_factors[0] describe different eigenvalues — which breaks the exact question the tool exists to answer ("which state drives the least-damped mode?"). Each mode now carries its native index.

  3. Real eigenvalues were hardcoded to damping_ratio_pct = 0.0. ANDES's post_process() applies -100*Re/|mu| to every mode with no imag == 0 special case, so real modes get ±100%. A real positive eigenvalue is monotonic instability — the most severe small-signal result there is — and at 0.0 the ascending sort buried it mid-list, below stable oscillatory modes. Only a mode at the origin is special-cased now. Your frequency_hz = 0.0 for real modes was correct and is unchanged.

  4. dynamic_models_loaded reported the argument, not the result. Your own PR body notes that ANDES's PSS/E dyr parser has gaps on some model types — which means a supplied .dyr can attach nothing while the flag still says True. It is now derived from the model count, which also sums RenGen and DG so an inverter-only .dyr isn't reported as zero dynamics.

  5. n_eigenvalues / eigenvalues were dropped. eigenvalues carried real data (ss.EIG.mu.tolist()); only eigenvectors and state_variables were the always-empty broken fields. Both are restored as aliases, making the change purely additive like the run_power_flow half of your PR.

Minor: math.pi replaces the numpy import added for one constant, and the andes_mcp fixture points POWERMCP_HOME at a per-test tmp_path — run directories are named after the case stem, so your two ieee14 tests shared one directory and leaked the .dyr copy between them.

Your test file and the conftest.py fixture extraction are kept as written, plus four tests covering the fixes above. Verified against a real andes 2.0.0 install: 13 passed; extras-free venv: 374 passed, 17 skipped.

Closing in favour of #58.

qian-harvard added a commit that referenced this pull request Aug 22, 2026
ANDES: fix run_eigenvalue_analysis + add PSS/E raw+dyr loading (integrates #52)
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.

2 participants