Skip to content

ANDES: fix run_eigenvalue_analysis + add PSS/E raw+dyr loading (integrates #52) - #58

Merged
qian-harvard merged 3 commits into
mainfrom
integration/andes-52
Aug 22, 2026
Merged

ANDES: fix run_eigenvalue_analysis + add PSS/E raw+dyr loading (integrates #52)#58
qian-harvard merged 3 commits into
mainfrom
integration/andes-52

Conversation

@qian-harvard

Copy link
Copy Markdown
Contributor

Integrates #52 by @elasticdotventures, which branched from 52deb67 — before #57 landed the MCP 2 migration and checked_path containment — and so conflicted with main in ANDES/andes_mcp.py, ANDES/README.md, and powermcp/README.md. The author's commits are preserved as a parent of the merge.

Conflict resolution

Kept main's sandboxed structure (_prepare_run_dir, checked_path, the PowerIO resolve_solver_case bridge) and re-applied the PR's two features on top of it. ANDES/README.md keeps main's load_network_from_* lines — they document #57's operating_point / study_commit parameters, which this branch predates and would otherwise have silently dropped — and takes the PR's run_power_flow / run_eigenvalue_analysis blocks and license note.

Review fixes applied on top

  • dyr_path bypassed the sandbox. The PR adds a second path argument to a function main had just finished containing. It now goes through checked_path like its file_path sibling, and the .dyr copy into the run directory is a checked write.
  • Sorting modes desynced them from participation_factors. modes.sort(...) reordered only the mode list while pfactors / x_name kept ANDES's native order, so modes[0] and participation_factors[0] described different eigenvalues — breaking exactly the question the tool exists to answer. Each mode now carries its native index.
  • Real eigenvalues were hardcoded to 0% damping. ANDES's post_process() applies -100*Re/|mu| to every mode, giving ±100% for real ones. A real positive eigenvalue is monotonic instability; reporting 0% sorted it into the middle of the list instead of first. Only a mode at the origin is special-cased now.
  • dynamic_models_loaded reported the argument, not the result. ANDES's PSS/E dyr parser skips model types it does not support, so a supplied .dyr can attach nothing. The flag is now derived from the model count, which also covers inverter-based resources (RenGen, DG) rather than SynGen alone.
  • n_eigenvalues / eigenvalues were dropped. Restored alongside the new n_modes / modes, making the change purely additive for existing callers. Only eigenvectors and state_variables are gone — they read attributes EIG has never had, so they only ever returned [].
  • math.pi replaces a numpy import added for one constant.
  • The andes_mcp fixture points POWERMCP_HOME at a per-test tmp_path. Run directories are named after the case stem, so the two ieee14 tests shared one directory and leaked the .dyr copy between them.

Verification

  • With andes 2.0.0 installed: 13 passed in tests/test_andes_server.py, including four new tests covering the fixes above. They skip rather than fail without the extra.
  • Extras-free venv (what CI installs): 374 passed, 17 skipped.

Closes #52.

🤖 Generated with Claude Code

elasticdotventures and others added 3 commits August 3, 2026 22:55
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
…loading (#52)

Merges elasticdotventures' #52, branched from 52deb67 (before #57's MCP 2 +
sandbox migration), resolving the conflicts in ANDES/andes_mcp.py and the two
READMEs against main.

Conflict resolution keeps main's sandboxed structure (_prepare_run_dir,
checked_path) and re-applies the PR's two features on top. ANDES/README.md
keeps main's load_network_from_* lines, which document #57's operating_point /
study_commit parameters that this branch predates.

Review fixes applied on top of the author's work:

- dyr_path goes through checked_path like its file_path sibling, and the .dyr
  copy into the run directory is a checked write. The PR predates containment
  and had neither, so it would have added an unchecked path argument to a
  function main had just finished sandboxing.

- Sorting `modes` by damping left participation_factors and state_names in
  ANDES's native ordering, so modes[0] and participation_factors[0] described
  different eigenvalues -- breaking exactly the question the tool exists to
  answer. Each mode now carries its native `index`.

- Real eigenvalues were hardcoded to 0% damping. ANDES's post_process applies
  -100*Re/|mu| to every mode, giving +/-100% for real ones; a real positive
  eigenvalue is monotonic instability, and reporting 0% sorted it into the
  middle of the list instead of first. Only a mode at the origin is special
  now.

- dynamic_models_loaded reported whether the caller passed an argument rather
  than whether anything attached. It is now derived from the model count,
  which also covers inverter-based resources (RenGen, DG) instead of SynGen
  alone.

- n_eigenvalues / eigenvalues are retained alongside the new n_modes / modes,
  so the change is purely additive for existing callers. Only eigenvectors and
  state_variables are gone -- they read attributes EIG has never had.

- math.pi replaces a numpy import added for one constant.

The andes_mcp fixture now points POWERMCP_HOME at a per-test tmp_path: run
directories are named after the case stem, so the two ieee14 tests shared one
directory and leaked the .dyr copy between them.

Verified with andes 2.0.0 installed: 13 passed in tests/test_andes_server.py
(they skip, not fail, without the extra). Full suite in an extras-free venv:
374 passed, 17 skipped.

Co-Authored-By: Brian Horakh <elasticdotventures@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qian-harvard
qian-harvard merged commit d40e31d into main Aug 22, 2026
2 checks passed
@qian-harvard
qian-harvard deleted the integration/andes-52 branch August 22, 2026 21:01
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