From 3f969a53e10056f75a6e26c3148021901684a188 Mon Sep 17 00:00:00 2001 From: elasticdotventures Date: Fri, 7 Aug 2026 02:22:11 +0000 Subject: [PATCH] feat(sienna): add SIENNA connector (load/translate-to-PLEXOS/solve via r2x + Julia) Implements issue #54: a new SIENNA MCP connector exposing load_system, translate_to_plexos, run_sienna_solve, compare_solutions. - powermcp/registry.py: new "sienna" Tool entry mirroring HOPE's Julia precedent (julia_bin/julia_depot_path config_keys, external_solvers=("Julia",)). - SIENNA/sienna_mcp/: main.py + tools/*_tools.py (PSCAD's shape). translate_to_plexos and compare_solutions call r2x (r2x_core/r2x_plexos/r2x_sienna_to_plexos) directly, imported lazily at call time so importing sienna_mcp.main never requires r2x. run_sienna_solve shells out to a bundled Julia driver script (sienna_mcp/scripts/solve_system.jl), the same subprocess mechanism HOPE uses. - SIENNA/pyproject.toml, root pyproject.toml: sienna extra pinning r2x; SIENNA/ force-included in the wheel/sdist build like every other connector. - SIENNA/tests/test_tools.py: mocked tests (PSCAD's MagicMock style) for all four tools; tests/test_vendor_import.py: extended with a license-free import test for sienna_mcp.main (r2x genuinely absent, not just monkeypatched). - SIENNA/README.md: Julia + PowerSystems.jl/PowerSimulations.jl setup, config keys, and a documented, verified-not-assumed gap: the Python r2x_core.System JSON schema and native Julia PowerSystems.jl JSON schema do not round-trip through each other as of r2x 2.1.0 / PowerSystems.jl 5.12.1. run_sienna_solve was verified against a real, freshly-installed Julia 1.10.9 + PowerSystems.jl 5.12.1 + PowerSimulations.jl 0.38.2 + HiGHS.jl 1.24.1: a native Sienna system was built and solved for real (build_status=BUILT, solve_status=SUCCESSFULLY_FINALIZED, objective_value=23039.999999999996). The automated test suite mocks the Julia subprocess rather than re-running that install. tests/ + SIENNA/tests: 122 passed, 4 skipped (pre-existing skips), 0 failures. --- SIENNA/README.md | 168 ++++++++++ SIENNA/pyproject.toml | 28 ++ SIENNA/sienna_mcp/__init__.py | 20 ++ SIENNA/sienna_mcp/_julia.py | 125 +++++++ SIENNA/sienna_mcp/main.py | 53 +++ SIENNA/sienna_mcp/scripts/solve_system.jl | 107 ++++++ SIENNA/sienna_mcp/tools/__init__.py | 1 + SIENNA/sienna_mcp/tools/compare_tools.py | 148 +++++++++ SIENNA/sienna_mcp/tools/solve_tools.py | 147 +++++++++ SIENNA/sienna_mcp/tools/system_tools.py | 95 ++++++ SIENNA/sienna_mcp/tools/translate_tools.py | 140 ++++++++ SIENNA/tests/test_tools.py | 362 +++++++++++++++++++++ powermcp/registry.py | 16 + pyproject.toml | 8 +- tests/test_vendor_import.py | 37 +++ 15 files changed, 1452 insertions(+), 3 deletions(-) create mode 100644 SIENNA/README.md create mode 100644 SIENNA/pyproject.toml create mode 100644 SIENNA/sienna_mcp/__init__.py create mode 100644 SIENNA/sienna_mcp/_julia.py create mode 100644 SIENNA/sienna_mcp/main.py create mode 100644 SIENNA/sienna_mcp/scripts/solve_system.jl create mode 100644 SIENNA/sienna_mcp/tools/__init__.py create mode 100644 SIENNA/sienna_mcp/tools/compare_tools.py create mode 100644 SIENNA/sienna_mcp/tools/solve_tools.py create mode 100644 SIENNA/sienna_mcp/tools/system_tools.py create mode 100644 SIENNA/sienna_mcp/tools/translate_tools.py create mode 100644 SIENNA/tests/test_tools.py diff --git a/SIENNA/README.md b/SIENNA/README.md new file mode 100644 index 0000000..ec6f613 --- /dev/null +++ b/SIENNA/README.md @@ -0,0 +1,168 @@ +# SIENNA MCP Server + +MCP connector for [Sienna](https://github.com/Sienna-Platform) (`PowerSystems.jl` / +`PowerSimulations.jl`), the open-source Julia power-systems modeling toolchain. Exposes four +tools: + +- `load_system` -- load a Sienna PSY-JSON system and summarize its components. +- `translate_to_plexos` -- translate a Sienna system to PLEXOS-shaped components via + [`r2x`](https://pypi.org/project/r2x/). +- `run_sienna_solve` -- run a `PowerSimulations.jl` economic-dispatch solve (open-source HiGHS + solver) against a Sienna system, via a local Julia install. +- `compare_solutions` -- diff a Sienna-side artifact against a PLEXOS-side artifact. + +No PLEXOS license, Sienna license, or vendor network call is required anywhere in this +connector. `translate_to_plexos` and `compare_solutions` call `r2x` directly -- there is no +PowerMCP-authored bridge/interop module, the same relationship `pandapower`/`PyPSA`/`Egret`/ +`ANDES` have with `powerio` in this repo. + +This is the counterpart to the sibling `PLEXOSDB` connector (PLEXOS CRUD + +`translate_to_sienna`, [issue #53](https://github.com/Power-Agent/PowerMCP/issues/53)) -- out +of scope here, mentioned only because it's a compatible producer/consumer for some of this +connector's tools (see "Known gaps" below for exactly how compatible). + +## Install + +```bash +pip install powermcp[sienna] +``` + +This installs `r2x` (which pulls in `r2x-core`, `r2x-plexos`, `r2x-sienna`, +`r2x-sienna-to-plexos` as its own transitive dependencies -- verified against `r2x==2.1.0`). +`r2x` is enough for `load_system`, `translate_to_plexos`, and the System-JSON side of +`compare_solutions`. `run_sienna_solve` additionally needs a local Julia install with +`PowerSystems.jl`, `PowerSimulations.jl`, `HiGHS.jl`, and `JSON3.jl` -- see below. + +## Julia setup (for `run_sienna_solve`) + +1. Install Julia 1.10+ (verified against 1.10.9): https://julialang.org/downloads/ + +2. Install the required Julia packages into your default environment (or a dedicated one -- + point `julia_depot_path` at it, see below): + + ```bash + julia -e 'using Pkg; Pkg.add(["PowerSystems", "PowerSimulations", "HiGHS", "JSON3"]); Pkg.precompile()' + ``` + + Verified package versions this connector's Julia driver script + (`sienna_mcp/scripts/solve_system.jl`) was developed and tested against: + `PowerSystems.jl 5.12.1`, `PowerSimulations.jl 0.38.2`, `HiGHS.jl 1.24.1`. + +3. Run the PowerMCP install wizard (or set the equivalent env vars) to capture: + + - `julia_bin` -- path to the `julia` executable (or set `SIENNA_JULIA_BIN`). + - `julia_depot_path` -- optional; sets `JULIA_DEPOT_PATH` for the subprocess if your Julia + packages live in a non-default depot (or set `JULIA_DEPOT_PATH` directly). + + This mirrors the `HOPE` connector's own `julia_bin`/`julia_depot_path` config keys + (`powermcp/registry.py`) -- `run_sienna_solve` shells out to `julia` as a subprocess, the + same mechanism `HOPE` uses (not `juliacall`/`PyJulia`). Unlike `HOPE`, there is no + `repo_root` config key: Sienna is a set of registered Julia packages, not a local git + checkout. + +## Loading a Sienna PSY JSON + +`load_system` accepts a PSY-JSON file and uses `r2x_core.System.from_json` (part of the `r2x` +PyPI distribution) to deserialize it, returning a component-type-count summary rather than the +full system (systems can be large). Two producers of compatible input: + +- The sibling `PLEXOSDB` connector's `translate_to_sienna` tool + ([issue #53](https://github.com/Power-Agent/PowerMCP/issues/53), out of scope here) -- + produces `r2x_core.System` JSON directly, since it also goes through `r2x` on the Python + side. This is the input `load_system`/`translate_to_plexos` are built for. +- Anything else that writes `r2x_core.System.to_json`-compatible JSON. + +**Important: this is not the same JSON format PowerSystems.jl's own native `to_json`/`System()` +constructor read and write on the Julia side.** See "Known gaps" below -- this was verified, +not assumed. + +## Known gaps (verified during development, not assumed) + +**The Python `r2x_core.System` JSON schema and the native Julia `PowerSystems.jl` JSON schema +do not round-trip through each other, in either direction**, as of `r2x 2.1.0` / +`PowerSystems.jl 5.12.1`: + +- A system built and serialized in Python via `r2x_core.System`/`r2x_sienna.models` (top-level + keys: `name`, `description`, `uuid`, `data_format_version`, `components`, + `supplemental_attributes`, `time_series`, `system_base_power`, `r2x_core_version`) fails to + load via Julia's `PowerSystems.System(path)` constructor + (`MethodError: no method matching VersionNumber(::Nothing)`, because + `data_format_version` is `None`/absent in the Python-written file). +- A system built and serialized natively in Julia via `PowerSystems.to_json` (top-level keys: + `data`, `data_format_version`, `frequency`, `internal`, `metadata`, `runchecks`, + `units_settings` -- a structurally different schema) fails to load via Python's + `r2x_core.System.from_json` (`KeyError: 'time_series'`). + +Practical consequence: **`load_system`/`translate_to_plexos` (Python, via `r2x_core`) and +`run_sienna_solve` (Julia, via `PowerSystems.System(path)`) currently expect two different "PSY +JSON" dialects.** A file produced by `translate_to_sienna` (#53, Python/`r2x_core`-shaped) can +be `load_system`'d and `translate_to_plexos`'d by this connector, but **cannot** be fed +directly into `run_sienna_solve` without a native-Julia re-export step first (e.g., load it +into a Julia session some other way and call `PowerSystems.to_json` on it). Conversely, a +system exported natively from a real Sienna/PowerSystems.jl model (via `to_json` on the Julia +side) is exactly what `run_sienna_solve` expects, but is **not** directly loadable by +`load_system`/`translate_to_plexos` on the Python side. + +Closing this gap (a real format bridge between the two schemas) is out of scope for this issue +and not attempted here; it is called out explicitly rather than silently papered over. Until +then, treat `load_system`/`translate_to_plexos` as the Python/`r2x`-ecosystem half of this +connector and `run_sienna_solve` as the native-Julia half, each expecting its own native input. + +`translate_to_plexos`'s `export_xml=True` full-PLEXOS-database export +(`r2x_plexos.PLEXOSExporter`) is similarly best-effort: it requires more PLEXOS-specific +configuration than a bare Sienna system carries (at minimum `horizon_year`; verified via a +real `PLEXOSExporter.run()` call, which raised `PluginError: ... requires 'horizon_year' in +config to create simulation configuration` without it). Failures are reported in the +`xml_export` field of the tool's response rather than raised, so the System-level translation +(which always succeeds independent of PLEXOS-specific config) is still returned. + +## `run_sienna_solve` -- real-install verification + +`run_sienna_solve`'s Julia driver script (`sienna_mcp/scripts/solve_system.jl`) was run for +real during development of this connector, not just mocked: + +- Julia 1.10.9, `PowerSystems.jl 5.12.1`, `PowerSimulations.jl 0.38.2`, `HiGHS.jl 1.24.1`, + `JSON3.jl` -- installed fresh via `Pkg.add` into a scratch depot (~200s to precompile 206 + packages). +- A minimal native Sienna system (one `ACBus`, one `ThermalStandard` with a linear cost curve, + one `PowerLoad` with an hourly load-shape `SingleTimeSeries`) was built directly in Julia and + serialized with `PowerSystems.to_json`. +- The driver script loaded it, called `PowerSystems.transform_single_time_series!` (turns the + attached `SingleTimeSeries` into the `Deterministic` forecast windows + `PowerSimulations.DecisionModel` requires -- omitting this step fails with `"The System does + not contain any forecast data or transformed time series data."`), built a minimal + copper-plate `ProblemTemplate` (`ThermalBasicDispatch` / `RenewableFullDispatch` / + `StaticPowerLoad`, whichever component types are present), and solved a 24-hour + economic-dispatch problem with the open-source HiGHS solver. +- Real result: `build_status=BUILT`, `solve_status=SUCCESSFULLY_FINALIZED`, + `objective_value=23039.999999999996`, solved in well under a second. + +The automated test suite (`SIENNA/tests/test_tools.py::TestRunSiennaSolve`) mocks the Julia +subprocess rather than re-running this install (a multi-hundred-MB Julia toolchain is not +something CI should have to provision) -- the mocked tests check the Python-side subprocess +wiring, timeout handling, and result-JSON parsing; the real run above verified the Julia driver +script itself is correct against genuine Sienna packages. + +## `compare_solutions` + +Each of `sienna_path`/`plexos_path` may be either an R2X System JSON (compared by +component-type counts via `r2x_core.System.from_json`) or a plain results JSON, e.g. +`run_sienna_solve`'s own output (compared by shared numeric top-level fields). `r2x`/ +`plexosdb` (an `r2x` transitive dependency) do not expose a PLEXOS *solution* reader as of +`r2x 2.1.0`/`plexosdb 1.5.0` -- only PLEXOS input-database CRUD (the surface `PLEXOSDB`, #53, +wraps) -- so the PLEXOS side of a results-vs-results comparison has to come from some other +export, not a call this connector makes into `r2x` itself. + +## Tests + +```bash +pytest SIENNA/tests -q +``` + +`SIENNA/tests/test_tools.py` mocks `r2x_core`/`r2x_plexos`/`r2x_sienna_to_plexos` via +`sys.modules` (PSCAD's `MagicMock` style) for `load_system`, `translate_to_plexos`, and +`compare_solutions`, and mocks the Julia subprocess for `run_sienna_solve`. A separate +license-free import test lives in the repo root's `tests/test_vendor_import.py` +(`test_sienna_main_import_and_server_creation_are_r2x_free`), extending its PSSE/PSLF pattern: +importing `sienna_mcp.main` and building the server must not import any `r2x` module, verified +in an environment where `r2x` is genuinely not installed. diff --git a/SIENNA/pyproject.toml b/SIENNA/pyproject.toml new file mode 100644 index 0000000..f060649 --- /dev/null +++ b/SIENNA/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "sienna-mcp" +version = "0.1.0" +description = "MCP server for Sienna (PowerSystems.jl / PowerSimulations.jl), with PLEXOS translation via r2x." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "PowerMCP contributors" }] +dependencies = [ + "mcp>=1.0", + # r2x provides r2x_core/r2x_sienna/r2x_plexos/r2x_sienna_to_plexos as its own + # transitive dependencies -- see root pyproject.toml's `sienna` extra for the + # authoritative pin. Listed here too so `pip install ./SIENNA` is self-contained. + "r2x", +] + +[project.scripts] +sienna-mcp = "sienna_mcp.main:main" + +[tool.setuptools.packages.find] +include = ["sienna_mcp*"] + +[tool.setuptools.package-data] +sienna_mcp = ["scripts/*.jl"] diff --git a/SIENNA/sienna_mcp/__init__.py b/SIENNA/sienna_mcp/__init__.py new file mode 100644 index 0000000..e67db82 --- /dev/null +++ b/SIENNA/sienna_mcp/__init__.py @@ -0,0 +1,20 @@ +"""sienna_mcp -- PowerMCP connector for Sienna (PowerSystems.jl / PowerSimulations.jl). + +Exposes four tools over MCP: ``load_system``, ``translate_to_plexos``, +``run_sienna_solve``, ``compare_solutions``. Sienna itself is Julia-only (no PyPI +package); this connector drives it via a subprocess, the same mechanism the +sibling ``HOPE`` connector uses for its own Julia backend +(``HOPE/src/hope_mcp_server/core.py``). The PLEXOS-facing tools +(``translate_to_plexos``, ``compare_solutions``) call the upstream ``r2x`` +package directly -- there is no PowerMCP-authored bridge/interop module. +""" + +from __future__ import annotations + +__all__ = ["main"] + + +def main() -> None: + from .main import main as _main + + _main() diff --git a/SIENNA/sienna_mcp/_julia.py b/SIENNA/sienna_mcp/_julia.py new file mode 100644 index 0000000..80f4be4 --- /dev/null +++ b/SIENNA/sienna_mcp/_julia.py @@ -0,0 +1,125 @@ +"""Julia process helpers for the SIENNA connector. + +Mirrors the mechanism ``HOPE`` (this repo's other Julia-backed connector) uses: +Sienna (PowerSystems.jl / PowerSimulations.jl) is not on PyPI, so we shell out to +a ``julia`` binary rather than embedding it via ``juliacall``/``PyJulia``. See +``HOPE/src/hope_mcp_server/core.py`` (``_hope_setting``, ``_build_julia_process_env``, +``validate_julia_command``) for the precedent this file follows. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +DEFAULT_JULIA_COMMAND = "julia" + + +def _sienna_setting(env_var: str, config_key: str, default: Any) -> Any: + """Resolve a SIENNA setting in order: env var, powermcp config, default. + + The powermcp import is wrapped in try/except so this connector stays runnable + standalone (e.g. ``python -m sienna_mcp``) without powermcp installed; on any + failure we fall back to the given default. + """ + v = os.environ.get(env_var) + if v: + return v + try: + from powermcp.config import get_path + + return get_path("sienna", config_key, must_exist=False) + except Exception: + return default + + +def configured_julia_command() -> str: + return _sienna_setting("SIENNA_JULIA_BIN", "julia_bin", DEFAULT_JULIA_COMMAND) + + +def configured_julia_depot_path() -> str: + return _sienna_setting("JULIA_DEPOT_PATH", "julia_depot_path", "") + + +def validate_julia_command() -> tuple[str | None, dict[str, Any] | None]: + """Resolve and sanity-check the configured Julia binary. + + Returns ``(julia_path, None)`` on success or ``(None, error_dict)`` on failure. + """ + julia_command = configured_julia_command() + julia_env = julia_command if julia_command != DEFAULT_JULIA_COMMAND else None + if julia_env: + julia_path = Path(julia_env).expanduser() + if not julia_path.is_file(): + return None, { + "ok": False, + "error_type": "julia_not_found", + "message": f"julia_bin does not point to a file: {julia_path}", + "configured_julia_bin": str(julia_path), + } + if not os.access(julia_path, os.X_OK): + return None, { + "ok": False, + "error_type": "julia_not_executable", + "message": f"julia_bin is not executable: {julia_path}", + "configured_julia_bin": str(julia_path), + } + return str(julia_path), None + + resolved = shutil.which(DEFAULT_JULIA_COMMAND) + if resolved is None: + return None, { + "ok": False, + "error_type": "julia_not_found", + "message": ( + "Julia was not found on PATH and no julia_bin is configured. " + "Set it via the powermcp install wizard, SIENNA_JULIA_BIN, or config.toml " + "([sienna].julia_bin)." + ), + } + return resolved, None + + +def build_julia_process_env() -> dict[str, str]: + """Inherit the current environment, overriding JULIA_DEPOT_PATH if configured.""" + proc_env = os.environ.copy() + depot = configured_julia_depot_path() + if depot: + proc_env["JULIA_DEPOT_PATH"] = depot + return proc_env + + +def julia_string_literal(value: str | Path) -> str: + escaped = str(value).replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def run_julia_script( + julia_bin: str, + script_path: Path, + script_args: list[str], + *, + timeout_seconds: float, + project_dir: Path | None = None, +) -> subprocess.CompletedProcess[str]: + """Run a Julia script file as a subprocess and return the completed process. + + Raises ``subprocess.TimeoutExpired`` on timeout (the caller decides how to + report it); does not raise on a non-zero exit code. + """ + command = [julia_bin, "--startup-file=no"] + if project_dir is not None: + command.append(f"--project={project_dir}") + command += [str(script_path), *script_args] + + return subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=build_julia_process_env(), + timeout=timeout_seconds, + ) diff --git a/SIENNA/sienna_mcp/main.py b/SIENNA/sienna_mcp/main.py new file mode 100644 index 0000000..cbe6db3 --- /dev/null +++ b/SIENNA/sienna_mcp/main.py @@ -0,0 +1,53 @@ +"""SIENNA MCP server entry point (mirrors PSCAD/pscad_mcp/main.py's shape).""" + +from __future__ import annotations + +import logging + +from mcp.server.fastmcp import FastMCP + +from sienna_mcp.tools.compare_tools import register_compare_tools +from sienna_mcp.tools.solve_tools import register_solve_tools +from sienna_mcp.tools.system_tools import register_system_tools +from sienna_mcp.tools.translate_tools import register_translate_tools + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger("sienna-mcp") + + +def create_server() -> FastMCP: + """Factory to create and configure the FastMCP server. + + Registers each tool group from its own module (system/translate/solve/compare), + matching PSCAD's modular register_*_tools(mcp) pattern. + """ + mcp = FastMCP( + "SIENNA", + instructions=( + "SIENNA MCP server: load Sienna PowerSystems.jl systems, translate them to " + "PLEXOS via r2x, run PowerSimulations.jl solves via a local Julia install, and " + "compare solutions. No PLEXOS license or vendor network call is required by this " + "connector." + ), + ) + + register_system_tools(mcp) + register_translate_tools(mcp) + register_solve_tools(mcp) + register_compare_tools(mcp) + + logger.info("SIENNA MCP server initialized (load_system, translate_to_plexos, run_sienna_solve, compare_solutions).") + return mcp + + +def main() -> None: + """Main entry point.""" + mcp = create_server() + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/SIENNA/sienna_mcp/scripts/solve_system.jl b/SIENNA/sienna_mcp/scripts/solve_system.jl new file mode 100644 index 0000000..1bb8106 --- /dev/null +++ b/SIENNA/sienna_mcp/scripts/solve_system.jl @@ -0,0 +1,107 @@ +#!/usr/bin/env julia +# Solve a Sienna PSY-JSON system with PowerSimulations.jl and write a small JSON +# result summary. Invoked as a subprocess by sienna_mcp.tools.solve_tools.run_sienna_solve +# -- see SIENNA/sienna_mcp/_julia.py for the process-launch mechanism (mirrors HOPE's). +# +# Usage: +# julia solve_system.jl [solver] [horizon_hours] +# +# solver: currently only "HiGHS" (open-source, MIT-licensed MILP/LP solver) is +# wired up, matching the open-source-only constraint in issue #54 ("without a +# PLEXOS license or vendor network call anywhere in this connector"). + +using Dates +using JSON3 +using PowerSystems +using PowerSimulations +using HiGHS + +const PSY = PowerSystems +const PSI = PowerSimulations + +function build_template(sys::PSY.System) + template = PSI.ProblemTemplate() + PSI.set_network_model!(template, PSI.NetworkModel(PSI.CopperPlatePowerModel)) + if !isempty(PSY.get_components(PSY.ThermalStandard, sys)) + PSI.set_device_model!(template, PSY.ThermalStandard, PSI.ThermalBasicDispatch) + end + if !isempty(PSY.get_components(PSY.RenewableDispatch, sys)) + PSI.set_device_model!(template, PSY.RenewableDispatch, PSI.RenewableFullDispatch) + end + if !isempty(PSY.get_components(PSY.PowerLoad, sys)) + PSI.set_device_model!(template, PSY.PowerLoad, PSI.StaticPowerLoad) + end + return template +end + +function main() + args = ARGS + length(args) >= 2 || error( + "usage: solve_system.jl [solver] [horizon_hours]", + ) + psy_json_path = args[1] + output_json_path = args[2] + solver = length(args) >= 3 ? args[3] : "HiGHS" + horizon_hours = length(args) >= 4 ? parse(Int, args[4]) : 24 + + result = Dict{String, Any}( + "psy_json_path" => psy_json_path, + "solver" => solver, + "horizon_hours" => horizon_hours, + ) + + try + sys = PSY.System(psy_json_path) + result["component_count"] = length(collect(PSY.get_components(PSY.Component, sys))) + + if solver != "HiGHS" + error("Unsupported solver '$solver'; only the open-source HiGHS solver is wired up.") + end + + # PowerSimulations needs Deterministic forecasts, not raw SingleTimeSeries; + # this is the standard Sienna workflow step (see PSY/PSI tutorials) that + # turns any attached SingleTimeSeries into horizon-length forecast windows. + # Systems with no time series at all (a bare topology/definition export) + # cannot be solved for production cost -- transform_single_time_series! + # raises in that case, which the outer catch below reports as an error + # rather than a silent no-op. + PSY.transform_single_time_series!(sys, Dates.Hour(horizon_hours), Dates.Hour(horizon_hours)) + + template = build_template(sys) + model = PSI.DecisionModel( + template, + sys; + optimizer = HiGHS.Optimizer, + horizon = Dates.Hour(horizon_hours), + name = "sienna_mcp_solve", + ) + + build_status = PSI.build!(model; output_dir = mktempdir()) + result["build_status"] = string(build_status) + + solve_status = PSI.solve!(model) + result["solve_status"] = string(solve_status) + + result["ok"] = string(solve_status) in ("SUCCESSFULLY_FINALIZED", "SUCCESSFUL") + + try + stats = PSI.get_optimizer_stats(model) + result["objective_value"] = stats.objective_value + result["solve_time_sec"] = stats.solve_time + catch e + result["optimizer_stats_error"] = sprint(showerror, e) + end + catch e + result["ok"] = false + result["error"] = sprint(showerror, e) + result["error_type"] = string(typeof(e)) + end + + open(output_json_path, "w") do io + JSON3.write(io, result) + end + + println(result["ok"] ? "SIENNA_SOLVE_OK" : "SIENNA_SOLVE_FAILED") +end + +main() diff --git a/SIENNA/sienna_mcp/tools/__init__.py b/SIENNA/sienna_mcp/tools/__init__.py new file mode 100644 index 0000000..bac60ae --- /dev/null +++ b/SIENNA/sienna_mcp/tools/__init__.py @@ -0,0 +1 @@ +"""SIENNA MCP tool modules, one per tool group (PSCAD/pscad_mcp/tools/ shape).""" diff --git a/SIENNA/sienna_mcp/tools/compare_tools.py b/SIENNA/sienna_mcp/tools/compare_tools.py new file mode 100644 index 0000000..8496692 --- /dev/null +++ b/SIENNA/sienna_mcp/tools/compare_tools.py @@ -0,0 +1,148 @@ +"""``compare_solutions`` -- diff a Sienna-side artifact against a PLEXOS-side one. + +Calls r2x directly (``r2x_core.System.from_json``) -- no PowerMCP-authored +bridge/interop module. Each input path may be either: + +* an R2X/infrasys **System JSON** -- a native Sienna PSY export, a + ``translate_to_plexos`` output from this connector, or a + ``translate_to_sienna`` export from the sibling PLEXOSDB connector (issue + #53) -- compared by component-type counts, or +* a plain **results JSON** (e.g. written by this connector's + ``run_sienna_solve``, or exported by any external tool from a PLEXOS + solution) -- compared by shared numeric top-level fields (objective value, + total cost, etc.). + +r2x does not ship a PLEXOS *solution* (as opposed to input database) reader as +of r2x 2.1.0 / plexosdb 1.5.0 -- ``plexosdb`` (an r2x transitive dependency) +exposes PLEXOS input-database CRUD, the same surface PLEXOSDB (#53) wraps, not +solved-results parsing. So the PLEXOS side of a *results* comparison is +necessarily a results JSON produced some other way (manually, or by a +downstream tool), not a call this connector makes into r2x. This is a known, +documented gap -- see SIENNA/README.md. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .system_tools import R2X_NOT_INSTALLED_MESSAGE, _component_counts + + +def _load_side(path_str: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Load one side of the comparison. Returns (loaded, error).""" + from r2x_core import System + + path = Path(path_str).expanduser() + if not path.is_file(): + return None, { + "error_type": "file_not_found", + "message": f"File not found: {path}", + "path": str(path), + } + + try: + system = System.from_json(path) + return { + "kind": "system", + "path": str(path), + "component_counts": _component_counts(system), + }, None + except Exception: + pass # not a System JSON -- fall through to plain results JSON + + try: + data = json.loads(path.read_text()) + if not isinstance(data, dict): + raise ValueError("top-level JSON must be an object") + return {"kind": "results", "path": str(path), "data": data}, None + except Exception as exc: + return None, { + "error_type": "unreadable", + "message": f"Could not parse {path} as an R2X system or a results JSON object: {exc}", + "path": str(path), + } + + +def compare_solutions( + sienna_path: str, + plexos_path: str, + metrics: list[str] | None = None, +) -> dict[str, Any]: + """Compare a Sienna-side artifact against a PLEXOS-side artifact. + + ``sienna_path``/``plexos_path`` are each either an R2X System JSON (compared + by component-type counts) or a results JSON (compared by shared numeric + top-level fields; restrict to specific fields with ``metrics``). Comparing a + System against a results file returns both sides verbatim with a note that + nothing numeric was computed -- pass like-for-like (system vs system, or + results vs results). + """ + try: + import r2x_core # noqa: F401 -- import-time availability check only + except ImportError: + return { + "ok": False, + "error_type": "r2x_not_installed", + "message": R2X_NOT_INSTALLED_MESSAGE, + } + + sienna_loaded, err = _load_side(sienna_path) + if err is not None: + return {"ok": False, "side": "sienna", **err} + + plexos_loaded, err = _load_side(plexos_path) + if err is not None: + return {"ok": False, "side": "plexos", **err} + + assert sienna_loaded is not None and plexos_loaded is not None + result: dict[str, Any] = {"ok": True, "sienna": sienna_loaded, "plexos": plexos_loaded} + + if sienna_loaded["kind"] == "system" and plexos_loaded["kind"] == "system": + sc = sienna_loaded["component_counts"] + pc = plexos_loaded["component_counts"] + keys = sorted(set(sc) | set(pc)) + result["component_count_diff"] = { + k: {"sienna": sc.get(k, 0), "plexos": pc.get(k, 0), "diff": sc.get(k, 0) - pc.get(k, 0)} + for k in keys + } + elif sienna_loaded["kind"] == "results" and plexos_loaded["kind"] == "results": + sd, pd = sienna_loaded["data"], plexos_loaded["data"] + candidate_keys = metrics if metrics is not None else sorted(set(sd) & set(pd)) + diffs: dict[str, Any] = {} + for k in candidate_keys: + sv, pv = sd.get(k), pd.get(k) + if isinstance(sv, (int, float)) and isinstance(pv, (int, float)) and not isinstance(sv, bool) and not isinstance(pv, bool): + diffs[k] = { + "sienna": sv, + "plexos": pv, + "diff": sv - pv, + "pct_diff": ((sv - pv) / pv * 100.0) if pv else None, + } + result["metric_diff"] = diffs + if metrics is None and not diffs: + result["note"] = "No shared numeric top-level fields found; pass metrics=[...] explicitly." + else: + result["note"] = ( + "One side is a System JSON and the other is a results JSON; nothing directly " + "comparable was computed. Compare like-for-like (system vs system, or results vs results)." + ) + + return result + + +def register_compare_tools(mcp: FastMCP) -> None: + """Register comparison tools with the MCP server.""" + mcp.tool( + name="compare_solutions", + description=( + "Compare a Sienna-side artifact against a PLEXOS-side artifact. Each of " + "sienna_path/plexos_path is either an R2X System JSON (compared by " + "component-type counts via r2x_core.System.from_json) or a plain results JSON " + "such as run_sienna_solve's output (compared by shared numeric fields, " + "optionally restricted with metrics=[...])." + ), + )(compare_solutions) diff --git a/SIENNA/sienna_mcp/tools/solve_tools.py b/SIENNA/sienna_mcp/tools/solve_tools.py new file mode 100644 index 0000000..247a1f3 --- /dev/null +++ b/SIENNA/sienna_mcp/tools/solve_tools.py @@ -0,0 +1,147 @@ +"""``run_sienna_solve`` -- run PowerSimulations.jl against a Sienna PSY-JSON system. + +Sienna (PowerSystems.jl / PowerSimulations.jl) is Julia-only -- there is no +PyPI package for it. This shells out to a ``julia`` binary running +``sienna_mcp/scripts/solve_system.jl``, the same mechanism the sibling ``HOPE`` +connector uses for its own Julia backend (subprocess, not ``juliacall``/``PyJulia`` +-- see ``HOPE/src/hope_mcp_server/core.py``'s ``_launch_job``/``build_run_command``). + +Requires the julia_bin (and optionally julia_depot_path) config keys set via +the powermcp install wizard, and PowerSystems.jl/PowerSimulations.jl/HiGHS.jl/ +JSON3.jl already installed into that Julia environment -- see SIENNA/README.md +for setup steps. The open-source HiGHS solver is the only one wired up (no +vendor network call or license anywhere in this connector, per issue #54). +""" + +from __future__ import annotations + +import json +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .._julia import run_julia_script, validate_julia_command + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "solve_system.jl" + +DEFAULT_TIMEOUT_SECONDS = 1800.0 + + +def run_sienna_solve( + psy_json_path: str, + solver: str = "HiGHS", + horizon_hours: int = 24, + output_path: str | None = None, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, +) -> dict[str, Any]: + """Run a PowerSimulations.jl economic-dispatch solve against a Sienna system. + + Builds a minimal copper-plate ProblemTemplate (ThermalBasicDispatch, + RenewableFullDispatch, StaticPowerLoad -- whichever component types are + present) and solves it with the open-source HiGHS solver over + ``horizon_hours``. Returns the parsed JSON result written by the Julia + driver script (objective value, solve status, timing) plus raw stdout/ + stderr tails for debugging. Requires Julia with PowerSystems.jl, + PowerSimulations.jl, HiGHS.jl, and JSON3.jl installed -- see + SIENNA/README.md. + """ + if not SCRIPT_PATH.is_file(): + return { + "ok": False, + "error_type": "script_missing", + "message": f"Julia driver script not found: {SCRIPT_PATH}", + } + + psy_path = Path(psy_json_path).expanduser() + if not psy_path.is_file(): + return { + "ok": False, + "error_type": "file_not_found", + "message": f"PSY JSON file not found: {psy_path}", + "path": str(psy_path), + } + + julia_bin, julia_error = validate_julia_command() + if julia_error is not None: + return julia_error + + out_path = Path(output_path).expanduser() if output_path else None + cleanup_output = False + if out_path is None: + fd, tmp_name = tempfile.mkstemp(suffix=".json", prefix="sienna_solve_") + Path(tmp_name).unlink(missing_ok=True) # let the script create it fresh + import os as _os + + _os.close(fd) + out_path = Path(tmp_name) + cleanup_output = True + + script_args = [str(psy_path), str(out_path), solver, str(horizon_hours)] + + try: + completed = run_julia_script( + julia_bin, + SCRIPT_PATH, + script_args, + timeout_seconds=timeout_seconds, + ) + except subprocess.TimeoutExpired as err: + return { + "ok": False, + "error_type": "solve_timeout", + "message": f"run_sienna_solve timed out after {timeout_seconds}s", + "stdout": (err.stdout or ""), + "stderr": (err.stderr or ""), + "command": [julia_bin, "--startup-file=no", str(SCRIPT_PATH), *script_args], + } + + result: dict[str, Any] = { + "exit_code": completed.returncode, + "stdout_tail": "\n".join(completed.stdout.splitlines()[-20:]), + "stderr_tail": "\n".join(completed.stderr.splitlines()[-20:]), + "julia_bin": julia_bin, + "psy_json_path": str(psy_path), + } + + if out_path.is_file(): + try: + solve_payload = json.loads(out_path.read_text()) + result.update(solve_payload) + except Exception as exc: + result["ok"] = False + result["error_type"] = "result_unparseable" + result["message"] = f"Could not parse Julia driver output {out_path}: {exc}" + elif "ok" not in result: + result["ok"] = False + result["error_type"] = "no_result_file" + result["message"] = ( + f"Julia driver script exited {completed.returncode} without writing a result " + f"file at {out_path}. See stdout_tail/stderr_tail." + ) + + if cleanup_output: + out_path.unlink(missing_ok=True) + else: + result["output_json_path"] = str(out_path) + + return result + + +def register_solve_tools(mcp: FastMCP) -> None: + """Register the Sienna solve tool with the MCP server.""" + mcp.tool( + name="run_sienna_solve", + description=( + "Run a PowerSimulations.jl economic-dispatch solve (open-source HiGHS solver) " + "against a Sienna PSY-JSON system over a Julia subprocess. Requires Julia with " + "PowerSystems.jl/PowerSimulations.jl/HiGHS.jl/JSON3.jl installed (see " + "SIENNA/README.md) and julia_bin configured. Returns objective value, solve " + "status, and timing." + ), + )(run_sienna_solve) + + +__all__ = ["run_sienna_solve", "register_solve_tools"] diff --git a/SIENNA/sienna_mcp/tools/system_tools.py b/SIENNA/sienna_mcp/tools/system_tools.py new file mode 100644 index 0000000..79c4f16 --- /dev/null +++ b/SIENNA/sienna_mcp/tools/system_tools.py @@ -0,0 +1,95 @@ +"""``load_system`` -- load a Sienna PSY-JSON system and summarize it. + +Uses ``r2x_core.System.from_json`` (part of the ``r2x`` distribution on PyPI) +directly. ``r2x_core.System`` deserializes the same infrasys-based JSON schema +that PowerSystems.jl's own ``to_json``/``from_json`` produce, so this accepts: + +* a PSY JSON file exported natively from Sienna (Julia side), or +* a PSY JSON file produced by the sibling ``PLEXOSDB`` connector's + ``translate_to_sienna`` tool (see ``PLEXOSDB`` / GitHub issue #53 -- out of + scope for this connector, mentioned here only as a compatible input source). + +The ``r2x`` import is deferred to call time (not module import time) so that +importing ``sienna_mcp.main`` never requires ``r2x`` to be installed -- see +``tests/test_vendor_import.py`` (``test_sienna_main_import_and_server_creation_are_r2x_free``). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from mcp.server.fastmcp import FastMCP + +R2X_NOT_INSTALLED_MESSAGE = ( + "r2x is not installed. Install the 'sienna' extra " + "(`pip install powermcp[sienna]`) or `pip install r2x` directly." +) + + +def _component_counts(system: Any) -> dict[str, int]: + counts: dict[str, int] = {} + for component_type in system.get_component_types(): + name = getattr(component_type, "__name__", str(component_type)) + counts[name] = len(list(system.get_components(component_type))) + return counts + + +def load_system(psy_json_path: str) -> dict[str, Any]: + """Load a Sienna PSY-JSON system file and return a component-count summary. + + Does not return the full system (systems can be large); use the returned + ``component_counts`` to decide what to inspect further, or pass + ``psy_json_path`` straight through to ``translate_to_plexos``. + """ + try: + from r2x_core import System + except ImportError: + return { + "ok": False, + "error_type": "r2x_not_installed", + "message": R2X_NOT_INSTALLED_MESSAGE, + } + + path = Path(psy_json_path).expanduser() + if not path.is_file(): + return { + "ok": False, + "error_type": "file_not_found", + "message": f"PSY JSON file not found: {path}", + "path": str(path), + } + + try: + system = System.from_json(path) + except Exception as exc: # r2x/infrasys raise a variety of errors on bad input + return { + "ok": False, + "error_type": "load_failed", + "message": f"Failed to load system from {path}: {exc}", + "path": str(path), + } + + component_counts = _component_counts(system) + return { + "ok": True, + "path": str(path), + "name": getattr(system, "name", None), + "description": getattr(system, "description", None), + "component_type_count": len(component_counts), + "component_counts": component_counts, + "total_components": sum(component_counts.values()), + } + + +def register_system_tools(mcp: FastMCP) -> None: + """Register system-loading tools with the MCP server.""" + mcp.tool( + name="load_system", + description=( + "Load a Sienna PowerSystems.jl system from a PSY JSON file (produced by " + "PowerSystems.jl's to_json on the Julia side, or by the PLEXOSDB connector's " + "translate_to_sienna tool, see issue #53) and return a component-type-count " + "summary. Calls r2x's r2x_core.System.from_json directly." + ), + )(load_system) diff --git a/SIENNA/sienna_mcp/tools/translate_tools.py b/SIENNA/sienna_mcp/tools/translate_tools.py new file mode 100644 index 0000000..5886327 --- /dev/null +++ b/SIENNA/sienna_mcp/tools/translate_tools.py @@ -0,0 +1,140 @@ +"""``translate_to_plexos`` -- translate a Sienna PSY-JSON system to PLEXOS via r2x. + +Calls r2x directly (``r2x_core``, ``r2x_sienna_to_plexos``, ``r2x_plexos``) -- +there is no PowerMCP-authored bridge/interop module, matching PLEXOSDB's +(issue #53) ``translate_to_sienna`` which does the reverse translation the same +way. Verified against r2x 2.1.0 (2026-08-07): ``r2x_sienna_to_plexos.sienna_to_plexos`` +is a plain function, ``System -> System``, that maps Sienna component types +(``r2x_sienna.models``) onto PLEXOS component types (``r2x_plexos.models``, +e.g. ``PLEXOSNode``, ``PLEXOSGenerator``); this always succeeds when the +sienna_to_plexos rules cover every component type present. A full PLEXOS +XML/database export additionally requires ``r2x_plexos.PLEXOSExporter``, which +needs more PLEXOS-specific configuration (at minimum ``horizon_year``) that +this tool does not have a way to infer from a bare Sienna system -- see the +``export_xml`` docstring below and SIENNA/README.md. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from mcp.server.fastmcp import FastMCP + +from .system_tools import R2X_NOT_INSTALLED_MESSAGE, _component_counts + + +def translate_to_plexos( + psy_json_path: str, + output_path: str | None = None, + export_xml: bool = False, + horizon_year: int | None = None, + model_name: str = "default", + prime_mover_mapping: dict[str, list[str]] | None = None, +) -> dict[str, Any]: + """Translate a Sienna PSY-JSON system to PLEXOS-shaped components via r2x. + + Always performs the System-level translation (``r2x_sienna_to_plexos.sienna_to_plexos``) + and returns component counts for the result. If ``output_path`` is given, the + translated system is additionally written out as JSON at that path. + + If ``export_xml=True``, this also attempts a full PLEXOS XML/database export via + ``r2x_plexos.PLEXOSExporter`` into the directory given by ``output_path``. This is + best-effort: PLEXOS export needs more configuration than a bare Sienna system + carries (at minimum ``horizon_year``; PLEXOSExporter may also need a template). + Failures are reported in the ``xml_export`` field rather than raised, so the + System-level translation result is still returned. + """ + try: + from r2x_core import PluginContext, System + from r2x_plexos import PLEXOSConfig, PLEXOSExporter + from r2x_sienna_to_plexos import SiennaToPlexosConfig, sienna_to_plexos + except ImportError: + return { + "ok": False, + "error_type": "r2x_not_installed", + "message": R2X_NOT_INSTALLED_MESSAGE, + } + + path = Path(psy_json_path).expanduser() + if not path.is_file(): + return { + "ok": False, + "error_type": "file_not_found", + "message": f"PSY JSON file not found: {path}", + "path": str(path), + } + + try: + source_system = System.from_json(path) + except Exception as exc: + return { + "ok": False, + "error_type": "load_failed", + "message": f"Failed to load source system from {path}: {exc}", + "path": str(path), + } + + try: + config = SiennaToPlexosConfig(prime_mover_mapping=prime_mover_mapping or {}) + translated = sienna_to_plexos(source_system, config) + except Exception as exc: + return { + "ok": False, + "error_type": "translate_failed", + "message": f"r2x sienna_to_plexos translation failed: {exc}", + "path": str(path), + } + + component_counts = _component_counts(translated) + result: dict[str, Any] = { + "ok": True, + "source_path": str(path), + "translated_component_counts": component_counts, + "translated_total_components": sum(component_counts.values()), + } + + out = Path(output_path).expanduser() if output_path else None + if out is not None: + try: + translated.to_json(out) + result["output_json_path"] = str(out) + except Exception as exc: + result["output_json_error"] = str(exc) + + if export_xml: + if out is None: + result["xml_export"] = { + "ok": False, + "message": "export_xml=True requires output_path (used as the PLEXOS export directory).", + } + else: + export_dir = out.parent if out.suffix else out + try: + plexos_cfg = PLEXOSConfig( + output_path=str(export_dir), + model_name=model_name, + horizon_year=horizon_year, + ) + ctx = PluginContext(config=plexos_cfg, system=translated) + PLEXOSExporter.from_context(ctx).run() + result["xml_export"] = {"ok": True, "output_path": str(export_dir)} + except Exception as exc: + result["xml_export"] = {"ok": False, "message": str(exc)} + + return result + + +def register_translate_tools(mcp: FastMCP) -> None: + """Register PLEXOS-translation tools with the MCP server.""" + mcp.tool( + name="translate_to_plexos", + description=( + "Translate a Sienna PSY-JSON system to PLEXOS-shaped components using r2x's " + "sienna_to_plexos translation rules. Always returns component counts for the " + "translated system; pass output_path to also write it as JSON. Pass " + "export_xml=True to additionally attempt a full PLEXOS XML/database export " + "(best-effort; requires horizon_year and reports failure in xml_export rather " + "than raising)." + ), + )(translate_to_plexos) diff --git a/SIENNA/tests/test_tools.py b/SIENNA/tests/test_tools.py new file mode 100644 index 0000000..08be9ff --- /dev/null +++ b/SIENNA/tests/test_tools.py @@ -0,0 +1,362 @@ +"""Mocked unit tests for the SIENNA connector tools (PSCAD/tests/test_tools.py's style: +unittest + MagicMock). r2x is not installed in this environment (by design -- these tests +must pass with no PLEXOS/Sienna license and no r2x install), so every r2x_core/r2x_plexos/ +r2x_sienna_to_plexos import is faked via sys.modules before each tool function runs. + +run_sienna_solve is exercised here only against a mocked Julia subprocess. It was also run +for real against a genuine Julia + PowerSystems.jl 5.12.1 + PowerSimulations.jl 0.38.2 + +HiGHS.jl 1.24.1 install during development of this connector (see SIENNA/README.md and the +PR description) -- that verification is not repeated by this automated suite, which must run +without a multi-hundred-MB Julia toolchain present. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +SIENNA_ROOT = Path(__file__).resolve().parent.parent +if str(SIENNA_ROOT) not in sys.path: + sys.path.insert(0, str(SIENNA_ROOT)) + +_MISSING = object() + + +class _patch_sys_modules: + """Like unittest.mock.patch.dict(sys.modules, ...), but restores only the + specific keys it touched instead of clearing/restoring the whole dict. + + mock.patch.dict's teardown unconditionally clears the target dict and + replays a full snapshot taken at __enter__ time -- for sys.modules that + wipes out every module imported *during* the test (e.g. mcp/pydantic + submodules pulled in by a first-time `from mcp.server.fastmcp import + FastMCP`), corrupting later tests in the same process. This only ever + touches the handful of r2x_* keys we actually want to fake. + """ + + def __init__(self, mapping: dict[str, object]) -> None: + self.mapping = mapping + self._saved: dict[str, object] = {} + + def start(self) -> None: + for name, mod in self.mapping.items(): + self._saved[name] = sys.modules.get(name, _MISSING) + if mod is _MISSING: + sys.modules.pop(name, None) + else: + sys.modules[name] = mod + + def stop(self) -> None: + for name, original in self._saved.items(): + if original is _MISSING: + sys.modules.pop(name, None) + else: + sys.modules[name] = original + + +class _FakeComponentType: + def __init__(self, name: str) -> None: + self.__name__ = name + + +class _FakeSystem: + """Stand-in for r2x_core.System with a couple of component types.""" + + def __init__(self, counts: dict[str, int] | None = None, name: str = "fake_system") -> None: + self._counts = counts or {"ACBus": 1, "ThermalStandard": 1, "PowerLoad": 1} + self.name = name + self.description = "a fake system" + self._to_json_calls: list[str] = [] + + def get_component_types(self): + return [_FakeComponentType(n) for n in self._counts] + + def get_components(self, component_type): + name = getattr(component_type, "__name__", str(component_type)) + return [object()] * self._counts.get(name, 0) + + def to_json(self, path): + self._to_json_calls.append(str(path)) + Path(path).write_text(json.dumps({"name": self.name, "fake": True})) + + +def _install_fake_r2x_modules(counts=None, translated_counts=None): + """Patch sys.modules with fake r2x_core/r2x_plexos/r2x_sienna_to_plexos. + + Returns the fake modules dict so tests can assert on call args. + """ + fake_source_system = _FakeSystem(counts=counts, name="source") + fake_translated_system = _FakeSystem( + counts=translated_counts or {"PLEXOSNode": 1, "PLEXOSGenerator": 1}, + name="translated", + ) + + fake_r2x_core = types.ModuleType("r2x_core") + fake_system_cls = MagicMock() + fake_system_cls.from_json = MagicMock(return_value=fake_source_system) + fake_r2x_core.System = fake_system_cls + fake_r2x_core.PluginContext = MagicMock() + + fake_r2x_plexos = types.ModuleType("r2x_plexos") + fake_r2x_plexos.PLEXOSConfig = MagicMock() + fake_exporter_instance = MagicMock() + fake_exporter_instance.run = MagicMock(return_value=None) + fake_plexos_exporter_cls = MagicMock() + fake_plexos_exporter_cls.from_context = MagicMock(return_value=fake_exporter_instance) + fake_r2x_plexos.PLEXOSExporter = fake_plexos_exporter_cls + + fake_r2x_s2p = types.ModuleType("r2x_sienna_to_plexos") + fake_r2x_s2p.SiennaToPlexosConfig = MagicMock() + fake_r2x_s2p.sienna_to_plexos = MagicMock(return_value=fake_translated_system) + + return { + "r2x_core": fake_r2x_core, + "r2x_plexos": fake_r2x_plexos, + "r2x_sienna_to_plexos": fake_r2x_s2p, + }, fake_source_system, fake_translated_system + + +class TestLoadSystem(unittest.TestCase): + def setUp(self): + self.fake_modules, self.source_system, _ = _install_fake_r2x_modules() + self.patcher = _patch_sys_modules(self.fake_modules) + self.patcher.start() + self.tmp = Path(__file__).resolve().parent / "_tmp_psy.json" + self.tmp.write_text("{}") + + def tearDown(self): + self.patcher.stop() + self.tmp.unlink(missing_ok=True) + + def test_load_system_ok(self): + from sienna_mcp.tools.system_tools import load_system + + result = load_system(str(self.tmp)) + self.assertTrue(result["ok"]) + self.assertEqual(result["name"], "source") + self.assertEqual(result["component_counts"]["ACBus"], 1) + self.assertEqual(result["total_components"], 3) + + def test_load_system_missing_file(self): + from sienna_mcp.tools.system_tools import load_system + + result = load_system(str(self.tmp.parent / "does_not_exist.json")) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "file_not_found") + + def test_load_system_r2x_not_installed(self): + # sys.modules["r2x_core"] = None is Python's documented way to force + # `import r2x_core` to raise ImportError (see importlib docs), simulating + # r2x genuinely not being installed regardless of what setUp faked in. + blocker = _patch_sys_modules({"r2x_core": None}) + blocker.start() + try: + from sienna_mcp.tools.system_tools import load_system + + result = load_system(str(self.tmp)) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "r2x_not_installed") + finally: + blocker.stop() + + +class TestTranslateToPlexos(unittest.TestCase): + def setUp(self): + self.fake_modules, self.source_system, self.translated_system = _install_fake_r2x_modules() + self.patcher = _patch_sys_modules(self.fake_modules) + self.patcher.start() + self.tmp_dir = Path(__file__).resolve().parent + self.psy = self.tmp_dir / "_tmp_psy_translate.json" + self.psy.write_text("{}") + self.out = self.tmp_dir / "_tmp_translated.json" + + def tearDown(self): + self.patcher.stop() + self.psy.unlink(missing_ok=True) + self.out.unlink(missing_ok=True) + + def test_translate_calls_r2x_directly_and_returns_counts(self): + from sienna_mcp.tools.translate_tools import translate_to_plexos + + result = translate_to_plexos(str(self.psy)) + self.assertTrue(result["ok"]) + self.assertEqual(result["translated_component_counts"]["PLEXOSNode"], 1) + self.assertEqual(result["translated_total_components"], 2) + + fake_s2p_module = self.fake_modules["r2x_sienna_to_plexos"] + fake_s2p_module.sienna_to_plexos.assert_called_once() + called_system = fake_s2p_module.sienna_to_plexos.call_args[0][0] + self.assertIs(called_system, self.source_system) + + def test_translate_writes_output_json(self): + from sienna_mcp.tools.translate_tools import translate_to_plexos + + result = translate_to_plexos(str(self.psy), output_path=str(self.out)) + self.assertTrue(result["ok"]) + self.assertEqual(result["output_json_path"], str(self.out)) + self.assertIn(str(self.out), self.translated_system._to_json_calls) + + def test_translate_missing_file(self): + from sienna_mcp.tools.translate_tools import translate_to_plexos + + result = translate_to_plexos(str(self.tmp_dir / "nope.json")) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "file_not_found") + + def test_translate_failure_is_reported_not_raised(self): + self.fake_modules["r2x_sienna_to_plexos"].sienna_to_plexos.side_effect = RuntimeError("boom") + from sienna_mcp.tools.translate_tools import translate_to_plexos + + result = translate_to_plexos(str(self.psy)) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "translate_failed") + self.assertIn("boom", result["message"]) + + +class TestCompareSolutions(unittest.TestCase): + def setUp(self): + self.fake_modules, _, _ = _install_fake_r2x_modules() + self.patcher = _patch_sys_modules(self.fake_modules) + self.patcher.start() + self.tmp_dir = Path(__file__).resolve().parent + + def tearDown(self): + self.patcher.stop() + for name in ("_sienna_results.json", "_plexos_results.json"): + (self.tmp_dir / name).unlink(missing_ok=True) + + def test_compare_results_json_numeric_diff(self): + sienna_path = self.tmp_dir / "_sienna_results.json" + plexos_path = self.tmp_dir / "_plexos_results.json" + + # Make System.from_json fail so both sides fall back to plain results JSON. + self.fake_modules["r2x_core"].System.from_json.side_effect = ValueError("not a system") + sienna_path.write_text(json.dumps({"objective_value": 100.0, "total_cost": 500.0})) + plexos_path.write_text(json.dumps({"objective_value": 90.0, "total_cost": 500.0})) + + from sienna_mcp.tools.compare_tools import compare_solutions + + result = compare_solutions(str(sienna_path), str(plexos_path)) + self.assertTrue(result["ok"]) + self.assertEqual(result["metric_diff"]["objective_value"]["diff"], 10.0) + self.assertEqual(result["metric_diff"]["total_cost"]["diff"], 0.0) + + def test_compare_system_json_component_diff(self): + sienna_path = self.tmp_dir / "_sienna_results.json" + plexos_path = self.tmp_dir / "_plexos_results.json" + sienna_path.write_text("{}") + plexos_path.write_text("{}") + + # Two calls to System.from_json: first for sienna_path, second for plexos_path. + sienna_system = _FakeSystem(counts={"ThermalStandard": 2}) + plexos_system = _FakeSystem(counts={"PLEXOSGenerator": 1}) + self.fake_modules["r2x_core"].System.from_json.side_effect = [sienna_system, plexos_system] + + from sienna_mcp.tools.compare_tools import compare_solutions + + result = compare_solutions(str(sienna_path), str(plexos_path)) + self.assertTrue(result["ok"]) + diff = result["component_count_diff"] + self.assertEqual(diff["ThermalStandard"], {"sienna": 2, "plexos": 0, "diff": 2}) + self.assertEqual(diff["PLEXOSGenerator"], {"sienna": 0, "plexos": 1, "diff": -1}) + + def test_compare_missing_file(self): + from sienna_mcp.tools.compare_tools import compare_solutions + + result = compare_solutions(str(self.tmp_dir / "nope1.json"), str(self.tmp_dir / "nope2.json")) + self.assertFalse(result["ok"]) + self.assertEqual(result["side"], "sienna") + + +class TestRunSiennaSolve(unittest.TestCase): + """run_sienna_solve is mocked here (subprocess.run stubbed out); see module + docstring for the real-Julia verification performed outside this suite.""" + + def setUp(self): + self.tmp_dir = Path(__file__).resolve().parent + self.psy = self.tmp_dir / "_tmp_psy_solve.json" + self.psy.write_text("{}") + + def tearDown(self): + self.psy.unlink(missing_ok=True) + + @patch("sienna_mcp.tools.solve_tools.validate_julia_command") + @patch("sienna_mcp.tools.solve_tools.run_julia_script") + def test_run_sienna_solve_success(self, mock_run_script, mock_validate): + mock_validate.return_value = ("/usr/local/bin/julia", None) + + def _fake_run(julia_bin, script_path, script_args, timeout_seconds): + out_path = Path(script_args[1]) + out_path.write_text(json.dumps({ + "ok": True, + "solve_status": "SUCCESSFULLY_FINALIZED", + "objective_value": 23040.0, + })) + return subprocess.CompletedProcess(args=[], returncode=0, stdout="SIENNA_SOLVE_OK\n", stderr="") + + mock_run_script.side_effect = _fake_run + + from sienna_mcp.tools.solve_tools import run_sienna_solve + + result = run_sienna_solve(str(self.psy)) + self.assertTrue(result["ok"]) + self.assertEqual(result["objective_value"], 23040.0) + self.assertEqual(result["exit_code"], 0) + + @patch("sienna_mcp.tools.solve_tools.validate_julia_command") + def test_run_sienna_solve_julia_not_found(self, mock_validate): + mock_validate.return_value = (None, {"ok": False, "error_type": "julia_not_found", "message": "no julia"}) + + from sienna_mcp.tools.solve_tools import run_sienna_solve + + result = run_sienna_solve(str(self.psy)) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "julia_not_found") + + def test_run_sienna_solve_missing_psy_file(self): + from sienna_mcp.tools.solve_tools import run_sienna_solve + + result = run_sienna_solve(str(self.tmp_dir / "nope.json")) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "file_not_found") + + @patch("sienna_mcp.tools.solve_tools.validate_julia_command") + @patch("sienna_mcp.tools.solve_tools.run_julia_script") + def test_run_sienna_solve_timeout(self, mock_run_script, mock_validate): + mock_validate.return_value = ("/usr/local/bin/julia", None) + mock_run_script.side_effect = subprocess.TimeoutExpired(cmd=["julia"], timeout=5, output="", stderr="") + + from sienna_mcp.tools.solve_tools import run_sienna_solve + + result = run_sienna_solve(str(self.psy), timeout_seconds=5) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "solve_timeout") + + +class TestRegisterTools(unittest.TestCase): + """Registration smoke test -- each register_*_tools(mcp) call adds its tool.""" + + def test_register_all_tools(self): + from mcp.server.fastmcp import FastMCP + + from sienna_mcp.tools.compare_tools import register_compare_tools + from sienna_mcp.tools.solve_tools import register_solve_tools + from sienna_mcp.tools.system_tools import register_system_tools + from sienna_mcp.tools.translate_tools import register_translate_tools + + mcp = FastMCP("Test") + register_system_tools(mcp) + register_translate_tools(mcp) + register_solve_tools(mcp) + register_compare_tools(mcp) + # No assertion beyond "did not raise" -- FastMCP's tool registry is private + # API; PSCAD's own test_tools.py takes the same approach. + + +if __name__ == "__main__": + unittest.main() diff --git a/powermcp/registry.py b/powermcp/registry.py index a2cea82..9a28898 100644 --- a/powermcp/registry.py +++ b/powermcp/registry.py @@ -130,6 +130,22 @@ def resolve_module_root(self) -> Path: ), external_solvers=("Julia",), ), + Tool( + "sienna", "SIENNA", "open-source", windows_only=False, extra="sienna", + server_dir="SIENNA", run_kind="module", + module="sienna_mcp.main", module_root_rel=None, # parent of sienna_mcp is the server dir itself + probe="r2x_core", + config_keys=( + ConfigKey("julia_bin", "Path to the Julia executable", "file"), + ConfigKey("julia_depot_path", "JULIA_DEPOT_PATH (optional, Enter to skip)", "dir", required=False), + ), + external_solvers=("Julia",), + notes=( + "Sienna (PowerSystems.jl/PowerSimulations.jl) is Julia-only, not on PyPI; " + "run_sienna_solve shells out to julia_bin the same way HOPE does. " + "translate_to_plexos/compare_solutions call the r2x PyPI package directly." + ), + ), Tool( "powerio", "PowerIO", "open-source", windows_only=False, extra=None, server_dir="powerio", run_kind="script", entry_rel="powerio_mcp.py", diff --git a/pyproject.toml b/pyproject.toml index 89c014c..d086e65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ opendss = ["py_dss_toolkit"] ltspice = ["PyLTSpice", "matplotlib"] surge = ["surge-py>=0.1.5; python_version >= '3.12' and python_version < '3.15'"] hope = ["PyYAML>=6.0"] +sienna = ["r2x"] # translate_to_plexos/compare_solutions; Sienna itself is Julia-only (julia_bin config key) # powerio moved to core `dependencies`; the extra is gone (a bare # `pip install powermcp` now always provides the conversion server). # --- closed-source / vendor tool engines --- @@ -74,7 +75,7 @@ psse = [] # vendor `psspy` not on PyPI — pslf = ["pandas"] # vendor `PSLF_PYTHON` not on PyPI — path captured in config.toml powerfactory = ["fastmcp>=2.0", "numpy>=1.26", "matplotlib>=3.8", "pandas>=1.5"] # vendor `powerfactory` not on PyPI # --- convenience groups --- -opensource = ["powermcp[andes]", "powermcp[egret]", "powermcp[opendss]", "powermcp[ltspice]", "powermcp[surge]", "powermcp[hope]"] +opensource = ["powermcp[andes]", "powermcp[egret]", "powermcp[opendss]", "powermcp[ltspice]", "powermcp[surge]", "powermcp[hope]", "powermcp[sienna]"] windows = ["powermcp[pscad-windows]", "powermcp[powerworld]", "powermcp[powerfactory]", "powermcp[psse]", "powermcp[pslf]"] all = ["powermcp[opensource]", "powermcp[powerworld]", "powermcp[powerfactory]", "powermcp[pscad-windows]", "powermcp[psse]", "powermcp[pslf]"] @@ -101,6 +102,7 @@ packages = ["powermcp"] "surge" = "powermcp/_servers/surge" "OpenDSS" = "powermcp/_servers/OpenDSS" "HOPE" = "powermcp/_servers/HOPE" +"SIENNA" = "powermcp/_servers/SIENNA" "powerio" = "powermcp/_servers/powerio" "PSCAD" = "powermcp/_servers/PSCAD" "PSSE" = "powermcp/_servers/PSSE" @@ -114,11 +116,11 @@ packages = ["powermcp"] [tool.hatch.build.targets.sdist] include = [ "powermcp", - "pandapower", "PyPSA", "ANDES", "Egret", "surge", "OpenDSS", "HOPE", "powerio", + "pandapower", "PyPSA", "ANDES", "Egret", "surge", "OpenDSS", "HOPE", "SIENNA", "powerio", "PSCAD", "PSSE", "PSLF", "PowerWorld", "PowerFactory", "LTSpice", "README.md", "LICENSE", "pyproject.toml", ] exclude = [ "**/__pycache__", "**/*.py[cod]", "**/.pytest_cache", - "PSCAD/tests", "HOPE/tests", + "PSCAD/tests", "HOPE/tests", "SIENNA/tests", ] diff --git a/tests/test_vendor_import.py b/tests/test_vendor_import.py index 7f17d9c..d08ab5c 100644 --- a/tests/test_vendor_import.py +++ b/tests/test_vendor_import.py @@ -69,3 +69,40 @@ def test_pslf_import_side_effect_free_then_inits_once(monkeypatch): assert hasattr(mod, "CaseParameters") monkeypatch.delitem(sys.modules, "pslf_mcp_under_test", raising=False) + + +def test_sienna_main_import_and_server_creation_are_r2x_free(monkeypatch): + """SIENNA's shape differs from PSSE/PSLF: instead of a module-level global that + a memoized ``_ensure_*()`` populates on first use, each SIENNA tool function + (``load_system``/``translate_to_plexos``/``compare_solutions``) imports + ``r2x_core``/``r2x_plexos``/``r2x_sienna_to_plexos`` inline, at call time -- see + SIENNA/sienna_mcp/tools/*.py. So the invariant to check is simpler: importing + ``sienna_mcp.main`` and building the server (``create_server()``, which only + registers tool callables, never calls them) must not import any r2x module. + r2x is not installed in this test environment (by design), so this exercises a + real absence, not a simulated one -- unlike psspy/PSLF_PYTHON above, which do + get faked, no monkeypatched replacement module is needed here. + """ + r2x_module_names = ("r2x", "r2x_core", "r2x_plexos", "r2x_sienna", "r2x_sienna_to_plexos") + for mod_name in r2x_module_names: + assert mod_name not in sys.modules, f"{mod_name} must not already be imported" + + tool = get_tool("sienna") + monkeypatch.syspath_prepend(str(tool.resolve_module_root())) + + path = tool.resolve_module_root() / "sienna_mcp" / "main.py" + mod = _load("sienna_mcp_main_under_test", path) + + for mod_name in r2x_module_names: + assert mod_name not in sys.modules, f"importing sienna_mcp.main pulled in {mod_name}" + + server = mod.create_server() + assert server is not None + + for mod_name in r2x_module_names: + assert mod_name not in sys.modules, f"create_server() pulled in {mod_name}" + + monkeypatch.delitem(sys.modules, "sienna_mcp_main_under_test", raising=False) + for name in list(sys.modules): + if name == "sienna_mcp" or name.startswith("sienna_mcp."): + monkeypatch.delitem(sys.modules, name, raising=False)