diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 598188f..7c0f60f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: test: name: pytest (py${{ matrix.python-version }}) @@ -12,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - # floor and ceiling of requires-python + # floor of requires-python, and the highest version the classifiers claim python-version: ["3.10", "3.12"] steps: - uses: actions/checkout@v4 @@ -22,8 +26,6 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip - - uses: dtolnay/rust-toolchain@stable - - name: Install package run: | python -m pip install --upgrade pip diff --git a/.gitignore b/.gitignore index eb240d6..180652c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Environments .venv/ +.venv-*/ env/ venv/ ENV/ diff --git a/ANDES/README.md b/ANDES/README.md index ae3f719..2f7a65a 100644 --- a/ANDES/README.md +++ b/ANDES/README.md @@ -57,8 +57,8 @@ Configure in your MCP client (e.g., Cursor, Claude Desktop): - `success`: whether ANDES's `EIG.run()` reported success. - `n_eigenvalues`, `eigenvalues`: the pre-0.3.0 fields, retained so existing callers keep working. The old `eigenvectors` and `state_variables` fields are gone: they read attributes the `EIG` routine has never had, so they only ever returned `[]`. - **get_system_info()**: Get information about the currently loaded power system. -- **load_network_from_any(...)**: Convert any PowerIO-readable case or one selected `.pio.json` package state into the ANDES run format. -- **load_network_from_json(...)**: Convert PowerIO model JSON or one selected `.pio.json` package state without staging the source input. +- **load_network_from_any(...)**: Convert any PowerIO-readable case, or one selected entry of a PowerIO IR collection, into the ANDES run format. +- **load_network_from_json(...)**: Convert serialized PowerIO IR, or one selected entry of it, without staging the source input. ## License note diff --git a/ANDES/andes_mcp.py b/ANDES/andes_mcp.py index 8f3783b..7ccfc92 100644 --- a/ANDES/andes_mcp.py +++ b/ANDES/andes_mcp.py @@ -22,6 +22,7 @@ checked_path, checked_read_tree, ensure_checked_directory, + staged_file_write, ) finally: if _repo_root_added: @@ -44,6 +45,19 @@ def _ensure_andes_runs_dir() -> str: ) +def _write_case_file(destination: str, text: str) -> None: + """Install one MATPOWER case at ``destination`` only once it is complete. + + The text lands in a private staging file first, so a write that fails part + way leaves any file already at ``destination`` as it was. + """ + staged_file_write( + destination, + True, + lambda staging: Path(staging).write_text(text, encoding="utf-8"), + ) + + def _prepare_run_dir(name: str, purpose: str) -> str: run_dir = checked_path( os.path.join(_ensure_andes_runs_dir(), name), @@ -493,12 +507,18 @@ def get_system_info() -> Dict[str, Any]: @mcp.tool() def load_network_from_json( - network_json: str, - out_path: str, + network_json: str = "", + out_path: str = "", operating_point: Optional[int] = None, study_commit: Optional[int] = None, + time_index: Optional[int] = None, + scenario_id: Optional[str] = None, + powerio_ir: str = "", + edits: str = "", + to_balanced: bool = False, + base_mva: float = 100.0, ) -> Dict[str, Any]: - """Stage PowerIO model JSON or one package state as MATPOWER for ANDES. + """Stage a selected PowerIO IR module as MATPOWER for ANDES. Accepts the ``json`` string returned by the powerio server's parse tool. Converts the network to MATPOWER format, writes it to out_path (use a .m @@ -509,12 +529,26 @@ def load_network_from_json( Args: network_json: The JSON transport string from powerio out_path: Destination for the MATPOWER case file (.m) - operating_point: Optional package operating-point index to materialize - study_commit: Optional package study-commit index to materialize + operating_point: Compatibility alias for time_index + study_commit: Retired package selector; export a Tellegen Study state as IR + time_index: Explicit TimeSeries index + scenario_id: Explicit ScenarioSet identifier + powerio_ir: Serialized PowerIO IR from the powerio server (the + preferred spelling; network_json is its alias) + edits: JSON list of typed what-if edits PowerIO applies before the + conversion, in list order, for example + [{"op": "set_load_active_power", "load": "loads:0", "mw": 91.5}] + Consecutive updates of one class apply as one atomic batch, and a + bus load reallocation sees the values the edits before it produced. + to_balanced: Authorize the multiconductor to balanced transformation; + the response carries its readiness report as `lowering` + base_mva: System base for that transformation Returns: Dict with status, case_file path, component counts, and fidelity warnings """ + if not out_path: + return {"status": "error", "message": "out_path is required"} try: out_path = checked_path(out_path, purpose="out_path", for_write=True) except PathNotAllowed as exc: @@ -524,12 +558,17 @@ def load_network_from_json( network_json=network_json, operating_point=operating_point, study_commit=study_commit, + time_index=time_index, + scenario_id=scenario_id, + powerio_ir=powerio_ir, + edits=edits, + to_balanced=to_balanced, + base_mva=base_mva, ) case = prepared.network - conv = case.to_format("matpower") + conv = prepared.emit("matpower") abs_out = os.path.abspath(out_path) - with open(abs_out, "w") as fh: - fh.write(conv.text) + _write_case_file(abs_out, conv.text) except Exception as e: return {"status": "error", "message": str(e)} return { @@ -539,10 +578,9 @@ def load_network_from_json( "info": { "buses": case.n_buses, "branches": case.n_branches, - "generators": case.n_gens, + "generators": case.n_generators, }, - "warnings": list(prepared.warnings) + list(conv.warnings), - **({"package": prepared.package} if prepared.package is not None else {}), + **prepared.response_fields(conv), } @@ -553,6 +591,11 @@ def load_network_from_any( source_format: Optional[str] = None, operating_point: Optional[int] = None, study_commit: Optional[int] = None, + time_index: Optional[int] = None, + scenario_id: Optional[str] = None, + edits: str = "", + to_balanced: bool = False, + base_mva: float = 100.0, ) -> Dict[str, Any]: """Stage any powerio readable case as a MATPOWER file for ANDES. @@ -566,8 +609,18 @@ def load_network_from_any( out_path: Destination for the MATPOWER case file (.m) source_format: Input format name (matpower, powermodels-json, egret-json, psse, powerworld); inferred from the file extension when omitted - operating_point: Optional package operating-point index to materialize - study_commit: Optional package study-commit index to materialize + operating_point: Compatibility alias for time_index + study_commit: Retired package selector; export a Tellegen Study state as IR + time_index: Explicit TimeSeries index + scenario_id: Explicit ScenarioSet identifier + edits: JSON list of typed what-if edits PowerIO applies before the + conversion, in list order, for example + [{"op": "set_load_active_power", "load": "loads:0", "mw": 91.5}] + Consecutive updates of one class apply as one atomic batch, and a + bus load reallocation sees the values the edits before it produced. + to_balanced: Authorize the multiconductor to balanced transformation; + the response carries its readiness report as `lowering` + base_mva: System base for that transformation Returns: Dict with status, case_file path, component counts, and fidelity warnings @@ -586,12 +639,16 @@ def load_network_from_any( source_format=source_format, operating_point=operating_point, study_commit=study_commit, + time_index=time_index, + scenario_id=scenario_id, + edits=edits, + to_balanced=to_balanced, + base_mva=base_mva, ) case = prepared.network - conv = case.to_format("matpower") + conv = prepared.emit("matpower") abs_out = os.path.abspath(out_path) - with open(abs_out, "w") as fh: - fh.write(conv.text) + _write_case_file(abs_out, conv.text) except FileNotFoundError: return {"status": "error", "message": f"File not found: {file_path}"} except Exception as e: @@ -603,14 +660,15 @@ def load_network_from_any( "info": { "buses": case.n_buses, "branches": case.n_branches, - "generators": case.n_gens, + "generators": case.n_generators, }, - "warnings": list(prepared.warnings) + list(conv.warnings), - **({"package": prepared.package} if prepared.package is not None else {}), + **prepared.response_fields(conv), } if __name__ == "__main__": - print(f"Starting ANDES MCP Server") - print(f"Using storage directory: {_andes_runs_dir()}") + # stdout carries JSON-RPC once the server runs, so startup notes go to the + # logger, which writes to stderr. + logger.info("Starting ANDES MCP Server") + logger.info("Using storage directory: %s", _andes_runs_dir()) mcp.run(transport="stdio") diff --git a/ANDES/requirements.txt b/ANDES/requirements.txt index bab8a80..529fabc 100644 --- a/ANDES/requirements.txt +++ b/ANDES/requirements.txt @@ -1,3 +1,3 @@ andes mcp>=2,<3 -powerio[mcp,matrix]>=0.9.0,<1 +powerio[mcp,matrix]>=0.11.3,<0.12 diff --git a/Egret/README.md b/Egret/README.md index c587f60..dbfd94d 100644 --- a/Egret/README.md +++ b/Egret/README.md @@ -37,8 +37,8 @@ Configure in your MCP client (e.g., Cursor, Claude Desktop): - **solve_unit_commitment_problem(case_file, solver, mipgap, timelimit)**: Solve a unit commitment problem with custom solver, MIP gap, and time limits. - **solve_ac_opf(case_file, solver, return_results)**: Run AC Optimal Power Flow on Matpower or Egret JSON case files. - **solve_dc_opf(case_file, solver, return_results)**: Run DC Optimal Power Flow on Matpower or Egret JSON case files. -- **load_model_from_any(...)**: Convert any PowerIO-readable case or one selected `.pio.json` package state into an Egret model. -- **load_model_from_json(...)**: Convert PowerIO model JSON or one selected `.pio.json` package state without staging the source input. +- **load_model_from_any(...)**: Convert any PowerIO-readable case, or one selected entry of a PowerIO IR collection, into an Egret model. +- **load_model_from_json(...)**: Convert serialized PowerIO IR, or one selected entry of it, without staging the source input. ## Prompt Example diff --git a/Egret/egret_mcp.py b/Egret/egret_mcp.py index bcb8e7e..210fb56 100644 --- a/Egret/egret_mcp.py +++ b/Egret/egret_mcp.py @@ -17,7 +17,12 @@ sys.path.insert(0, _repo_root) try: from powermcp.solver_case import resolve_solver_case - from powermcp.sandbox import PathNotAllowed, checked_path, ensure_checked_directory + from powermcp.sandbox import ( + PathNotAllowed, + checked_path, + ensure_checked_directory, + staged_file_write, + ) finally: if _repo_root_added: sys.path.remove(_repo_root) @@ -224,22 +229,28 @@ def _stage_egret_model(egret_json_text: str): """Validate egret JSON by constructing a ModelData from the parsed dict, stage it to a temp file the solver tools can read, and summarize it.""" import json + import pathlib import tempfile + def write(staging: str) -> None: + with open(staging, "w", encoding="utf-8") as fh: + fh.write(egret_json_text) + md = ModelData(json.loads(egret_json_text)) fd, path = tempfile.mkstemp( suffix=".json", prefix="egret_case_", dir=_ensure_egret_runs_dir() ) + os.close(fd) try: path = checked_path( path, purpose="generated Egret case path", for_write=True ) + # mkstemp reserves the name; the model text replaces it in one step, so + # the path a solver tool receives holds the whole case or nothing. + staged_file_write(path, True, write) except BaseException: - os.close(fd) - os.unlink(path) + pathlib.Path(path).unlink(missing_ok=True) raise - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(egret_json_text) info = {name: len(items) for name, items in md.data.get("elements", {}).items()} return path, info @@ -250,13 +261,17 @@ def load_model_from_any( source_format: Optional[str] = None, operating_point: Optional[int] = None, study_commit: Optional[int] = None, + time_index: Optional[int] = None, + scenario_id: Optional[str] = None, + edits: str = "", + to_balanced: bool = False, + base_mva: float = 100.0, ) -> Dict[str, Any]: """Convert any powerio readable case file into an egret model. - Reads any balanced PowerIO format or a ``.pio.json`` package, converts one + Reads any balanced PowerIO format or a ``.pio.json`` module, converts one selected state to Egret JSON, validates it as ModelData, and stages it. For - a package containing stored state data, select operating_point or - study_commit. Pass the returned `case_file` path to solve_ac_opf, solve_dc_opf, or + a TimeSeries, select time_index; for a ScenarioSet, select scenario_id. Pass the returned `case_file` path to solve_ac_opf, solve_dc_opf, or solve_unit_commitment_problem. powerio is a core dependency, so this is always available. @@ -265,8 +280,18 @@ def load_model_from_any( source_format: Input format name (matpower, powermodels-json, egret-json, psse, powerworld); inferred from the file extension when omitted - operating_point: Optional package operating-point index to materialize - study_commit: Optional package study-commit index to materialize + operating_point: Compatibility alias for time_index + study_commit: Retired package selector; export a Tellegen Study state as IR + time_index: Explicit TimeSeries index + scenario_id: Explicit ScenarioSet identifier + edits: JSON list of typed what-if edits PowerIO applies before the + conversion, in list order, for example + [{"op": "set_load_active_power", "load": "loads:0", "mw": 91.5}] + Consecutive updates of one class apply as one atomic batch, and a + bus load reallocation sees the values the edits before it produced. + to_balanced: Authorize the multiconductor to balanced transformation; + the response carries its readiness report as `lowering` + base_mva: System base for that transformation Returns: Dict with status, the staged `case_file` path, model element counts, @@ -282,8 +307,13 @@ def load_model_from_any( source_format=source_format, operating_point=operating_point, study_commit=study_commit, + time_index=time_index, + scenario_id=scenario_id, + edits=edits, + to_balanced=to_balanced, + base_mva=base_mva, ) - conv = prepared.network.to_format("egret-json") + conv = prepared.emit("egret-json") path, info = _stage_egret_model(conv.text) except FileNotFoundError: return {"status": "error", "message": f"File not found: {file_path}"} @@ -293,18 +323,23 @@ def load_model_from_any( "status": "success", "case_file": path, "model_info": info, - "warnings": list(prepared.warnings) + list(conv.warnings), - **({"package": prepared.package} if prepared.package is not None else {}), + **prepared.response_fields(conv), } @mcp.tool() def load_model_from_json( - network_json: str, + network_json: str = "", operating_point: Optional[int] = None, study_commit: Optional[int] = None, + time_index: Optional[int] = None, + scenario_id: Optional[str] = None, + powerio_ir: str = "", + edits: str = "", + to_balanced: bool = False, + base_mva: float = 100.0, ) -> Dict[str, Any]: - """Convert PowerIO model JSON or one package state into an Egret model. + """Convert a selected PowerIO IR module into an Egret model. Accepts the `json` string returned by the powerio server's parse tool, so a case parsed once there feeds egret without re-reading the file. @@ -315,8 +350,20 @@ def load_model_from_json( Args: network_json: The JSON transport string from powerio - operating_point: Optional package operating-point index to materialize - study_commit: Optional package study-commit index to materialize + operating_point: Compatibility alias for time_index + study_commit: Retired package selector; export a Tellegen Study state as IR + time_index: Explicit TimeSeries index + scenario_id: Explicit ScenarioSet identifier + powerio_ir: Serialized PowerIO IR from the powerio server (the + preferred spelling; network_json is its alias) + edits: JSON list of typed what-if edits PowerIO applies before the + conversion, in list order, for example + [{"op": "set_load_active_power", "load": "loads:0", "mw": 91.5}] + Consecutive updates of one class apply as one atomic batch, and a + bus load reallocation sees the values the edits before it produced. + to_balanced: Authorize the multiconductor to balanced transformation; + the response carries its readiness report as `lowering` + base_mva: System base for that transformation Returns: Dict with status, the staged `case_file` path, model element counts, @@ -327,8 +374,14 @@ def load_model_from_json( network_json=network_json, operating_point=operating_point, study_commit=study_commit, + time_index=time_index, + scenario_id=scenario_id, + powerio_ir=powerio_ir, + edits=edits, + to_balanced=to_balanced, + base_mva=base_mva, ) - conv = prepared.network.to_format("egret-json") + conv = prepared.emit("egret-json") path, info = _stage_egret_model(conv.text) except Exception as e: return {"status": "error", "message": str(e)} @@ -336,8 +389,7 @@ def load_model_from_json( "status": "success", "case_file": path, "model_info": info, - "warnings": list(prepared.warnings) + list(conv.warnings), - **({"package": prepared.package} if prepared.package is not None else {}), + **prepared.response_fields(conv), } diff --git a/Egret/requirements.txt b/Egret/requirements.txt index e7ead8d..9b0ebe9 100644 --- a/Egret/requirements.txt +++ b/Egret/requirements.txt @@ -1,4 +1,4 @@ gridx-egret pyomo mcp>=2,<3 -powerio[mcp,matrix]>=0.9.0,<1 +powerio[mcp,matrix]>=0.11.3,<0.12 diff --git a/GenX/README.md b/GenX/README.md index f0bc86b..00c34b0 100644 --- a/GenX/README.md +++ b/GenX/README.md @@ -133,6 +133,22 @@ Job names are restricted to `[A-Za-z0-9._-]` and every value interpolated into a generated SLURM script is shell-quoted: the script is piped to `sbatch` and runs on the cluster under your own account. +## Tool results + +Every tool returns the shape the whole distribution uses: + +```json +{"status": "success", "...": "the keys that tool documents"} +{"status": "error", "message": "what went wrong"} +``` + +A refused path, an unknown zone or CapRes region, an invalid `plot_type`, and a +`sbatch` that is missing or refuses the job all arrive as `"status": "error"` +with a message naming the cause. Nothing raises out of a tool, so a caller +reads `status` once and never has a second failure path to handle. A tool that +documents a key on both branches, such as `plot_capacity`'s `file_path`, +carries it on the error branch too. + ## Usage Ask Claude in plain language: diff --git a/GenX/requirements.txt b/GenX/requirements.txt index 0696ac9..525790b 100644 --- a/GenX/requirements.txt +++ b/GenX/requirements.txt @@ -1,5 +1,7 @@ # Standalone install for running GenX/server.py straight from a clone. # `pip install "powermcp[genx]"` covers the same ground. mcp>=2,<3 +# GenX/server.py imports powermcp.sandbox, which re-exports powerio's path policy. +powerio[mcp,matrix]>=0.11.3,<0.12 pandas>=2.0,<3 matplotlib>=3.7 diff --git a/GenX/server.py b/GenX/server.py index 1633c84..451e9c5 100644 --- a/GenX/server.py +++ b/GenX/server.py @@ -2,7 +2,7 @@ ''' Cluster settings (the GenX.jl checkout, SLURM defaults, module names) are -resolved at call time from ~/.powermcp/config.toml or the environment -- see +resolved at call time from ~/.powermcp/config.toml or the environment; see GenX/README.md. Nothing is read at import, so this server starts on a machine that has never configured GenX; the tools that need a setting say so when they are called. @@ -11,18 +11,22 @@ import logging import sys from pathlib import Path -from typing import Any, Callable, Optional +from typing import Optional # Make the repo root importable so `from GenX.tool_logic...` works when the # MCP client launches this file directly (sys.path[0] is GenX/, not the root). +# The entry stays for the process lifetime: GenX carries no __init__.py, so its +# namespace __path__ is recomputed from sys.path on every attribute lookup and +# the lazy `from GenX.tool_logic.slurm import ...` calls inside tool_logic +# would fail without it. _REPO_ROOT = str(Path(__file__).resolve().parent.parent) if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) from mcp.server.mcpserver import MCPServer as FastMCP +from powermcp.errors import run_tool, tool_error, tool_success from powermcp.sandbox import ( - PathNotAllowed, checked_path, ensure_checked_directory, ) @@ -51,22 +55,6 @@ mcp = FastMCP("genx_agent") -def _guarded(call: Callable[[], dict]) -> dict: - """Run a tool body, turning any failure into the shared error shape. - - Without this the tools raise straight out of the server: half of them - returned {"success": False, ...} and half surfaced a raw MCP protocol - error, so a caller had two failure protocols to handle from one connector. - """ - try: - return call() - except (PathNotAllowed, ValueError) as exc: - return {"success": False, "message": str(exc)} - except Exception as exc: # noqa: BLE001 - the boundary has to hold - logger.exception("GenX tool failed") - return {"success": False, "message": f"{type(exc).__name__}: {exc}"} - - def _checked(path: str, purpose: str) -> str: """Contain a model-supplied path, per powermcp/sandbox.py. @@ -101,9 +89,9 @@ def check_capacity_setting(csv_path: str) -> dict: def run() -> dict: df = load_capacity_csv(_checked(csv_path, "csv_path")) # check_existing: whether StartCap > 0 (brownfield) or all StartCap = 0 (greenfield) - return {"success": True, **check_existing(df)} + return tool_success(**check_existing(df)) - return _guarded(run) + return run_tool(run, logger=logger) @mcp.tool() @@ -123,13 +111,12 @@ def run() -> dict: # aggregate_capacity_by_resource returns a DataFrame; this is a # `-> dict` MCP tool, so it has to be serializable. aggregated = aggregate_capacity_by_resource(df) - return { - "success": True, - "zones": zones, - "by_resource": aggregated.to_dict(orient="records"), - } + return tool_success( + zones=zones, + by_resource=aggregated.to_dict(orient="records"), + ) - return _guarded(run) + return run_tool(run, logger=logger) @mcp.tool() @@ -153,11 +140,10 @@ def plot_capacity( def run() -> dict: valid_types = ["StartCap", "RetCap", "NewCap", "EndCap", "NetCap"] if plot_type not in valid_types: - return { - "success": False, - "message": f"Invalid plot_type '{plot_type}'. Must be one of: {valid_types}", - "file_path": None, - } + return tool_error( + f"Invalid plot_type '{plot_type}'. Must be one of: {valid_types}", + file_path=None, + ) df = load_capacity_csv(_checked(csv_path, "csv_path")) if zones: @@ -171,12 +157,11 @@ def run() -> dict: message_suffix = "" if not is_brownfield: if column in ["StartCap", "RetCap"]: - return { - "success": False, - "message": "In this case all StartCap = 0, so NewCap = EndCap = NetCap.", - "file_path": None, - "setting": "greenfield", - } + return tool_error( + "In this case all StartCap = 0, so NewCap = EndCap = NetCap.", + file_path=None, + setting="greenfield", + ) if column == "NetCap": column = "EndCap" message_suffix = " Note that NewCap = EndCap = NetCap in this case" @@ -192,13 +177,13 @@ def run() -> dict: title=title, ) - if result["success"]: + if result["status"] == "success": result["message"] += message_suffix result["setting"] = "brownfield" if is_brownfield else "greenfield" return result - return _guarded(run) + return run_tool(run, logger=logger) @mcp.tool() @@ -212,10 +197,9 @@ def preview_genx_case( """ Generate the SLURM submission script for a GenX case. """ - return _guarded( - lambda: _preview_case( - case_dir, time_hours, mem_gb, cpus, case_name - ) + return run_tool( + lambda: _preview_case(case_dir, time_hours, mem_gb, cpus, case_name), + logger=logger, ) @@ -236,10 +220,9 @@ def submit_genx_case( If the user has not stated this, ask before calling this tool. """ - return _guarded( - lambda: _submit_case( - case_dir, time_hours, mem_gb, cpus, case_name - ) + return run_tool( + lambda: _submit_case(case_dir, time_hours, mem_gb, cpus, case_name), + logger=logger, ) @@ -270,10 +253,9 @@ def compute_capacity_cost( zones: Zone numbers for the peak-demand denominator (default: all zones in Demand_data.csv). """ - return _guarded( - lambda: _compute_capacity_cost( - scenario_path, period, capres_regions, zones - ) + return run_tool( + lambda: _compute_capacity_cost(scenario_path, period, capres_regions, zones), + logger=logger, ) @@ -309,7 +291,7 @@ def plot_diurnal_generation( compare_case_dir: Optional second case for pairwise comparison. diff: Plot Case 1 - Case 2 difference (requires compare_case_dir). """ - return _guarded( + return run_tool( lambda: _plot_diurnal_generation( case_dir, _checked_output_file(output_path, "output_path"), @@ -318,7 +300,8 @@ def plot_diurnal_generation( labels, compare_case_dir, diff, - ) + ), + logger=logger, ) if __name__ == "__main__": diff --git a/GenX/tool_logic/compute_capacity_cost.py b/GenX/tool_logic/compute_capacity_cost.py index 0fed1f9..83f40fa 100644 --- a/GenX/tool_logic/compute_capacity_cost.py +++ b/GenX/tool_logic/compute_capacity_cost.py @@ -11,6 +11,8 @@ import numpy as np import pandas as pd +from powermcp.errors import tool_error, tool_success + def resolve_scenario(scenario_path: str, period: int = 1, marker: str | None = None) -> str: """ @@ -86,8 +88,7 @@ def compute_capacity_cost( for path in (dem_path, resmar_path, capres_path): if not os.path.isfile(path): - return {"success": False, - "message": f"Missing required file: {path}"} + return tool_error(f"Missing required file: {path}") dem_in = pd.read_csv(dem_path) resmar = pd.read_csv(resmar_path) @@ -106,9 +107,8 @@ def compute_capacity_cost( else: unknown = sorted(set(capres_regions) - set(available_regions)) if unknown: - return {"success": False, - "message": f"Invalid CapRes region(s) {unknown}. " - f"Available regions: {available_regions}"} + return tool_error(f"Invalid CapRes region(s) {unknown}. " + f"Available regions: {available_regions}") total_cost = 0.0 for capres_num in capres_regions: @@ -134,28 +134,25 @@ def compute_capacity_cost( else: invalid = sorted(set(zones) - set(available_zones)) if invalid: - return {"success": False, - "message": f"Invalid zone(s) {invalid}. " - f"Available zones: {available_zones}"} + return tool_error(f"Invalid zone(s) {invalid}. " + f"Available zones: {available_zones}") denom_zones = sorted(zones) peak_demand = dem_in[[f"Demand_MW_z{z}" for z in denom_zones]].sum(axis=1).values.max() if peak_demand <= 0: - return {"success": False, - "message": f"Peak demand across zone(s) {denom_zones} is " - f"{peak_demand}; a capacity price cannot be " - f"computed against zero demand."} + return tool_error(f"Peak demand across zone(s) {denom_zones} is " + f"{peak_demand}; a capacity price cannot be " + f"computed against zero demand.") price_annual = total_cost / peak_demand price_day = price_annual / 365 - return { - "success": True, - "scenario": os.path.basename(scenario), - "scenario_path": scenario, - "period": period, - "capres_regions": list(capres_regions), - "zones": denom_zones, - "price_per_mw_day": round(float(price_day), 2), - "price_per_mw_yr": round(float(price_annual), 2), - "peak_demand_mw": round(float(peak_demand), 1), - } \ No newline at end of file + return tool_success( + scenario=os.path.basename(scenario), + scenario_path=scenario, + period=period, + capres_regions=list(capres_regions), + zones=denom_zones, + price_per_mw_day=round(float(price_day), 2), + price_per_mw_yr=round(float(price_annual), 2), + peak_demand_mw=round(float(peak_demand), 1), + ) \ No newline at end of file diff --git a/GenX/tool_logic/plot_avg_generation.py b/GenX/tool_logic/plot_avg_generation.py index f783a6f..021231f 100644 --- a/GenX/tool_logic/plot_avg_generation.py +++ b/GenX/tool_logic/plot_avg_generation.py @@ -12,6 +12,7 @@ from GenX.tool_logic.compute_capacity_cost import resolve_scenario import GenX.tool_logic.diurnal_generation as dg +from powermcp.errors import tool_error, tool_success def resolve_case(case_dir: str, period: int) -> str: """Resolve `case_dir` to an absolute path containing @@ -53,42 +54,42 @@ def plot_diurnal_generation( compare_case_dir: str | None = None, diff: bool = False, ) -> dict: - # Implements the diurnal_generation.py logic - try: - case = resolve_case(case_dir, period) - primary = dg.diurnal_by_tech(case, period, zones) + """Write the average-day generation chart and report where it landed. - zone_lbl = "all zones" if zones == "all" else f"zones {zones}" - label_list = [s.strip() for s in labels.split(",")] + The caller runs this through ``powermcp.errors.run_tool``, so a failure + here raises and is reported as the error shape by the tool wrapper. + """ + case = resolve_case(case_dir, period) + primary = dg.diurnal_by_tech(case, period, zones) - out = Path(os.path.expanduser(output_path)) - out.parent.mkdir(parents=True, exist_ok=True) + zone_lbl = "all zones" if zones == "all" else f"zones {zones}" + label_list = [s.strip() for s in labels.split(",")] - if compare_case_dir is None: - if diff: - return {"success": False, - "message": "diff=True requires compare_case_dir."} - title = f"Average day, generation by technology — {zone_lbl}, p{period}" - plot_single(primary, label_list[0], title, str(out)) + out = Path(os.path.expanduser(output_path)) + out.parent.mkdir(parents=True, exist_ok=True) + + other_case = None + if compare_case_dir is None: + if diff: + return tool_error("diff=True requires compare_case_dir.") + title = f"Average day, generation by technology — {zone_lbl}, p{period}" + plot_single(primary, label_list[0], title, str(out)) + else: + other_case = resolve_case(compare_case_dir, period) + other = dg.diurnal_by_tech(other_case, period, zones) + if len(label_list) < 2: + label_list = ["Original", "Comparison"] + kind = "difference" if diff else "generation by technology" + title = f"Average day, {kind} — {zone_lbl}, p{period}" + if diff: + dg.plot_difference(primary, other, label_list, title, str(out)) else: - other_case = resolve_case(compare_case_dir, period) - other = dg.diurnal_by_tech(other_case, period, zones) - if len(label_list) < 2: - label_list = ["Original", "Comparison"] - kind = "difference" if diff else "generation by technology" - title = f"Average day, {kind} — {zone_lbl}, p{period}" - if diff: - dg.plot_difference(primary, other, label_list, title, str(out)) - else: - dg.plot_comparison(primary, other, label_list, title, str(out)) + dg.plot_comparison(primary, other, label_list, title, str(out)) - return { - "success": True, - "message": f"Wrote {out}", - "file_path": str(out), - "case_dir": case, - "compare_case_dir": None if compare_case_dir is None else other_case, - "tech_groups": list(primary.columns), - } - except Exception as e: - return {"success": False, "message": f"{type(e).__name__}: {e}"} \ No newline at end of file + return tool_success( + message=f"Wrote {out}", + file_path=str(out), + case_dir=case, + compare_case_dir=other_case, + tech_groups=list(primary.columns), + ) \ No newline at end of file diff --git a/GenX/tool_logic/plot_capacity.py b/GenX/tool_logic/plot_capacity.py index 88d1fcf..d6c7c29 100644 --- a/GenX/tool_logic/plot_capacity.py +++ b/GenX/tool_logic/plot_capacity.py @@ -7,6 +7,7 @@ import matplotlib.pyplot as plt from GenX.tool_logic.palette import RESOURCE_COLORS +from powermcp.errors import tool_success # Resource name mapping for proper capitalization resource_labels = { @@ -252,8 +253,7 @@ def plot_capacity_bar( plt.savefig(output_path, dpi=300, bbox_inches="tight") plt.close() - return { - "success": True, - "message": f"Plot saved successfully: {capacity_column}", - "file_path": str(output_path), - } \ No newline at end of file + return tool_success( + message=f"Plot saved successfully: {capacity_column}", + file_path=str(output_path), + ) \ No newline at end of file diff --git a/GenX/tool_logic/slurm.py b/GenX/tool_logic/slurm.py index 8153c22..8717bbd 100644 --- a/GenX/tool_logic/slurm.py +++ b/GenX/tool_logic/slurm.py @@ -15,6 +15,8 @@ from pathlib import Path from typing import Any, Optional +from powermcp.errors import tool_success + logger = logging.getLogger(__name__) # `sbatch` is normally instant. A wedged or unreachable SLURM controller is a @@ -228,15 +230,14 @@ def preview_case( case_path = find_case(case_dir) final_cpus = cpus if cpus is not None else slurm_defaults()["cpus"] script = build_script(case_path, time_hours, mem_gb, final_cpus, case_name=case_name) - return { - "success": True, - "case_name": _checked_job_name(case_name, case_path), - "case_path": case_path, - "time_h": time_hours, - "mem_gb": mem_gb, - "cpus": final_cpus, - "script": script, - } + return tool_success( + case_name=_checked_job_name(case_name, case_path), + case_path=case_path, + time_h=time_hours, + mem_gb=mem_gb, + cpus=final_cpus, + script=script, + ) def submit_case( @@ -277,12 +278,11 @@ def submit_case( raise RuntimeError(f"sbatch failed: {result.stderr.strip()}") job_id = result.stdout.strip() - return { - "success": True, - "job_id": job_id, - "case_name": _checked_job_name(case_name, case_path), - "case_path": case_path, - "time_h": time_hours, - "mem_gb": mem_gb, - "cpus": final_cpus, - } + return tool_success( + job_id=job_id, + case_name=_checked_job_name(case_name, case_path), + case_path=case_path, + time_h=time_hours, + mem_gb=mem_gb, + cpus=final_cpus, + ) diff --git a/LTSpice/README.md b/LTSpice/README.md index b74ad56..aff8a5a 100644 --- a/LTSpice/README.md +++ b/LTSpice/README.md @@ -50,6 +50,20 @@ Configure in your MCP client (e.g., Cursor, Claude Desktop): - **create_rc_transient_netlist(...)**: Helper to create a standard RC circuit netlist. - **view_netlist_in_ltspice(netlist_path: str)**: Open netlist in LTSpice GUI. +## Tool results + +Every tool returns the shape the whole distribution uses: + +```json +{"status": "success", "...": "the keys that tool documents"} +{"status": "error", "message": "what went wrong"} +``` + +spicelib and matplotlib are imported by the two tools that need them, not at +startup, so the server runs and lists its tools with either one absent. +`list_available_traces` and `plot_specific_traces` then report which package to +install. The other tools need neither. + ## Prompt Example Could you create a simple RC circuit netlist with a 1k resistor and 1uF capacitor, run a transient simulation for 5ms, and plot the output voltage? diff --git a/LTSpice/ltspice_mcp.py b/LTSpice/ltspice_mcp.py index 3b076b3..99fb6b6 100644 --- a/LTSpice/ltspice_mcp.py +++ b/LTSpice/ltspice_mcp.py @@ -33,29 +33,23 @@ from pathlib import Path # --- Third-Party Imports --- -# The MCP Python SDK provides the server implementation. -try: - from mcp.server.mcpserver import MCPServer as FastMCP -except ImportError: - sys.exit("Error: mcp library not found. Please run 'pip install mcp'.") +# The MCP Python SDK provides the server implementation. `powermcp run ltspice` +# reports a missing SDK before it launches this file, and a direct launch +# raises ImportError here with the module name in it. +from mcp.server.mcpserver import MCPServer as FastMCP -# `matplotlib` is used for plotting simulation results. -try: - import matplotlib.pyplot as plt -except ImportError: - sys.exit("Error: Matplotlib library not found. Please run 'pip install matplotlib'.") - -# `PyLTSpice` provides the tools to read LTSpice's binary .raw files. -try: - from spicelib.raw.raw_read import RawRead as LTSpiceRawRead -except ImportError: - sys.exit("Error: PyLTSpice/spicelib not found. Please run 'pip install PyLTSpice'.") +# matplotlib and spicelib are imported inside the tools that need them, so the +# server starts, and lists its tools, with either one absent. A tool that needs +# a missing one reports how to install it. +_MATPLOTLIB_HINT = "matplotlib is not installed. Run 'pip install matplotlib'." +_SPICELIB_HINT = "PyLTSpice/spicelib is not installed. Run 'pip install PyLTSpice'." _repo_root = str(Path(__file__).resolve().parents[1]) _repo_root_added = _repo_root not in sys.path if _repo_root_added: sys.path.insert(0, _repo_root) try: + from powermcp.errors import tool_error from powermcp.sandbox import PathNotAllowed, checked_path, ensure_checked_directory finally: if _repo_root_added: @@ -133,6 +127,27 @@ def _ensure_output_dir() -> str: return ensure_checked_directory(_output_dir(), purpose="generated output root") +def _raw_reader(): + """The spicelib .raw reader, imported on first use.""" + from spicelib.raw.raw_read import RawRead + + return RawRead + + +def _pyplot(): + """matplotlib's pyplot on the Agg backend, imported on first use. + + Agg is selected before pyplot is imported: these tools write PNG files on + machines with no display, and an interactive backend would try to open one. + """ + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + return plt + + def check_ltspice_executable(): """ Checks if the LTspice executable and its wrapper (Wine) are available. @@ -278,8 +293,11 @@ async def list_available_traces(raw_file_path: str) -> dict: if not os.path.exists(raw_file_path): return {"status": "error", "message": f"RAW file not found: '{raw_file_path}'"} try: - raw_reader = LTSpiceRawRead(raw_file_path) - traces = raw_reader.get_trace_names() + raw_read = _raw_reader() + except ImportError: + return tool_error(_SPICELIB_HINT) + try: + traces = raw_read(raw_file_path).get_trace_names() return {"status": "success", "traces": traces} except Exception as e: logging.error(f"Failed to read traces: {e}", exc_info=True) @@ -302,7 +320,16 @@ async def plot_specific_traces(raw_file_path: str, session_dir: str, trace_names return {"status": "error", "message": f"RAW file not found: '{raw_file_path}'"} try: - raw_reader = LTSpiceRawRead(raw_file_path) + raw_read = _raw_reader() + except ImportError: + return tool_error(_SPICELIB_HINT) + try: + plt = _pyplot() + except ImportError: + return tool_error(_MATPLOTLIB_HINT) + + try: + raw_reader = raw_read(raw_file_path) plt.style.use('seaborn-v0_8-whitegrid') plt.figure(figsize=(12, 7)) plt.title("LTSpice Simulation Results") diff --git a/LTSpice/requirements.txt b/LTSpice/requirements.txt index 4257282..92d94bd 100644 --- a/LTSpice/requirements.txt +++ b/LTSpice/requirements.txt @@ -1,6 +1,6 @@ # Core MCP dependencies mcp>=2,<3 -powerio[mcp,matrix]>=0.9.0,<1 +powerio[mcp,matrix]>=0.11.3,<0.12 # Circuit simulation dependencies matplotlib diff --git a/OpenDSS/README.md b/OpenDSS/README.md index ef1cd5c..fc3a049 100644 --- a/OpenDSS/README.md +++ b/OpenDSS/README.md @@ -33,7 +33,13 @@ Configure in your MCP client (e.g., Cursor, Claude Desktop): ## Available Tools -Responses are JSON with `success` and either `payload` (tabular data) or `error`. +Every tool returns the shape the whole distribution uses: `{"status": +"success", "payload": ...}` with the tabular data under `payload`, or +`{"status": "error", "message": ...}`. + +The OpenDSS engine loads on the first `compile_opendss_file` call, not at +import, so the server starts and lists its tools on a machine where the engine +is missing; the load failure then reaches the caller as an error result. ### Configuration diff --git a/OpenDSS/core/engine.py b/OpenDSS/core/engine.py index 40fbeca..2aa186e 100644 --- a/OpenDSS/core/engine.py +++ b/OpenDSS/core/engine.py @@ -1,7 +1,29 @@ -"""Single py_dss_interface DSS instance and dss_tools wiring.""" +"""The py_dss_interface DSS instance, built on first use. -from py_dss_interface import DSS -from py_dss_toolkit import dss_tools +Constructing ``DSS()`` loads the OpenDSS engine library, so it happens when a +tool is called rather than at import. The server then starts, and reports its +own capabilities, on a machine where the engine is missing or fails to load; +the failure reaches the caller as a tool result naming the cause. -dss = DSS() -dss_tools.update_dss(dss) +The instance is a process-wide singleton because OpenDSS keeps one global +circuit: ``dss_tools`` is pointed at it once, and every tool shares that state. +""" + +from __future__ import annotations + +from typing import Any + +_dss: Any = None + + +def get_dss() -> Any: + """Return the shared DSS instance, building and wiring it on first call.""" + global _dss + if _dss is None: + from py_dss_interface import DSS + from py_dss_toolkit import dss_tools + + instance = DSS() + dss_tools.update_dss(instance) + _dss = instance + return _dss diff --git a/OpenDSS/core/server.py b/OpenDSS/core/server.py index 8c2082e..b8a7278 100644 --- a/OpenDSS/core/server.py +++ b/OpenDSS/core/server.py @@ -1,7 +1,5 @@ """FastMCP factory: register all domain tools.""" -import core.engine # noqa: F401 — ensure DSS + dss_tools wired before tools run - from mcp.server.mcpserver import MCPServer as FastMCP from opendss_tools.configuration import register_configuration_tools diff --git a/OpenDSS/opendss_tools/configuration.py b/OpenDSS/opendss_tools/configuration.py index 9aa1eca..05951f0 100644 --- a/OpenDSS/opendss_tools/configuration.py +++ b/OpenDSS/opendss_tools/configuration.py @@ -7,7 +7,7 @@ from py_dss_toolkit import dss_tools from core import state -from core.engine import dss +from core.engine import get_dss from powermcp.sandbox import ( PathNotAllowed, allowed_roots, @@ -55,6 +55,7 @@ def compile_opendss_file(dss_file: str, force_recompile: bool = False) -> Dict[s } ) try: + get_dss() dss_tools.configuration.compile_dss(dss_file) readiness = dss_tools.configuration.circuit_readiness() state.circuit_loaded = True @@ -80,7 +81,7 @@ def compile_opendss_file(dss_file: str, force_recompile: bool = False) -> Dict[s def clear_all_opendss_memory() -> Dict[str, Any]: """Clear OpenDSS engine memory (ClearAll); resets circuit_loaded, solution_available, and last compiled path.""" try: - dss.text("ClearAll") + get_dss().text("ClearAll") state.circuit_loaded = False state.solution_available = False state.last_compiled_dss_file = None diff --git a/OpenDSS/requirements.txt b/OpenDSS/requirements.txt index f48f9c7..2e39542 100644 --- a/OpenDSS/requirements.txt +++ b/OpenDSS/requirements.txt @@ -1,3 +1,3 @@ py_dss_toolkit mcp>=2,<3 -powerio[mcp,matrix]>=0.9.0,<1 +powerio[mcp,matrix]>=0.11.3,<0.12 diff --git a/OpenDSS/utils/responses.py b/OpenDSS/utils/responses.py index 5ebc468..457b820 100644 --- a/OpenDSS/utils/responses.py +++ b/OpenDSS/utils/responses.py @@ -1,8 +1,13 @@ -"""JSON response helpers and precondition guards.""" +"""Result helpers and precondition checks for the OpenDSS tools. + +Every tool reports through the shape :mod:`powermcp.errors` names: tabular +data arrives under ``payload`` on a success, and a failure carries a message. +""" from typing import Any, Dict, Optional from core import state +from powermcp.errors import tool_error, tool_success def _json_safe(obj: Any) -> Any: @@ -22,25 +27,26 @@ def _json_safe(obj: Any) -> Any: def _ok(payload: Any = None) -> Dict[str, Any]: - out: Dict[str, Any] = {"success": True} - if payload is not None: - out["payload"] = _json_safe(payload) - return out + """Report a successful tool call, with any tabular data under ``payload``.""" + if payload is None: + return tool_success() + return tool_success(payload=_json_safe(payload)) def _err(msg: str) -> Dict[str, Any]: - return {"success": False, "error": msg} + """Report a failed tool call.""" + return tool_error(msg) def _require_circuit_loaded() -> Optional[Dict[str, Any]]: - """Return an error response if no case has been compiled in this MCP session.""" + """Return an error result if no case has been compiled in this MCP session.""" if not state.circuit_loaded: return _err("No circuit loaded; call compile_opendss_file first.") return None def _require_solution() -> Optional[Dict[str, Any]]: - """Return an error response if no snapshot solve has completed since compile/clear.""" + """Return an error result if no snapshot solve has completed since compile/clear.""" if not state.solution_available: return _err("No snapshot solution; call solve_opendss_snapshot first.") return None diff --git a/PLEXOSDB/README.md b/PLEXOSDB/README.md index 79edfb7..288cec6 100644 --- a/PLEXOSDB/README.md +++ b/PLEXOSDB/README.md @@ -45,7 +45,7 @@ Then run it through PowerMCP as usual: powermcp run plexosdb ``` -Verify both real installs and the resulting tool surface with `uv`: +Verify both real installs and the resulting registered tools with `uv`: ```bash uv pip install --prerelease=allow plexosdb "r2x-plexos>=0.3.0" "r2x-plexos-to-sienna>=0.1.0" "r2x-sienna>=0.4.0" @@ -57,7 +57,7 @@ python -c "from plexosdb_mcp.server import build_mcp_server; print(build_mcp_ser `PLEXOSDB/plexosdb_mcp/main.py` deliberately lives in a package also named `plexosdb_mcp` — the same import name as the upstream distribution it re-exports -(mirroring `powerio/powerio_mcp.py`'s re-export of the `powerio` package). Because of +(this package re-exports that distribution's server object under the same name). Because of that shared name, the registry launches it as a **script** (`entry_rel= "plexosdb_mcp/main.py"`), not as a module. Module-style launch would add `PLEXOSDB/` itself to `sys.path`, and `import plexosdb_mcp` inside `main.py` would then resolve to @@ -104,7 +104,7 @@ result = translate_to_sienna( model_name="Base", # a PLEXOS Model object name — see list_models output_path="/tmp/out/system.json", ) -# {"ok": True, "output_path": ..., "model_name": "Base", +# {"status": "success", "output_path": ..., "model_name": "Base", # "component_types": {"ACBus": 12, "ThermalStandard": 4, ...}} ``` @@ -123,6 +123,20 @@ comparing two scenarios of a study, or a study before/after an edit made through plexosdb-mcp's own CRUD tools. It does not solve anything (no PLEXOS license is present); solving is the paired `SIENNA` connector's job. +## Tool results + +Both tools return the shape the whole distribution uses: + +```json +{"status": "success", "...": "the keys that tool documents"} +{"status": "error", "message": "what went wrong"} +``` + +A refused path and every r2x failure arrive the same way, so a missing model +name, an unreadable XML study or a failed export reads as a message rather than +as an MCP protocol error. The unexpected ones are logged with their traceback +on stderr, which keeps stdout free for the JSON-RPC channel. + ## Known upstream issues (tracked, and worked around) `translate_to_sienna`/`compare_solutions` were exercised end-to-end against a real diff --git a/PLEXOSDB/plexosdb_mcp/main.py b/PLEXOSDB/plexosdb_mcp/main.py index 1166fa6..8aa7fb2 100644 --- a/PLEXOSDB/plexosdb_mcp/main.py +++ b/PLEXOSDB/plexosdb_mcp/main.py @@ -10,10 +10,9 @@ PLEXOSDB/README.md for the (currently git-only) install step. This module keeps no copy of plexosdb-mcp's tool implementations -- it builds the -upstream FastMCP server object as-is (``build_mcp_server``) and re-exports it, the -same shape as ``powerio/powerio_mcp.py``'s ``mcp = _server.mcp``. plexosdb-mcp builds -its server via a factory rather than a module-level singleton, so calling that -factory once at import time is the direct analogue here. +upstream FastMCP server object as-is (``build_mcp_server``) and re-exports it as +``mcp``. plexosdb-mcp builds its server through a factory rather than a +module-level singleton, so this module calls that factory once at import time. The two tools added below call r2x's real, public API directly: ``r2x_plexos. PLEXOSParser`` builds an r2x System from a PLEXOS XML study, ``r2x_plexos_to_sienna. @@ -35,6 +34,7 @@ from __future__ import annotations +import logging import sys from pathlib import Path from typing import Any @@ -48,7 +48,8 @@ if _repo_root_added: sys.path.insert(0, _repo_root) try: - from powermcp.sandbox import PathNotAllowed, checked_path + from powermcp.errors import run_tool, tool_success + from powermcp.sandbox import checked_path finally: if _repo_root_added: sys.path.remove(_repo_root) @@ -56,6 +57,8 @@ from plexosdb_mcp import server as _server +logger = logging.getLogger(__name__) + # -- thin re-export of plexosdb-mcp's own server -------------------------- # MCPServerState = _server.MCPServerState build_mcp_server = _server.build_mcp_server @@ -121,8 +124,10 @@ def translate_to_sienna( Returns ------- - dict with ``ok``, ``output_path``, and a ``component_types`` count summary - of the translated Sienna system. + ``{"status": "success", "output_path": ..., "model_name": ..., + "component_types": {...}}`` with a component count per type in the + translated Sienna system, or ``{"status": "error", "message": ...}`` for a + refused path or any r2x failure. Notes ----- @@ -141,41 +146,47 @@ def translate_to_sienna( PLEXOSDB/README.md for the install command and both upstream issues (NatLabRockies/R2X#299, epri-dev/plexos2duckdb#3). """ - try: + + def run() -> dict[str, Any]: + nonlocal xml_path, output_path + xml_path = checked_path(xml_path, purpose="xml_path") output_path = checked_path(output_path, purpose="output_path", for_write=True) - except PathNotAllowed as exc: - return {"ok": False, "error": str(exc)} - - from r2x_core import PluginContext - from r2x_plexos import PLEXOSConfig, PLEXOSParser - from r2x_plexos_to_sienna import PlexosToSiennaConfig, plexos_to_sienna - from r2x_sienna import SiennaExporter, SiennaExporterConfig - - plexos_config = PLEXOSConfig(fpath=xml_path, model_name=model_name, horizon_year=horizon_year) - parse_ctx = PluginContext(config=plexos_config) - parse_ctx = PLEXOSParser.from_context(parse_ctx).run() - - sienna_system = plexos_to_sienna(parse_ctx.system, PlexosToSiennaConfig()) - - export_config = SiennaExporterConfig( - output_path=output_path, - system_base_power=system_base_power, - scenario=scenario, - ) - export_ctx = PluginContext(config=export_config, system=sienna_system) - SiennaExporter.from_context(export_ctx).run() - - component_types = { - component_type.__name__: len(list(sienna_system.get_components(component_type))) - for component_type in sienna_system.get_component_types() - } - return { - "ok": True, - "output_path": output_path, - "model_name": model_name, - "component_types": component_types, - } + + from r2x_core import PluginContext + from r2x_plexos import PLEXOSConfig, PLEXOSParser + from r2x_plexos_to_sienna import PlexosToSiennaConfig, plexos_to_sienna + from r2x_sienna import SiennaExporter, SiennaExporterConfig + + plexos_config = PLEXOSConfig( + fpath=xml_path, model_name=model_name, horizon_year=horizon_year + ) + parse_ctx = PluginContext(config=plexos_config) + parse_ctx = PLEXOSParser.from_context(parse_ctx).run() + + sienna_system = plexos_to_sienna(parse_ctx.system, PlexosToSiennaConfig()) + + export_config = SiennaExporterConfig( + output_path=output_path, + system_base_power=system_base_power, + scenario=scenario, + ) + export_ctx = PluginContext(config=export_config, system=sienna_system) + SiennaExporter.from_context(export_ctx).run() + + component_types = { + component_type.__name__: len( + list(sienna_system.get_components(component_type)) + ) + for component_type in sienna_system.get_component_types() + } + return tool_success( + output_path=output_path, + model_name=model_name, + component_types=component_types, + ) + + return run_tool(run, logger=logger) @mcp.tool() @@ -203,49 +214,63 @@ def compare_solutions( Returns ------- - dict with per-component-type counts for each side and the set of - component types whose count differs between them. + ``{"status": "success", "model_a": ..., "model_b": ..., "differences": + ..., "identical": ...}`` with per-component-type counts for each side and + the component types whose count differs, or ``{"status": "error", + "message": ...}`` for a refused path or any r2x failure. Notes ----- Uses the same ``r2x_plexos>=0.3.0`` pin documented on ``translate_to_sienna``, which fixes the upstream Horizon-resolution bug this connector previously hit. """ - try: + + def run() -> dict[str, Any]: + nonlocal xml_path_a, xml_path_b + xml_path_a = checked_path(xml_path_a, purpose="xml_path_a") xml_path_b = checked_path(xml_path_b, purpose="xml_path_b") - except PathNotAllowed as exc: - return {"ok": False, "error": str(exc)} - - from r2x_core import PluginContext - from r2x_plexos import PLEXOSConfig, PLEXOSParser - - def _component_counts(xml_path: str, model_name: str) -> dict[str, int]: - config = PLEXOSConfig(fpath=xml_path, model_name=model_name) - ctx = PluginContext(config=config) - ctx = PLEXOSParser.from_context(ctx).run() - return { - component_type.__name__: len(list(ctx.system.get_components(component_type))) - for component_type in ctx.system.get_component_types() + + from r2x_core import PluginContext + from r2x_plexos import PLEXOSConfig, PLEXOSParser + + def component_counts(xml_path: str, model_name: str) -> dict[str, int]: + config = PLEXOSConfig(fpath=xml_path, model_name=model_name) + ctx = PluginContext(config=config) + ctx = PLEXOSParser.from_context(ctx).run() + return { + component_type.__name__: len( + list(ctx.system.get_components(component_type)) + ) + for component_type in ctx.system.get_component_types() + } + + counts_a = component_counts(xml_path_a, model_name_a) + counts_b = component_counts(xml_path_b, model_name_b) + + all_types = sorted(set(counts_a) | set(counts_b)) + differences = { + t: {"a": counts_a.get(t, 0), "b": counts_b.get(t, 0)} + for t in all_types + if counts_a.get(t, 0) != counts_b.get(t, 0) } - counts_a = _component_counts(xml_path_a, model_name_a) - counts_b = _component_counts(xml_path_b, model_name_b) - - all_types = sorted(set(counts_a) | set(counts_b)) - differences = { - t: {"a": counts_a.get(t, 0), "b": counts_b.get(t, 0)} - for t in all_types - if counts_a.get(t, 0) != counts_b.get(t, 0) - } - - return { - "ok": True, - "model_a": {"xml_path": xml_path_a, "model_name": model_name_a, "component_types": counts_a}, - "model_b": {"xml_path": xml_path_b, "model_name": model_name_b, "component_types": counts_b}, - "differences": differences, - "identical": not differences, - } + return tool_success( + model_a={ + "xml_path": xml_path_a, + "model_name": model_name_a, + "component_types": counts_a, + }, + model_b={ + "xml_path": xml_path_b, + "model_name": model_name_b, + "component_types": counts_b, + }, + differences=differences, + identical=not differences, + ) + + return run_tool(run, logger=logger) if __name__ == "__main__": diff --git a/PLEXOSDB/pyproject.toml b/PLEXOSDB/pyproject.toml index 04e47a6..85883e5 100644 --- a/PLEXOSDB/pyproject.toml +++ b/PLEXOSDB/pyproject.toml @@ -33,8 +33,8 @@ Repository = "https://github.com/Power-Agent/PowerMCP" include = ["plexosdb_mcp*"] # NOTE ON THE NAME COLLISION: this package's importable name is deliberately -# `plexosdb_mcp` -- the same as the upstream `plexosdb-mcp` distribution it -# re-exports (mirrors powerio_mcp.py's re-export of the `powerio` package). +# `plexosdb_mcp` -- the same as the upstream `plexosdb-mcp` distribution whose +# server object it re-exports. # Do not `pip install -e PLEXOSDB` into the same environment as the upstream # git-installed `plexosdb-mcp` package and expect both to cleanly coexist as # independent site-packages entries; see PLEXOSDB/README.md for the supported diff --git a/PLEXOSDB/tests/test_tools.py b/PLEXOSDB/tests/test_tools.py index 7a4a5ed..f5e715c 100644 --- a/PLEXOSDB/tests/test_tools.py +++ b/PLEXOSDB/tests/test_tools.py @@ -215,7 +215,7 @@ def fake_get_components(ct): class TestUpstreamReExport(unittest.TestCase): """The re-exported plexosdb-mcp tools themselves are not PowerMCP's to unit test (they belong to the upstream project); this only checks the shape of - the re-export, matching powerio_mcp.py's own precedent.""" + the re-export.""" def test_mcp_and_upstream_names_are_re_exported(self): mod = _load_main_module() diff --git a/PSCAD/pyproject.toml b/PSCAD/pyproject.toml index 0bde692..713ea91 100644 --- a/PSCAD/pyproject.toml +++ b/PSCAD/pyproject.toml @@ -14,7 +14,7 @@ authors = [ ] dependencies = [ "mcp>=2,<3", - "powerio[mcp,matrix]>=0.9.0,<1", + "powerio[mcp,matrix]>=0.11.3,<0.12", "psutil" ] diff --git a/PSLF/requirements.txt b/PSLF/requirements.txt index 8abc20a..67558bd 100644 --- a/PSLF/requirements.txt +++ b/PSLF/requirements.txt @@ -1,3 +1,3 @@ pandas mcp>=2,<3 -powerio[mcp,matrix]>=0.9.0,<1 +powerio[mcp,matrix]>=0.11.3,<0.12 diff --git a/PSSE/requirements.txt b/PSSE/requirements.txt index 9dbf956..24a40da 100644 --- a/PSSE/requirements.txt +++ b/PSSE/requirements.txt @@ -1,2 +1,2 @@ mcp>=2,<3 -powerio[mcp,matrix]>=0.9.0,<1 +powerio[mcp,matrix]>=0.11.3,<0.12 diff --git a/PowerFactory/Agent_DIgSILENT.py b/PowerFactory/Agent_DIgSILENT.py index 3b58662..5df5ebe 100644 --- a/PowerFactory/Agent_DIgSILENT.py +++ b/PowerFactory/Agent_DIgSILENT.py @@ -2151,8 +2151,11 @@ def _done(ok: bool, msg: str) -> tuple[bool, str]: def run_pipeline(self) -> dict: """ - Execute the full pipeline and return a status report dict. - Each step is guarded: a failure stops the pipeline early. + Execute the full pipeline and return a step-by-step report. + + A failed step stops the pipeline and the report keeps whichever steps + already ran. ``status`` and ``message`` carry the shared tool result + shape, since the MCP server hands this report back as a tool result. """ report = { "connect": None, @@ -2164,7 +2167,8 @@ def run_pipeline(self) -> dict: "pfd_export": None, "csv_path": None, "pfd_path": None, - "success": False, + "status": "error", + "message": None, } steps = [ @@ -2179,7 +2183,8 @@ def run_pipeline(self) -> dict: ok, msg = fn() report[key] = {"ok": ok, "msg": msg} if not ok: - log.error(f"Pipeline stopped at step '{key}': {msg}") + report["message"] = f"Pipeline stopped at step '{key}': {msg}" + log.error(report["message"]) return report # -- Standard plots (always enabled by default) ---------------- @@ -2197,16 +2202,18 @@ def run_pipeline(self) -> dict: ok, msg = self.export_project_to_pfd() report["pfd_export"] = {"ok": ok, "msg": msg} if not ok: - log.error(f"Pipeline stopped at step 'pfd_export': {msg}") + report["message"] = f"Pipeline stopped at step 'pfd_export': {msg}" + log.error(report["message"]) return report report["pfd_path"] = msg else: report["pfd_export"] = {"ok": True, "msg": "Skipped (export_pfd=0)"} report["csv_path"] = report["csv_export"]["msg"] - report["success"] = True + report["status"] = "success" + report["message"] = f"All steps passed. Results: {report['csv_path']}" log.section("PIPELINE COMPLETE") - log.ok(f"All steps passed. Results → {report['csv_path']}") + log.ok(report["message"]) return report diff --git a/PowerFactory/MCP_PowerFactory.py b/PowerFactory/MCP_PowerFactory.py index 313d732..b423fdc 100644 --- a/PowerFactory/MCP_PowerFactory.py +++ b/PowerFactory/MCP_PowerFactory.py @@ -68,6 +68,7 @@ def _stderr_print(*args, _p=_bt.print, **kwargs): if _repo_root_added: sys.path.insert(0, _repo_root) try: + from powermcp.errors import tool_error, tool_success from powermcp.sandbox import ( checked_path, checked_read_tree, @@ -135,13 +136,21 @@ def _pf(fn, *args, **kwargs): return _pf_executor.submit(fn, *args, **kwargs).result() +def _reported(ok: bool, message: str, **fields: Any) -> dict[str, Any]: + """Map a DIgSILENTAgent (ok, message) pair onto the shared result shape.""" + if ok: + return tool_success(message=message, **fields) + return tool_error(message, **fields) + + def _agent_result(method_name: str, *args) -> str: + """Call a DIgSILENTAgent method on the PowerFactory thread and serialize it.""" _, DIgSILENTAgent = _load_modules() ok, message = _pf( getattr(DIgSILENTAgent, method_name), *args, ) - return json.dumps({"success": ok, "message": message}) + return json.dumps(_reported(ok, message)) def _load_modules(): @@ -200,13 +209,13 @@ def close_digsilent() -> str: Returns ------- str - JSON string with success flag and message. + JSON string with a "status" of "success" or "error", and a message. """ _, DIgSILENTAgent = _load_modules() def _impl(): DIgSILENTAgent.close() - return {"success": True, "message": "DIgSILENT API closed"} + return tool_success(message="DIgSILENT API closed") return _to_json(_pf(_impl)) @@ -226,21 +235,14 @@ def get_active_project() -> str: def _impl(): app = DIgSILENTAgent._shared_app if app is None: - return { - "success": False, - "message": "PowerFactory is not connected", - } + return tool_error("PowerFactory is not connected") project = app.GetActiveProject() if project is None: - return { - "success": False, - "message": "No PowerFactory project is active", - } - return { - "success": True, - "name": project.GetAttribute("loc_name"), - "full_name": project.GetFullName(), - } + return tool_error("No PowerFactory project is active") + return tool_success( + name=project.GetAttribute("loc_name"), + full_name=project.GetFullName(), + ) return _to_json(_pf(_impl)) @@ -252,21 +254,14 @@ def get_active_study_case() -> str: def _impl(): app = DIgSILENTAgent._shared_app if app is None: - return { - "success": False, - "message": "PowerFactory is not connected", - } + return tool_error("PowerFactory is not connected") study_case = app.GetActiveStudyCase() if study_case is None: - return { - "success": False, - "message": "No PowerFactory study case is active", - } - return { - "success": True, - "name": study_case.GetAttribute("loc_name"), - "full_name": study_case.GetFullName(), - } + return tool_error("No PowerFactory study case is active") + return tool_success( + name=study_case.GetAttribute("loc_name"), + full_name=study_case.GetFullName(), + ) return _to_json(_pf(_impl)) @@ -282,26 +277,17 @@ def get_parameters( def _impl(): app = DIgSILENTAgent._shared_app if app is None: - return { - "success": False, - "message": "PowerFactory is not connected", - } + return tool_error("PowerFactory is not connected") variable_names = list( dict.fromkeys(name.strip() for name in variables if name.strip()) ) if not variable_names: - return { - "success": False, - "message": "At least one variable is required", - } + return tool_error("At least one variable is required") objects = app.GetCalcRelevantObjects(object_query) or [] if not objects: - return { - "success": False, - "message": f"No objects found for query: {object_query}", - } + return tool_error(f"No objects found for query: {object_query}") limit = max(1, min(int(max_results), 1000)) results = [] @@ -333,14 +319,13 @@ def _impl(): results.append(item) - return { - "success": True, - "query": object_query, - "variables": variable_names, - "total_count": len(objects), - "returned_count": len(results), - "results": results, - } + return tool_success( + query=object_query, + variables=variable_names, + total_count=len(objects), + returned_count=len(results), + results=results, + ) return _to_json(_pf(_impl)) @@ -384,10 +369,7 @@ def list_objects(object_query: str = "*.ElmTerm", max_results: int = 100) -> str def _impl(): app = DIgSILENTAgent._shared_app if app is None: - return { - "success": False, - "message": "PowerFactory is not connected", - } + return tool_error("PowerFactory is not connected") objects = app.GetCalcRelevantObjects(object_query) or [] limit = max(1, min(int(max_results), 1000)) results = [ @@ -398,13 +380,12 @@ def _impl(): } for obj in objects[:limit] ] - return { - "success": True, - "query": object_query, - "total_count": len(objects), - "returned_count": len(results), - "results": results, - } + return tool_success( + query=object_query, + total_count=len(objects), + returned_count=len(results), + results=results, + ) return _to_json(_pf(_impl)) @@ -431,24 +412,17 @@ def list_components( queries = _COMPONENT_QUERIES.get(category) if queries is None: - return _to_json({ - "success": False, - "message": f"Unsupported component type: {component_type}", - "supported_component_types": [ - "all", - *_COMPONENT_QUERIES, - ], - }) + return _to_json(tool_error( + f"Unsupported component type: {component_type}", + supported_component_types=["all", *_COMPONENT_QUERIES], + )) _, DIgSILENTAgent = _load_modules() def _impl(): app = DIgSILENTAgent._shared_app if app is None: - return { - "success": False, - "message": "PowerFactory is not connected", - } + return tool_error("PowerFactory is not connected") components = {} @@ -476,14 +450,13 @@ def _impl(): results = list(components.values()) limit = max(1, min(int(max_results), 1000)) - return { - "success": True, - "component_type": category, - "queries": list(queries), - "total_count": len(results), - "returned_count": min(len(results), limit), - "results": results[:limit], - } + return tool_success( + component_type=category, + queries=list(queries), + total_count=len(results), + returned_count=min(len(results), limit), + results=results[:limit], + ) return _to_json(_pf(_impl)) @@ -495,16 +468,10 @@ def list_study_cases(max_results: int = 100) -> str: def _impl(): app = DIgSILENTAgent._shared_app if app is None: - return { - "success": False, - "message": "PowerFactory is not connected", - } + return tool_error("PowerFactory is not connected") folder = app.GetProjectFolder("study") if folder is None: - return { - "success": False, - "message": "Study-case folder was not found", - } + return tool_error("Study-case folder was not found") study_cases = folder.GetContents("*.IntCase", 1) or [] active_case = app.GetActiveStudyCase() active_full_name = active_case.GetFullName() if active_case else None @@ -519,12 +486,11 @@ def _impl(): "full_name": full_name, "is_active": full_name == active_full_name, }) - return { - "success": True, - "total_count": len(study_cases), - "returned_count": len(results), - "results": results, - } + return tool_success( + total_count=len(study_cases), + returned_count=len(results), + results=results, + ) return _to_json(_pf(_impl)) @@ -550,14 +516,14 @@ def import_project( Returns ------- str - JSON string with success flag and message. + JSON string with a "status" of "success" or "error", and a message. """ if not file_path: - return json.dumps({"success": False, "message": "file_path is required"}) + return json.dumps(tool_error("file_path is required")) file_path = checked_path(file_path, purpose="file_path") _, DIgSILENTAgent = _load_modules() ok, msg = _pf(DIgSILENTAgent.import_project, file_path, open_digsilent) - return json.dumps({"success": ok, "message": msg}) + return json.dumps(_reported(ok, msg)) @mcp.tool() @@ -589,7 +555,7 @@ def create_study_case( Returns ------- str - JSON string with success flag and message. + JSON string with a "status" of "success" or "error", and a message. """ SimulationConfig, DIgSILENTAgent = _load_modules() path = checked_path(cfg_path, purpose="cfg_path") if cfg_path else _default_cfg_path() @@ -602,7 +568,7 @@ def create_study_case( open_digsilent, request_id, ) - return json.dumps({"success": ok, "message": msg}) + return json.dumps(_reported(ok, msg)) @mcp.tool() @@ -630,11 +596,11 @@ def modify_parameter( Returns ------- str - JSON string with success flag and message. + JSON string with a "status" of "success" or "error", and a message. """ _, DIgSILENTAgent = _load_modules() ok, msg = _pf(DIgSILENTAgent.modify_parameter, object_name, variable, new_value, open_digsilent) - return json.dumps({"success": ok, "message": msg}) + return json.dumps(_reported(ok, msg)) @mcp.tool() @@ -665,7 +631,7 @@ def add_component( Set update_graphics to true to insert missing network elements into the currently active single-line diagram using PowerFactory's Diagram Layout Tool. If insertion fails, the network component remains created, - but the tool returns success=false with the graphical error. + but the tool reports "status": "error" with the graphical error. """ return _agent_result( "add_component", @@ -707,11 +673,7 @@ def delete_component( open_digsilent, update_graphics, ) - return json.dumps({ - "success": ok, - "deleted": ok and bool(confirmation), - "message": message, - }) + return json.dumps(_reported(ok, message, deleted=ok and bool(confirmation))) @mcp.tool() @@ -740,7 +702,7 @@ def run_loadflow( Returns ------- str - JSON string with success flag and message. + JSON string with a "status" of "success" or "error", and a message. """ SimulationConfig, DIgSILENTAgent = _load_modules() @@ -757,14 +719,11 @@ def run_loadflow( run_label = getattr(cfg, "run_label", run_label) or run_label except Exception as e: return json.dumps( - { - "success": False, - "message": f"Could not read config for CSV export: {e}", - } + tool_error(f"Could not read config for CSV export: {e}") ) ok, msg = _pf(DIgSILENTAgent.load_flow, open_digsilent, save_csv, output_dir, run_label) - return json.dumps({"success": ok, "message": msg}) + return json.dumps(_reported(ok, msg)) @mcp.tool() @@ -780,11 +739,11 @@ def run_short_circuit(open_digsilent: bool = True) -> str: Returns ------- str - JSON string with success flag and message. + JSON string with a "status" of "success" or "error", and a message. """ _, DIgSILENTAgent = _load_modules() ok, msg = _pf(DIgSILENTAgent.short_circuit, open_digsilent) - return json.dumps({"success": ok, "message": msg}) + return json.dumps(_reported(ok, msg)) @mcp.tool() @@ -814,7 +773,7 @@ def run_simulation( Returns ------- str - JSON string with success flag, csv_path, optional pfd_path, + JSON string with a "status", csv_path, optional pfd_path, and per-step status. """ SimulationConfig, DIgSILENTAgent = _load_modules() @@ -937,9 +896,10 @@ def read_results_csv(csv_path: str = "", max_rows: int = 2000, as_path: bool = F New parameters -------------- as_path : bool, optional - If True, return a small JSON object containing the absolute file - path instead of the file contents. Use this to avoid hitting MCP - transport size limits when passing the CSV to external LLMs. + If True, return a small JSON object carrying "status" and the + absolute file path instead of the file contents. Use this to avoid + hitting MCP transport size limits when passing the CSV to external + LLMs. max_bytes : int, optional If > 0, the returned CSV text will be truncated to at most `max_bytes` bytes (UTF-8 encoded). Truncation happens at row @@ -957,7 +917,8 @@ def read_results_csv(csv_path: str = "", max_rows: int = 2000, as_path: bool = F ------- str CSV text (header + up to max_rows rows) followed by metadata lines - with file path, total rows, and truncation flag. + with file path, total rows, and truncation flag. A failure instead + returns a JSON string with "status": "error" and a message. """ if csv_path: target = checked_path(csv_path, purpose="csv_path") @@ -976,17 +937,19 @@ def read_results_csv(csv_path: str = "", max_rows: int = 2000, as_path: bool = F candidates.append((os.path.getmtime(full), full)) if not candidates: - return json.dumps({"error": f"No *_RMS.csv files found under {base_dir}"}) + return json.dumps( + tool_error(f"No *_RMS.csv files found under {base_dir}") + ) candidates.sort(reverse=True) target = candidates[0][1] target = checked_path(target, purpose="results CSV path") if not os.path.exists(target): - return json.dumps({"error": f"File not found: {target}"}) + return json.dumps(tool_error(f"File not found: {target}")) if as_path: - return json.dumps({"file_path": target}) + return json.dumps(tool_success(file_path=target)) with open(target, "r", encoding="utf-8", errors="replace") as fh: lines = fh.readlines() diff --git a/PowerFactory/README.md b/PowerFactory/README.md index cebff36..59836ca 100644 --- a/PowerFactory/README.md +++ b/PowerFactory/README.md @@ -19,7 +19,7 @@ Agent_DIgSILENT.py ← Simulation engine DIgSILENT PowerFactory │ ▼ -Output folder: CSV results · PNG plots · optional .pfd export +Output folder: CSV results, PNG plots, optional .pfd export ``` ## Features @@ -39,6 +39,20 @@ Output folder: CSV results · PNG plots · optional .pfd export --- +## Tool results + +Every tool returns a JSON string carrying the shape the whole distribution +uses: + +```json +{"status": "success", "message": "Load flow OK"} +{"status": "error", "message": "PowerFactory is not connected"} +``` + +`ping` returns the bare string `"pong"`, `get_config` returns the configuration +file's own JSON, and `read_results_csv` returns CSV text on success; each of +those still reports a failure through the error shape above. + ## Implemented Functions ### MCP Tools (`MCP_PowerFactory.py`) @@ -93,7 +107,7 @@ Output folder: CSV results · PNG plots · optional .pfd export | `DIgSILENTAgent.add_component` | Creates and verifies supported network components and their connections. | | `DIgSILENTAgent.delete_component` | Performs guarded exact-name component deletion and cleanup. | | `DIgSILENTAgent.short_circuit` | Standalone ComShc execution. | -| `DIgSILENTAgent.run_pipeline` | Orchestrates the full workflow and returns a structured status report. | +| `DIgSILENTAgent.run_pipeline` | Orchestrates the full workflow and returns a per-step report carrying `status` and `message`. | | `DIgSILENTAgent.close` | Shuts down PowerFactory and clears shared handles. | ## Component Management @@ -127,7 +141,7 @@ Each generated connection cubicle contains one closed circuit breaker request insertion into the active single-line diagram. If graphical insertion fails, the network component remains created and the -tool returns `success=false` with the graphical error. Check the returned +tool reports `"status": "error"` with the graphical error. Check the returned message before retrying to avoid creating a duplicate. PowerFactory may place an isolated bus far from the existing network when it diff --git a/PowerFactory/requirements.txt b/PowerFactory/requirements.txt index 201c9ed..e11d88b 100644 --- a/PowerFactory/requirements.txt +++ b/PowerFactory/requirements.txt @@ -1,6 +1,6 @@ # MCP Python SDK 2 server framework mcp>=2,<3 -powerio[mcp,matrix]>=0.9.0,<1 +powerio[mcp,matrix]>=0.11.3,<0.12 # Scientific stack numpy>=1.26 diff --git a/PowerFactory/test_state_inspection.py b/PowerFactory/test_state_inspection.py index e2c3f21..e90afb6 100644 --- a/PowerFactory/test_state_inspection.py +++ b/PowerFactory/test_state_inspection.py @@ -119,11 +119,11 @@ def test_state_and_discovery_tools(self): ) active_project = json.loads(mcp_module.get_active_project()) - self.assertTrue(active_project["success"]) + self.assertEqual(active_project["status"], "success") self.assertEqual(active_project["name"], "test") active_case = json.loads(mcp_module.get_active_study_case()) - self.assertTrue(active_case["success"]) + self.assertEqual(active_case["status"], "success") self.assertEqual(active_case["name"], "Case 1") parameters = json.loads( @@ -133,7 +133,7 @@ def test_state_and_discovery_tools(self): max_results=1, ) ) - self.assertTrue(parameters["success"]) + self.assertEqual(parameters["status"], "success") self.assertEqual(parameters["variables"], ["m:u", "uknom"]) self.assertEqual(parameters["total_count"], 2) self.assertEqual(parameters["returned_count"], 1) @@ -157,7 +157,7 @@ def test_state_and_discovery_tools(self): FakeAgent._shared_app = None disconnected = json.loads(mcp_module.get_active_project()) - self.assertFalse(disconnected["success"]) + self.assertEqual(disconnected["status"], "error") def test_list_components(self): bus = FakeObject( @@ -195,14 +195,14 @@ def test_list_components(self): buses = json.loads( mcp_module.list_components("buses", max_results=10) ) - self.assertTrue(buses["success"]) + self.assertEqual(buses["status"], "success") self.assertEqual(buses["total_count"], 1) self.assertEqual(buses["results"][0]["name"], "Bus 01") branches = json.loads( mcp_module.list_components("branches", max_results=10) ) - self.assertTrue(branches["success"]) + self.assertEqual(branches["status"], "success") self.assertEqual(branches["total_count"], 2) self.assertEqual(branches["returned_count"], 2) self.assertEqual( @@ -230,7 +230,7 @@ def test_list_components(self): unsupported = json.loads( mcp_module.list_components("unknown") ) - self.assertFalse(unsupported["success"]) + self.assertEqual(unsupported["status"], "error") self.assertIn( "buses", unsupported["supported_component_types"], diff --git a/PowerWorld/powerworld_mcp.py b/PowerWorld/powerworld_mcp.py index 7dfc2b7..319c451 100644 --- a/PowerWorld/powerworld_mcp.py +++ b/PowerWorld/powerworld_mcp.py @@ -28,7 +28,7 @@ def _get_saw(case_path: Optional[str] = None) -> SAW: try: _saw = SAW(case_path, UIVisible=True) except PowerWorldError as e: - print(f"Error initializing SAW: {str(e)}") + print(f"Error initializing SAW: {str(e)}", file=sys.stderr) raise elif _saw is None: raise ValueError("No case is currently open. Please open a case first.") @@ -219,7 +219,7 @@ def analyze_contingencies(option: str = "N-1", validate: bool = False) -> Dict[s saw.LoadState() except Exception as e: - print(f"Error analyzing contingency: {str(e)}") + print(f"Error analyzing contingency: {str(e)}", file=sys.stderr) continue return { diff --git a/PowerWorld/requirements.txt b/PowerWorld/requirements.txt index 5d31ea4..87a825c 100644 --- a/PowerWorld/requirements.txt +++ b/PowerWorld/requirements.txt @@ -1,4 +1,4 @@ esa numba mcp>=2,<3 -powerio[mcp,matrix]>=0.9.0,<1 +powerio[mcp,matrix]>=0.11.3,<0.12 diff --git a/PyPSA/README.md b/PyPSA/README.md index b02ba9c..c865797 100644 --- a/PyPSA/README.md +++ b/PyPSA/README.md @@ -54,8 +54,8 @@ Configure in your MCP client (e.g., Cursor, Claude Desktop): - [x] `optimize_investment` - Run capacity expansion optimization - [x] `import_from_csv_folder` - Import network from CSV files - [x] `export_to_csv_folder` - Export network to CSV format -- [x] `import_case_from_any` - Import any PowerIO-readable case or one selected `.pio.json` package state to NetCDF -- [x] `import_case_from_json` - Import PowerIO model JSON or one selected `.pio.json` package state to NetCDF +- [x] `import_case_from_any` - Import any PowerIO-readable case, or one selected entry of a PowerIO IR collection, to NetCDF +- [x] `import_case_from_json` - Import serialized PowerIO IR, or one selected entry of it, to NetCDF - [x] `run_contingency_analysis` - N-1 contingency analysis # Future functionalities diff --git a/PyPSA/pypsa_mcp.py b/PyPSA/pypsa_mcp.py index 641d63d..9664137 100644 --- a/PyPSA/pypsa_mcp.py +++ b/PyPSA/pypsa_mcp.py @@ -13,7 +13,7 @@ if _repo_root_added: sys.path.insert(0, _repo_root) try: - from powermcp.solver_case import resolve_solver_case + from powermcp.solver_case import resolve_solver_case, diagnostic_messages from powermcp.sandbox import ( PathNotAllowed, checked_path, @@ -682,11 +682,13 @@ def export_to_csv_folder(network_name: str, folder_path: str) -> Dict[str, Any]: try: network_name = _checked_network_source(network_name, purpose="network_name") network = Network(network_name) - staged_directory_write( - folder_path, - True, - lambda staging: network.export_to_csv_folder(staging), - ) + + def write_csv_folder(staging: str) -> None: + # powerio hands the writer a path that does not exist yet. + os.makedirs(staging, exist_ok=True) + network.export_to_csv_folder(staging) + + staged_directory_write(folder_path, True, write_csv_folder) return { "status": "success", "message": f"Network exported to {folder_path}" @@ -705,15 +707,15 @@ def export_to_csv_folder(network_name: str, folder_path: str) -> Dict[str, Any]: def _import_case_to_netcdf(case, output_path: str, overwrite_zero_s_nom: Optional[float]): """Use PowerIO's native PyPSA CSV writer, then persist the network.""" - with tempfile.TemporaryDirectory(prefix="powermcp-pypsa-") as staging: - written = case.write_pypsa_csv_folder(staging) + with tempfile.TemporaryDirectory(prefix="powermcp-pypsa-") as staging_root: + staging = os.path.join(staging_root, "case") + written = case.emit("pypsa-csv", staging) network = Network() network.import_from_csv_folder(staging) - warnings = list(written.get("warnings", [])) + warnings = list(diagnostic_messages(written.diagnostics)) - # PowerIO 0.9 preserves source generator voltage targets in its native - # generators.csv extension column. PyPSA imports that column but regulates - # voltage through Bus.v_mag_pu_set, so apply it explicitly before solving. + # PyPSA regulates bus voltage through Bus.v_mag_pu_set; transfer the + # generator targets retained by the PowerIO CSV writer before solving. if "v_mag_pu_set" in network.generators: generators = network.generators regulated = generators.loc[ @@ -732,9 +734,9 @@ def _import_case_to_netcdf(case, output_path: str, overwrite_zero_s_nom: Optiona f"using {target} from the first generator" ) - # The 0.9 CSV writer emits generators in canonical source order. Restore - # transition costs that PyPSA supports but the writer does not yet emit. - source_generators = list(case.generators) + # Generator rows retain source order. Map supported transition costs + # into the PyPSA generator table. + source_generators = list(case.network.generators) if len(source_generators) == len(network.generators): network.generators["start_up_cost"] = [ float((generator.get("cost") or {}).get("startup", 0.0)) @@ -787,7 +789,7 @@ def _import_case_to_netcdf(case, output_path: str, overwrite_zero_s_nom: Optiona "transformers": len(network.transformers), "shunt_impedances": len(network.shunt_impedances), } - return info, warnings + return info, warnings, written @mcp.tool() @@ -798,14 +800,18 @@ def import_case_from_any( overwrite_zero_s_nom: Optional[float] = None, operating_point: Optional[int] = None, study_commit: Optional[int] = None, + time_index: Optional[int] = None, + scenario_id: Optional[str] = None, + edits: str = "", + to_balanced: bool = False, + base_mva: float = 100.0, ) -> Dict[str, Any]: """Import any powerio readable case file as a PyPSA network saved to a NetCDF file. - Reads any balanced PowerIO format or a ``.pio.json`` package and writes a - PyPSA network to output_path. If the package contains stored state data, - select exactly one operating_point or study_commit; PowerIO materializes it - first. + Reads any balanced PowerIO format or a ``.pio.json`` module and writes a + PyPSA network to output_path. Select a TimeSeries with time_index and a ScenarioSet with scenario_id. + The selected typed value is validated before conversion. PowerIO's native PyPSA writer preserves supported costs and element status. Args: @@ -815,8 +821,18 @@ def import_case_from_any( egret-json, psse, powerworld); inferred from the file extension when omitted overwrite_zero_s_nom: Replacement s_nom for branches with rating 0 - operating_point: Optional package operating-point index to materialize - study_commit: Optional package study-commit index to materialize + operating_point: Compatibility alias for time_index + study_commit: Retired package selector; export a Tellegen Study state as IR + time_index: Explicit TimeSeries index + scenario_id: Explicit ScenarioSet identifier + edits: JSON list of typed what-if edits PowerIO applies before the + conversion, in list order, for example + [{"op": "set_load_active_power", "load": "loads:0", "mw": 91.5}] + Consecutive updates of one class apply as one atomic batch, and a + bus load reallocation sees the values the edits before it produced. + to_balanced: Authorize the multiconductor to balanced transformation; + the response carries its readiness report as `lowering` + base_mva: System base for that transformation Returns: Dict with status, the saved network_file path, component counts, and @@ -836,11 +852,17 @@ def import_case_from_any( source_format=source_format, operating_point=operating_point, study_commit=study_commit, + time_index=time_index, + scenario_id=scenario_id, + edits=edits, + to_balanced=to_balanced, + base_mva=base_mva, ) - info, warnings = _import_case_to_netcdf( - prepared.network, output_path, overwrite_zero_s_nom + info, notes, written = _import_case_to_netcdf( + prepared, output_path, overwrite_zero_s_nom ) - warnings = list(prepared.warnings) + warnings + fields = prepared.response_fields(written) + fields["warnings"] = list(dict.fromkeys([*fields["warnings"], *notes])) except FileNotFoundError: return {"status": "error", "message": f"File not found: {file_path}"} except Exception as e: @@ -850,20 +872,25 @@ def import_case_from_any( "message": f"Network imported and saved to {output_path}", "network_file": output_path, "info": info, - "warnings": warnings, - **({"package": prepared.package} if prepared.package is not None else {}), + **fields, } @mcp.tool() def import_case_from_json( - network_json: str, - output_path: str, + network_json: str = "", + output_path: str = "", overwrite_zero_s_nom: Optional[float] = None, operating_point: Optional[int] = None, study_commit: Optional[int] = None, + time_index: Optional[int] = None, + scenario_id: Optional[str] = None, + powerio_ir: str = "", + edits: str = "", + to_balanced: bool = False, + base_mva: float = 100.0, ) -> Dict[str, Any]: - """Import PowerIO model JSON or one selected ``.pio.json`` package state + """Import PowerIO model JSON or one selected ``.pio.json`` module state as a PyPSA network saved to a NetCDF file. Accepts the `json` string returned by the powerio server's parse tool, @@ -880,13 +907,27 @@ def import_case_from_json( network_json: The JSON transport string from powerio output_path: Where to save the imported network (.nc) overwrite_zero_s_nom: Replacement s_nom for branches with rating 0 - operating_point: Optional package operating-point index to materialize - study_commit: Optional package study-commit index to materialize + operating_point: Compatibility alias for time_index + study_commit: Retired package selector; export a Tellegen Study state as IR + time_index: Explicit TimeSeries index + scenario_id: Explicit ScenarioSet identifier + powerio_ir: Serialized PowerIO IR from the powerio server (the + preferred spelling; network_json is its alias) + edits: JSON list of typed what-if edits PowerIO applies before the + conversion, in list order, for example + [{"op": "set_load_active_power", "load": "loads:0", "mw": 91.5}] + Consecutive updates of one class apply as one atomic batch, and a + bus load reallocation sees the values the edits before it produced. + to_balanced: Authorize the multiconductor to balanced transformation; + the response carries its readiness report as `lowering` + base_mva: System base for that transformation Returns: Dict with status, the saved network_file path, component counts, and warnings about dropped or adjusted data """ + if not output_path: + return {"status": "error", "message": "output_path is required"} try: output_path = checked_path(output_path, purpose="output_path", for_write=True) except PathNotAllowed as exc: @@ -896,11 +937,18 @@ def import_case_from_json( network_json=network_json, operating_point=operating_point, study_commit=study_commit, + time_index=time_index, + scenario_id=scenario_id, + powerio_ir=powerio_ir, + edits=edits, + to_balanced=to_balanced, + base_mva=base_mva, ) - info, warnings = _import_case_to_netcdf( - prepared.network, output_path, overwrite_zero_s_nom + info, notes, written = _import_case_to_netcdf( + prepared, output_path, overwrite_zero_s_nom ) - warnings = list(prepared.warnings) + warnings + fields = prepared.response_fields(written) + fields["warnings"] = list(dict.fromkeys([*fields["warnings"], *notes])) except Exception as e: return {"status": "error", "message": f"Failed to import case: {str(e)}"} return { @@ -908,8 +956,7 @@ def import_case_from_json( "message": f"Network imported and saved to {output_path}", "network_file": output_path, "info": info, - "warnings": warnings, - **({"package": prepared.package} if prepared.package is not None else {}), + **fields, } diff --git a/PyPSA/requirements.txt b/PyPSA/requirements.txt index 2ecaaea..d6207ad 100644 --- a/PyPSA/requirements.txt +++ b/PyPSA/requirements.txt @@ -3,4 +3,4 @@ pypsa>=0.35.2,<2 numpy>=1.24.0 pandas>=2.0.0 mcp>=2,<3 -powerio[mcp,matrix]>=0.9.0,<1 +powerio[mcp,matrix]>=0.11.3,<0.12 diff --git a/README.md b/README.md index 7c39649..325bc0b 100644 --- a/README.md +++ b/README.md @@ -127,72 +127,183 @@ These tools wrap commercial or locally-installed software, so PowerMCP stores th ### Case compilation between servers (PowerIO) -PowerMCP runs the MCP server that [powerio](https://github.com/eigenergy/powerio) ships in its own wheel, as a **core dependency** (no extra needed) — `powermcp run powerio` is `python -m powerio.mcp`, so a powerio release that adds tools or changes their implementation needs no local server copy. It parses transmission and distribution formats into canonical JSON transports, converts between target artifacts with fidelity warnings, and builds the sparse matrices solvers need (B', B'', Y_bus, PTDF, LODF, Laplacian, LACPF). - -Its JSON transport is the exchange format between PowerMCP servers: parse a case once, pass the returned `json` string between tool calls, and save runtime artifacts only when a backend needs a file. Existing `json` transport workflows remain supported. - -``` -parse(path="case9.raw") # powerio server -> {"json": ..., "summary": ...} -load_network_from_json(network_json=...) # pandapower server ingests the transport -load_model_from_json(network_json=...) # egret server stages it as a solvable case file -import_case_from_json(network_json=..., output_path="case9.nc") # PyPSA server writes a .nc for its tools -matrix(kind="ptdf", json=...) # powerio server builds matrices from it -save(to_format="psse", out_path="case9.raw", json=...) # stage a file for path only servers -``` - -PowerIO also supports the `.pio.json` package transport, which carries the model plus package metadata and structured diagnostics: - +PowerIO IR is the exchange format of this repository. PowerMCP runs the +server shipped in PowerIO 0.11.3 (`powermcp run powerio` is `python -m +powerio.mcp`); that server parses every grid exchange format, emits every +target, summarizes, diagnoses, normalizes, lowers multiconductor networks, and +calculates matrices. Its `parse` tool returns serialized **PowerIO IR +generation 2** (`"schema": "pio-ir"`, `"version": 2`), a typed module carrying +the electrical value, its provenance and its diagnostics. Every other server +here consumes that document: the pandapower, PyPSA, ANDES and Egret adapters +turn it into their own model with PowerIO's writers, and the Tellegen server +hands it to the native solver. PowerMCP itself never re-parses, re-validates, +or recomputes what PowerIO states; it routes a declared value to a consumer +that accepts it and owns only the final step into one simulator. + +```python +parsed = parse(path="case9.raw") # powerio server +ir = parsed["powerio_ir"] +summarize(powerio_ir=ir) +calc_matrix(matrix="ptdf", powerio_ir=ir) # rows and columns carry bus ids and branch identities +diagnostics(powerio_ir=ir) +import_case_from_json(powerio_ir=ir, output_path="case9.nc") # PyPSA +load_network_from_json(powerio_ir=ir) # pandapower +load_model_from_json(powerio_ir=ir) # Egret +load_network_from_json(powerio_ir=ir, out_path="case9.m") # ANDES +solve(powerio_ir=ir, formulation="dcopf") # tellegen +emit(format="psse", destination="case9.raw", powerio_ir=ir) ``` -parsed = parse(path="case9.raw", transport="package") -pkg = parsed["package_json"] -summary(package_json=pkg) -matrix(kind="ptdf", package_json=pkg) -save(to_format="psse", out_path="case9.raw", package_json=pkg) -diagnostics(package_json=pkg) # package diagnostics summary and structured findings -``` - -A package can also retain provenance and source maps, stable row identities, -validation state, operating-point series, cumulative study commits, and -lowering history. The canonical PowerIO MCP tools continue to own that package -lifecycle. PowerMCP uses the package only at the solver boundary: -``` -# A static package loads directly. -import_case_from_json(network_json=pkg, output_path="case9.nc") +`powerio_ir` is the argument every adapter takes; `network_json` remains an +alias for the same document. A `ScenarioSet` requires `scenario_id`; a +`TimeSeries` requires `time_index`; nested collections require both. An +operating point entry reaches the solver as the network it states. The +`operating_point` argument remains an alias for `time_index`. -# A package with one or more stored states requires an explicit selection. -# PowerIO v0.9 materializes and validates the selected state before PowerMCP -# creates the solver model. +```python import_case_from_json( - network_json=pkg, - output_path="dispatch.nc", - operating_point=3, + powerio_ir=ir, output_path="dispatch.nc", + scenario_id="high-demand", time_index=3, ) -load_network_from_json(network_json=pkg, study_commit=1) # pandapower ``` -The same `operating_point` and `study_commit` selectors are available on the -PowerIO import tools for pandapower, PyPSA, ANDES, and Egret. PowerMCP rejects -unselected stored state data instead of silently solving the package's base model. -Study materialization honors the package's `base_operating_point`. Balanced -solvers also reject multiconductor packages until the caller explicitly lowers -them with PowerIO, so a lossy distribution-to-transmission reduction is never -implicit. PyPSA and pandapower use PowerIO's native writers, preserving the -supported cost and in-service metadata without PowerMCP rebuilding PYPOWER -tables. - -`summary` returns the canonical nested shape used by PowerIO and PowerMCP: counts live under `elements` (`elements.buses`, `elements.branches`, `elements.generators`) and topology metadata lives under `topology` (`topology.connected_components`, `topology.reference_buses`). - -`save` covers the servers without a bridge: write the converted case to disk and point their load tools at the file. For OpenDSS, save a distribution transport as DSS, then compile that DSS file: - +Every adapter response carries the same tail: `value_type` (the PowerIO +structural type that was selected), `selection`, `diagnostics` (full PowerIO +records with code, severity, target, spans and suggested action) and +`warnings`. The powerio adapters add the emission `fidelity` +(`exact_same_format` when PowerIO echoed retained source bytes, `canonical` for +fresh output) and the typed `edits` report described below, because they own +the conversion into their own model. The `package` key keeps the IR context +earlier clients read. + +The Tellegen tools carry the same four keys for the module they hand to the +native solver, and `solve_module` and `plan` add what the returned module +states. They take no typed `edits` list: Tellegen's own `edits` argument is the +native request object (`{"deltas": ..., "rates": ...}`) the CLI applies inside +the solve. + +#### Typed edits before a solver import + +The powerio adapters accept `edits`, a JSON list of what-if changes PowerIO +applies as typed updates before the conversion. The whole list is validated +first, then applied in list order; consecutive updates of one class apply as +one atomic batch, and a bus load reallocation sees the values produced by the +edits before it. The response reports the changed components, in application +order, under `edits`. + +| op | keys | +|---|---| +| `set_load_active_power` | `load`, `mw`, `terminal?` | +| `set_load_reactive_power` | `load`, `mvar`, `terminal?` | +| `set_generator_active_power` | `generator`, `mw`, `terminal?` | +| `set_generator_reactive_power` | `generator`, `mvar`, `terminal?` | +| `set_generator_voltage_magnitude` | `generator`, `vm_pu` | +| `set_generator_in_service` | `generator`, `in_service` | +| `set_branch_in_service` | `branch`, `in_service` | +| `set_transformer_tap_ratio` | `transformer`, `tap_ratio` | +| `set_transformer_phase_shift` | `transformer`, `shift_degrees` | +| `set_switch_closed` | `switch`, `closed` | +| `set_branch_thermal_rating` | `branch`, `mva`, `terminal?` | +| `set_bus_load_active_power` | `bus`, `mw`, `allocation` (`proportional_to_current_active_power` or `equal`) | + +Component ids are the stable identities PowerIO reports (`loads:0`, +`branches:3`, or the source uid). A bus demand edit names an allocation rule +because PowerIO never assigns aggregate demand to an arbitrary load. + +```python +load_network_from_json( + powerio_ir=ir, + edits='[{"op": "set_load_active_power", "load": "loads:0", "mw": 91.5},' + ' {"op": "set_branch_in_service", "branch": "branches:3", "in_service": false}]', +) ``` -save(to_format="dss", out_path="feeder.dss", json=..., json_format="bmopf-json") -compile_opendss_file(dss_file="feeder.dss") + +#### Distribution networks + +A multiconductor value is rejected by a balanced solver until the caller asks +for the transformation: pass `to_balanced=True` (and `base_mva`) and the +response carries PowerIO's readiness report under `lowering`, or call the +powerio server's `to_balanced_report` and `to_balanced` tools first. Retaining +a component does not establish that the selected solver models it. Use `emit` +for a backend that needs files: OpenDSS output is a directory bundle whose +master DSS artifact `compile_opendss_file` accepts; `bmopf-json@0.1.0` and +`bmopf-json@0.2.0` select the BMOPF schema version (the latter writes draft +BMOPF 0.2, subject to Task Force approval); `geo-json` writes a geographic +layer. + +The retired `Package`, `model-json`, `package_json` and package `study_commit` +formats require migration: re-parse the original case and pass its +`powerio_ir`. Study goals, branching and decisions belong to Tellegen. + +PowerIO MCP paths support local files and `file://` URIs. Set +`POWERIO_MCP_ALLOWED_ROOTS` to an `os.pathsep` separated directory list to +constrain the shared path policy. With no root variable set, paths stay beneath +the directory the server process started in, which is rarely the directory an +operator means. Legacy single-root environment aliases remain supported. +Directory inputs check every descendant, and generated directories install from +private sibling staging paths. Place `POWERMCP_HOME` under an allowed root for +solver run artifacts. These checks cannot prevent another process from +replacing a path after validation. + +### Tellegen (native solver and Studies) + +[Tellegen](https://github.com/eigenergy/tellegen) solves DC power flow, DC OPF +with prices and dispatch, AC power flow and the SOCWR relaxation, computes +sensitivities, runs bounded capacity planning, and keeps durable Studies. It +consumes and produces PowerIO IR, so `powermcp run tellegen` is the third +consumer of the same format: the powerio server parses, Tellegen solves, and +the solution comes back as a `powerio.DcOpfSolution` module every other tool +can read. + +Build the CLI and point PowerMCP at it: + +```sh +cargo build -p tellegen-cli --features conic # in a tellegen checkout +powermcp config set tellegen.binary /path/to/target/debug/tellegen +# or: export POWERMCP_TELLEGEN_BINARY=/path/to/tellegen, or put tellegen on PATH +powermcp doctor # runs `tellegen capabilities` ``` -PowerWorld `.pwd` display files decode separately via `display(path=...)`, which returns the diagram canvas and each substation's display coordinates. The display geometry is distinct from the `.pwb`/`.aux` case data. +Tools: `capabilities`, `contract`, `solve(powerio_ir | path, formulation, +edits, sensitivities, max_elements)`, `solve_module(..., out_path)`, +`plan(spec, ...)`, and the Study family `study_contract`, `study_create` +(`input_path` lets PowerIO parse a grid exchange file into the Study input), +`study_inspect`, `study_run`, `study_export`, `study_import`. A grid exchange +`path` is parsed by PowerIO in the server process and serialized to IR before +it reaches the binary; collection entries take `time_index` and `scenario_id`. +Tellegen takes a balanced network or a calculation instance and lowers nothing: +a multiconductor value is refused here, before the binary runs, so lower it +with the powerio server's `to_balanced` first. A module PowerIO marks with an +error is refused on the same terms as every other adapter refuses it. +Applying a Study proposal binds a recommendation to the Study and is a human +action: it is not a tool, and `study_run` refuses the `apply` operation. + +The adapter accepts `POWERMCP_TELLEGEN_TIMEOUT_SECONDS` (default 1800) and +`POWERMCP_TELLEGEN_CANCEL_GRACE_SECONDS` (default 300). Equivalent keys live +under `[tellegen]` in the configuration file. Cancellation requests SIGTERM on +POSIX and CTRL_BREAK on a Windows process group, allowing the current exact +trial to finish and completed evidence to be saved. After the grace period, or +without a usable Windows console, a forced stop can retain only the previous +saved revision. Inspect the Study before retrying. See +[powermcp/TELLEGEN.md](powermcp/TELLEGEN.md). + +### Tool results + +Every tool in this repository reports the same shape, so one client handles all +of them: + +```json +{"status": "success", "...": "the keys that tool documents"} +{"status": "error", "message": "what went wrong"} +``` -PowerIO MCP tools accept local paths and `file://` URIs. Nonlocal URI schemes are rejected. Set `POWERIO_MCP_ALLOWED_ROOTS` to an `os.pathsep` separated list of directories to constrain paths handled by the shared PowerIO sandbox. PyPSA preflights a NetCDF file or every descendant of a CSV directory before constructing a network, and both explicit and legacy-derived CSV import destinations are checked before writing. PyPSA and surge install directory outputs from a private sibling staging directory. Generated run directories exposed by the bundled servers use the same path policy. Put `POWERMCP_HOME` under an allowed root if ANDES, Egret, or LTSpice should write run artifacts while containment is enabled. These are path preflight checks; another process can replace a checked entry before a backend opens it. +A failure reaches the caller as a result, not as an MCP protocol error: a +refused path, a rejected argument and a failure inside the simulator all arrive +as `"status": "error"` with a message. `powermcp/errors.py` holds the helpers +every server uses, `tool_success`, `tool_error` and `run_tool`. A tool that +documents a key on both branches carries it on both. A few tools return +something other than a JSON object on success, such as raw CSV text; they still +report a failure through the shape above. ### Running from a clone (without installing) diff --git a/pandapower/README.md b/pandapower/README.md index 83e25bc..70afe6c 100644 --- a/pandapower/README.md +++ b/pandapower/README.md @@ -35,8 +35,8 @@ Configure in your MCP client (e.g., Cursor, Claude Desktop): - **create_empty_network()**: Create an empty pandapower network. - **load_network(file_path: str)**: Load a network from a `.json` file. -- **load_network_from_any(...)**: Load any PowerIO-readable case or one selected `.pio.json` package state. -- **load_network_from_json(...)**: Load PowerIO model JSON or one selected `.pio.json` package state without staging a file. +- **load_network_from_any(...)**: Load any PowerIO-readable case, or one selected entry of a PowerIO IR collection. +- **load_network_from_json(...)**: Load serialized PowerIO IR, or one selected entry of it, without staging a file. - **export_network_to_format(to_format: str)**: Export the active network through PowerIO's native writer. - **run_power_flow(algorithm, calculate_voltage_angles, max_iteration, tolerance_mva)**: Run power flow analysis (Newton-Raphson or Backward/Forward Sweep). - **run_contingency_analysis(contingency_type, elements)**: Run N-1 or N-2 contingency analysis on lines and transformers. diff --git a/pandapower/panda_mcp.py b/pandapower/panda_mcp.py index fbbf889..8b2a1d7 100644 --- a/pandapower/panda_mcp.py +++ b/pandapower/panda_mcp.py @@ -11,450 +11,482 @@ if _repo_root_added: sys.path.insert(0, _repo_root) try: - from powermcp.solver_case import resolve_solver_case + from powermcp.solver_case import resolve_solver_case, diagnostic_messages from powermcp.sandbox import PathNotAllowed, checked_path finally: if _repo_root_added: sys.path.remove(_repo_root) del _repo_root, _repo_root_added - - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Initialize MCP server with logging -logger.info("Initializing Pandapower Analysis Server") -mcp = FastMCP("Pandapower Analysis Server") - -# Global variable to store the current network -_current_net = None - -def _get_network() -> pp.pandapowerNet: - """Get the current pandapower network instance. - - Returns: - pp.pandapowerNet: The current network or raises error if none loaded - """ - global _current_net - - if _current_net is None: - raise RuntimeError("No pandapower network is currently loaded. Please create or load a network first.") - return _current_net - - -@mcp.tool() -def create_empty_network() -> Dict[str, Any]: - """Create an empty pandapower network. - - Returns: - Dict containing status and network information - """ - logger.info("Creating an empty pandapower network") - global _current_net - try: - _current_net = pp.create_empty_network() - return { - "status": "success", - "message": "Empty network created successfully", - "network_info": { - "buses": len(_current_net.bus), - "lines": len(_current_net.line), - "trafos": len(_current_net.trafo) - } - } - except Exception as e: - return { - "status": "error", - "message": f"Failed to create empty network: {str(e)}" - } - -@mcp.tool() + + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize MCP server with logging +logger.info("Initializing Pandapower Analysis Server") +mcp = FastMCP("Pandapower Analysis Server") + +# Global variable to store the current network +_current_net = None + +def _get_network() -> pp.pandapowerNet: + """Get the current pandapower network instance. + + Returns: + pp.pandapowerNet: The current network or raises error if none loaded + """ + global _current_net + + if _current_net is None: + raise RuntimeError("No pandapower network is currently loaded. Please create or load a network first.") + return _current_net + + +@mcp.tool() +def create_empty_network() -> Dict[str, Any]: + """Create an empty pandapower network. + + Returns: + Dict containing status and network information + """ + logger.info("Creating an empty pandapower network") + global _current_net + try: + _current_net = pp.create_empty_network() + return { + "status": "success", + "message": "Empty network created successfully", + "network_info": { + "buses": len(_current_net.bus), + "lines": len(_current_net.line), + "trafos": len(_current_net.trafo) + } + } + except Exception as e: + return { + "status": "error", + "message": f"Failed to create empty network: {str(e)}" + } + +@mcp.tool() def load_network(file_path: str) -> Dict[str, Any]: """Load a pandapower network from a JSON file. - - Args: + + Args: file_path: Path to the network file (.json) - - Returns: - Dict containing status and network information - """ + + Returns: + Dict containing status and network information + """ try: file_path = checked_path(file_path, purpose="file_path") except PathNotAllowed as exc: return {"status": "error", "message": str(exc)} logger.info(f"Loading network from file: {file_path}") - global _current_net - try: - if file_path.endswith('.json'): - _current_net = pp.from_json(file_path) + global _current_net + try: + if file_path.endswith('.json'): + _current_net = pp.from_json(file_path) else: raise ValueError("Unsupported file format. Use a .json file.") - - return { - "status": "success", - "message": f"Network loaded successfully from {file_path}", - "network_info": { - "buses": len(_current_net.bus), - "lines": len(_current_net.line), - "trafos": len(_current_net.trafo) - } - } - except FileNotFoundError: - return { - "status": "error", - "message": f"File not found: {file_path}" - } - except ValueError as ve: - return { - "status": "error", - "message": str(ve) - } - except Exception as e: - return { - "status": "error", - "message": f"Failed to load network: {str(e)}" - } - -@mcp.tool() -def run_power_flow(algorithm: str = 'nr', calculate_voltage_angles: bool = True, - max_iteration: int = 10, tolerance_mva: float = 1e-8) -> Dict[str, Any]: - """Run power flow analysis on the current network. - - Args: - algorithm: Power flow algorithm ('nr' for Newton-Raphson, 'bfsw' for backward/forward sweep) - calculate_voltage_angles: Consider voltage angles in calculation - max_iteration: Maximum number of iterations - tolerance_mva: Convergence tolerance in MVA - - Returns: - Dict containing power flow results - """ - logger.info("Running power flow analysis") - try: - net = _get_network() - pp.runpp(net, algorithm=algorithm, calculate_voltage_angles=calculate_voltage_angles, - max_iteration=max_iteration, tolerance_mva=tolerance_mva) - - # Extract key results - results = { - "bus_results": net.res_bus.to_dict(), - "line_results": net.res_line.to_dict(), - "trafo_results": net.res_trafo.to_dict(), - "converged": net.converged - } - - return { - "status": "success", - "message": "Power flow calculation completed successfully" if net.converged else "Power flow did not converge", - "results": results - } - except RuntimeError as re: - return { - "status": "error", - "message": str(re) - } - except Exception as e: - return { - "status": "error", - "message": f"Power flow calculation failed: {str(e)}" - } - -@mcp.tool() -def run_contingency_analysis(contingency_type: str = "N-1", - elements: Optional[List[str]] = None) -> Dict[str, Any]: - """Run contingency analysis on the current network. - - Args: - contingency_type: Type of contingency analysis ("N-1" or "N-2") - elements: List of specific elements to analyze (optional) - - Returns: - Dict containing contingency analysis results - """ - logger.info("Running contingency analysis") - try: - net = _get_network() - - # Store original state - orig_net = net.deepcopy() - results = [] - - # Define elements to analyze - if elements is None: - elements = ['line', 'trafo'] - - # Perform contingency analysis - for element_type in elements: - for idx in net[element_type].index: - # Create contingency by taking element out of service - contingency_net = orig_net.deepcopy() - contingency_net[element_type].at[idx, 'in_service'] = False - - try: - pp.runpp(contingency_net) - - # Check for violations - violations = { - 'voltage_violations': contingency_net.res_bus[ - (contingency_net.res_bus.vm_pu < 0.95) | - (contingency_net.res_bus.vm_pu > 1.05) - ].index.tolist(), - 'loading_violations': contingency_net.res_line[ - contingency_net.res_line.loading_percent > 100 - ].index.tolist() - } - - results.append({ - 'contingency': f"{element_type}_{idx}", - 'converged': contingency_net.converged, - 'violations': violations - }) - - except Exception as e: - results.append({ - 'contingency': f"{element_type}_{idx}", - 'converged': False, - 'error': str(e) - }) - - return { - "status": "success", - "message": "Contingency analysis completed", - "results": results - } - except RuntimeError as re: - return { - "status": "error", - "message": str(re) - } - except Exception as e: - return { - "status": "error", - "message": f"Contingency analysis failed: {str(e)}" - } - -@mcp.tool() -def get_network_info() -> Dict[str, Any]: - """Get information about the current network. - - Returns: - Dict containing network statistics and information - """ - logger.info("Retrieving network information") - try: - net = _get_network() - info = { - "buses": len(net.bus), - "lines": len(net.line), - "trafos": len(net.trafo), - "generators": len(net.gen), - "loads": len(net.load), - "switches": len(net.switch), - "bus_data": net.bus.to_dict(), - "line_data": net.line.to_dict(), - "trafo_data": net.trafo.to_dict() - } - - return { - "status": "success", - "message": "Network information retrieved successfully", - "info": info - } - except RuntimeError as re: - return { - "status": "error", - "message": str(re) - } - except Exception as e: - return { - "status": "error", - "message": f"Failed to get network information: {str(e)}" - } - -# --------------------------------------------------------------------------- + + return { + "status": "success", + "message": f"Network loaded successfully from {file_path}", + "network_info": { + "buses": len(_current_net.bus), + "lines": len(_current_net.line), + "trafos": len(_current_net.trafo) + } + } + except FileNotFoundError: + return { + "status": "error", + "message": f"File not found: {file_path}" + } + except ValueError as ve: + return { + "status": "error", + "message": str(ve) + } + except Exception as e: + return { + "status": "error", + "message": f"Failed to load network: {str(e)}" + } + +@mcp.tool() +def run_power_flow(algorithm: str = 'nr', calculate_voltage_angles: bool = True, + max_iteration: int = 10, tolerance_mva: float = 1e-8) -> Dict[str, Any]: + """Run power flow analysis on the current network. + + Args: + algorithm: Power flow algorithm ('nr' for Newton-Raphson, 'bfsw' for backward/forward sweep) + calculate_voltage_angles: Consider voltage angles in calculation + max_iteration: Maximum number of iterations + tolerance_mva: Convergence tolerance in MVA + + Returns: + Dict containing power flow results + """ + logger.info("Running power flow analysis") + try: + net = _get_network() + pp.runpp(net, algorithm=algorithm, calculate_voltage_angles=calculate_voltage_angles, + max_iteration=max_iteration, tolerance_mva=tolerance_mva) + + # Extract key results + results = { + "bus_results": net.res_bus.to_dict(), + "line_results": net.res_line.to_dict(), + "trafo_results": net.res_trafo.to_dict(), + "converged": net.converged + } + + return { + "status": "success", + "message": "Power flow calculation completed successfully" if net.converged else "Power flow did not converge", + "results": results + } + except RuntimeError as re: + return { + "status": "error", + "message": str(re) + } + except Exception as e: + return { + "status": "error", + "message": f"Power flow calculation failed: {str(e)}" + } + +@mcp.tool() +def run_contingency_analysis(contingency_type: str = "N-1", + elements: Optional[List[str]] = None) -> Dict[str, Any]: + """Run contingency analysis on the current network. + + Args: + contingency_type: Type of contingency analysis ("N-1" or "N-2") + elements: List of specific elements to analyze (optional) + + Returns: + Dict containing contingency analysis results + """ + logger.info("Running contingency analysis") + try: + net = _get_network() + + # Store original state + orig_net = net.deepcopy() + results = [] + + # Define elements to analyze + if elements is None: + elements = ['line', 'trafo'] + + # Perform contingency analysis + for element_type in elements: + for idx in net[element_type].index: + # Create contingency by taking element out of service + contingency_net = orig_net.deepcopy() + contingency_net[element_type].at[idx, 'in_service'] = False + + try: + pp.runpp(contingency_net) + + # Check for violations + violations = { + 'voltage_violations': contingency_net.res_bus[ + (contingency_net.res_bus.vm_pu < 0.95) | + (contingency_net.res_bus.vm_pu > 1.05) + ].index.tolist(), + 'loading_violations': contingency_net.res_line[ + contingency_net.res_line.loading_percent > 100 + ].index.tolist() + } + + results.append({ + 'contingency': f"{element_type}_{idx}", + 'converged': contingency_net.converged, + 'violations': violations + }) + + except Exception as e: + results.append({ + 'contingency': f"{element_type}_{idx}", + 'converged': False, + 'error': str(e) + }) + + return { + "status": "success", + "message": "Contingency analysis completed", + "results": results + } + except RuntimeError as re: + return { + "status": "error", + "message": str(re) + } + except Exception as e: + return { + "status": "error", + "message": f"Contingency analysis failed: {str(e)}" + } + +@mcp.tool() +def get_network_info() -> Dict[str, Any]: + """Get information about the current network. + + Returns: + Dict containing network statistics and information + """ + logger.info("Retrieving network information") + try: + net = _get_network() + info = { + "buses": len(net.bus), + "lines": len(net.line), + "trafos": len(net.trafo), + "generators": len(net.gen), + "loads": len(net.load), + "switches": len(net.switch), + "bus_data": net.bus.to_dict(), + "line_data": net.line.to_dict(), + "trafo_data": net.trafo.to_dict() + } + + return { + "status": "success", + "message": "Network information retrieved successfully", + "info": info + } + except RuntimeError as re: + return { + "status": "error", + "message": str(re) + } + except Exception as e: + return { + "status": "error", + "message": f"Failed to get network information: {str(e)}" + } + +# --------------------------------------------------------------------------- # PowerIO interchange: resolve one balanced state and use PowerIO's native # pandapower writer. Export still round-trips pandapower's PYPOWER tables # through PowerIO because pandapower has no corresponding native writer. -# --------------------------------------------------------------------------- - -_POWERIO_HINT = "powerio not installed: pip install 'powerio[mcp,matrix]'" - +# --------------------------------------------------------------------------- + +_POWERIO_HINT = "powerio not installed: pip install 'powerio[mcp,matrix]'" + def _powerio_to_net(case): - """Use PowerIO's native writer to create the pandapower network.""" - conversion = case.to_format("pandapower-json") - return pp.from_json_string(conversion.text), list(conversion.warnings) - - -def _ppc_to_matpower_text(ppc) -> str: - """Serialize PYPOWER input tables as MATPOWER .m text for powerio to parse. - Columns beyond the MATPOWER input widths (result columns) are dropped.""" - width = {"bus": 13, "gen": 21, "branch": 13} - out = [ - "function mpc = ppc_export", - "mpc.version = '2';", - f"mpc.baseMVA = {float(ppc['baseMVA'])!r};", - ] - for name in ("bus", "gen", "branch", "gencost"): - table = ppc.get(name) - if table is None or len(table) == 0: - continue - w = width.get(name) - rows = "\n".join( - "\t" + "\t".join(repr(float(v)) for v in (row[:w] if w else row)) + ";" - for row in table - ) - out.append(f"mpc.{name} = [\n{rows}\n];") - return "\n".join(out) + "\n" - - -def _network_info_response( - message: str, - *, - warnings: Optional[List[str]] = None, - package: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: + """Use PowerIO's native writer to create the pandapower network. + + Returns the network and the shared response fields: value type, selection, + diagnostics, warnings, emission fidelity, edits, and lowering report. + """ + conversion = case.emit("pandapower-json") + return pp.from_json_string(conversion.text), case.response_fields(conversion) + + +def _network_info_response(message: str, **fields: Any) -> Dict[str, Any]: + """A success response carrying the loaded network's counts and every field. + + ``fields`` is the shared tail ``SolverCase.response_fields`` builds, so + every key it states, including an empty ``warnings`` or ``diagnostics`` + list, reaches the caller unchanged. + """ response = { "status": "success", - "message": message, - "network_info": { - "buses": len(_current_net.bus), - "lines": len(_current_net.line), - "trafos": len(_current_net.trafo), + "message": message, + "network_info": { + "buses": len(_current_net.bus), + "lines": len(_current_net.line), + "trafos": len(_current_net.trafo), }, } - if warnings: - response["warnings"] = warnings - if package is not None: - response["package"] = package + response.update(fields) return response - - -@mcp.tool() + + +@mcp.tool() def load_network_from_any( file_path: str, source_format: Optional[str] = None, operating_point: Optional[int] = None, study_commit: Optional[int] = None, + time_index: Optional[int] = None, + scenario_id: Optional[str] = None, + edits: str = "", + to_balanced: bool = False, + base_mva: float = 100.0, ) -> Dict[str, Any]: - """Load a network from any powerio readable case file. - - Reads any balanced PowerIO format or a ``.pio.json`` package and replaces - the current network. If the package contains stored state data, select - exactly one operating_point or study_commit; PowerIO materializes it before - conversion. - - Args: - file_path: Path to the case file + """Load a network from any powerio readable case file. + + Reads any balanced PowerIO format or a ``.pio.json`` module and replaces + the current network. Select a TimeSeries with time_index and a ScenarioSet with scenario_id. + The selected typed value is validated before conversion. + + Args: + file_path: Path to the case file source_format: Input format name (matpower, powermodels-json, egret-json, psse, powerworld); inferred from the file extension when omitted - operating_point: Optional package operating-point index to materialize - study_commit: Optional package study-commit index to materialize - - Returns: - Dict containing status and network information - """ + operating_point: Compatibility alias for time_index + study_commit: Retired package selector; export a Tellegen Study state as IR + time_index: Explicit TimeSeries index + scenario_id: Explicit ScenarioSet identifier + edits: JSON list of typed what-if edits PowerIO applies before the + conversion, in list order, for example + [{"op": "set_load_active_power", "load": "loads:0", "mw": 91.5}] + Consecutive updates of one class apply as one atomic batch, and a + bus load reallocation sees the values the edits before it produced. + to_balanced: Authorize the multiconductor to balanced transformation; + the response carries its readiness report as `lowering` + base_mva: System base for that transformation + + Returns: + Dict containing status and network information + """ try: file_path = checked_path(file_path, purpose="file_path") except PathNotAllowed as exc: return {"status": "error", "message": str(exc)} logger.info(f"Loading network via powerio from: {file_path}") - global _current_net + global _current_net try: prepared = resolve_solver_case( file_path=file_path, source_format=source_format, operating_point=operating_point, study_commit=study_commit, + time_index=time_index, + scenario_id=scenario_id, + edits=edits, + to_balanced=to_balanced, + base_mva=base_mva, ) - _current_net, conversion_warnings = _powerio_to_net(prepared.network) - except FileNotFoundError: - return {"status": "error", "message": f"File not found: {file_path}"} - except Exception as e: - return {"status": "error", "message": f"Failed to load network: {str(e)}"} + _current_net, fields = _powerio_to_net(prepared) + except FileNotFoundError: + return {"status": "error", "message": f"File not found: {file_path}"} + except Exception as e: + return {"status": "error", "message": f"Failed to load network: {str(e)}"} return _network_info_response( f"Network loaded successfully from {file_path}", - warnings=list(prepared.warnings) + conversion_warnings, - package=prepared.package, + **fields, ) - - -@mcp.tool() + + +@mcp.tool() def load_network_from_json( - network_json: str, + network_json: str = "", operating_point: Optional[int] = None, study_commit: Optional[int] = None, + time_index: Optional[int] = None, + scenario_id: Optional[str] = None, + powerio_ir: str = "", + edits: str = "", + to_balanced: bool = False, + base_mva: float = 100.0, ) -> Dict[str, Any]: - """Load PowerIO model JSON or one selected ``.pio.json`` package state. - + """Load PowerIO model JSON or one selected ``.pio.json`` module state. + Accepts the `json` string returned by the powerio server's parse tool, so a case parsed once there loads here without passing a file around or re-parsing it. Expects source-valued tables (MW, degrees) as parse emits them, not the per-unit normalize form. Replaces - the currently loaded network. powerio is a core dependency, so this is - always available. - - Args: + the currently loaded network. powerio is a core dependency, so this is + always available. + + Args: network_json: The JSON transport string from powerio - operating_point: Optional package operating-point index to materialize - study_commit: Optional package study-commit index to materialize - - Returns: - Dict containing status and network information - """ - logger.info("Loading network from powerio JSON transport") - global _current_net + operating_point: Compatibility alias for time_index + study_commit: Retired package selector; export a Tellegen Study state as IR + time_index: Explicit TimeSeries index + scenario_id: Explicit ScenarioSet identifier + powerio_ir: Serialized PowerIO IR from the powerio server (the + preferred spelling; network_json is its alias) + edits: JSON list of typed what-if edits PowerIO applies before the + conversion, in list order, for example + [{"op": "set_load_active_power", "load": "loads:0", "mw": 91.5}] + Consecutive updates of one class apply as one atomic batch, and a + bus load reallocation sees the values the edits before it produced. + to_balanced: Authorize the multiconductor to balanced transformation; + the response carries its readiness report as `lowering` + base_mva: System base for that transformation + + Returns: + Dict containing status and network information + """ + logger.info("Loading network from powerio JSON transport") + global _current_net try: prepared = resolve_solver_case( network_json=network_json, operating_point=operating_point, study_commit=study_commit, + time_index=time_index, + scenario_id=scenario_id, + powerio_ir=powerio_ir, + edits=edits, + to_balanced=to_balanced, + base_mva=base_mva, ) - _current_net, conversion_warnings = _powerio_to_net(prepared.network) - except Exception as e: - return {"status": "error", "message": f"Failed to load network: {str(e)}"} + _current_net, fields = _powerio_to_net(prepared) + except Exception as e: + return {"status": "error", "message": f"Failed to load network: {str(e)}"} return _network_info_response( - "Network loaded successfully from JSON transport", - warnings=list(prepared.warnings) + conversion_warnings, - package=prepared.package, + "Network loaded successfully from PowerIO IR", + **fields, ) - - -@mcp.tool() -def export_network_to_format(to_format: str) -> Dict[str, Any]: - """Export the current network to a power system case format via powerio. - - Converts the loaded network to MATPOWER tables and serializes them with - powerio. to_format is a powerio format name: matpower (m), - powermodels-json (pm), egret-json (egret), psse (raw), powerworld (aux). - powerio is a core dependency, so this is always available. - - Args: - to_format: Target format name - - Returns: - Dict with status, the exported case `text`, and fidelity `warnings` - listing anything the target format could not represent - """ - logger.info(f"Exporting network via powerio to format: {to_format}") - try: - import powerio - except ImportError: - return {"status": "error", "message": _POWERIO_HINT} - try: - net = _get_network() - from pandapower.converter.pypower.to_ppc import to_ppc - - ppc = to_ppc(net, init="flat") - case = powerio.parse_str(_ppc_to_matpower_text(ppc), "matpower") - conv = case.to_format(to_format) - except RuntimeError as re: - return {"status": "error", "message": str(re)} - except Exception as e: - return {"status": "error", "message": f"Failed to export network: {str(e)}"} - return {"status": "success", "text": conv.text, "warnings": list(conv.warnings)} - - + + +@mcp.tool() +def export_network_to_format(to_format: str) -> Dict[str, Any]: + """Export the current network to a power system case format via powerio. + + Converts the loaded network to MATPOWER tables and serializes them with + powerio. to_format is a powerio format name: matpower (m), + powermodels-json (pm), egret-json (egret), psse (raw), powerworld (aux). + powerio is a core dependency, so this is always available. + + Args: + to_format: Target format name + + Returns: + Dict with status, the exported case `text`, and fidelity `warnings` + listing anything the target format could not represent + """ + logger.info(f"Exporting network via powerio to format: {to_format}") + try: + import powerio + except ImportError: + return {"status": "error", "message": _POWERIO_HINT} + try: + net = _get_network() + from pandapower.converter.pypower.to_ppc import to_ppc + + ppc = to_ppc(net, init="flat") + module = powerio.PioModule.from_value(powerio.from_ppc(ppc)) + conv = powerio.emit(module, to_format) + except RuntimeError as re: + return {"status": "error", "message": str(re)} + except Exception as e: + return {"status": "error", "message": f"Failed to export network: {str(e)}"} + if conv.text is None: + return { + "status": "error", + "message": f"{to_format} is a directory format; use the powerio server's emit tool with a destination", + } + return { + "status": "success", + "text": conv.text, + "fidelity": conv.fidelity, + "diagnostics": powerio.diagnostic_records(conv.diagnostics), + "warnings": list(diagnostic_messages(conv.diagnostics)), + } + + if __name__ == "__main__": mcp.run(transport="stdio") diff --git a/pandapower/requirements.txt b/pandapower/requirements.txt index 5f49d6a..a2c2267 100644 --- a/pandapower/requirements.txt +++ b/pandapower/requirements.txt @@ -1,3 +1,3 @@ pandapower mcp>=2,<3 -powerio[mcp,matrix]>=0.9.0,<1 +powerio[mcp,matrix]>=0.11.3,<0.12 diff --git a/powermcp/README.md b/powermcp/README.md index fa0863e..18680f8 100644 --- a/powermcp/README.md +++ b/powermcp/README.md @@ -1,9 +1,9 @@ -# `powermcp` — package & CLI +# `powermcp`: package and CLI This folder is the **PowerMCP core package**: the CLI installer (`powermcp install`), the launcher (`powermcp run`), the central config (`~/.powermcp/config.toml`), the tool registry, and the MCP client-config writers. The actual MCP servers live in the -top-level tool dirs (`PSSE/`, `pandapower/`, …) and are shipped into the wheel under +top-level tool dirs (`PSSE/`, `pandapower/`, ...) and are shipped into the wheel under `powermcp/_servers/` at build time. This guide shows how to **install** and **test** the packaged version. @@ -19,9 +19,9 @@ pip install powermcp ``` The base install includes **pandapower**, **PyPSA**, and the canonical **PowerIO** -conversion server. PowerIO `.pio.json` packages can be passed directly to the -solver import tools, with explicit operating-point or study-commit selection -when a package contains stored state data. Everything else is opt-in via an extra: +conversion server. PowerIO IR modules can be passed directly to the solver +import tools, with explicit `time_index` or `scenario_id` selection when a +module carries a collection. Everything else is opt-in via an extra: ```bash pip install "powermcp[psse]" # one tool @@ -41,7 +41,7 @@ pip install "powermcp[all]" # everything | `andes` | ANDES | GPL-3.0; installed only as an optional pip extra, never vendored | | `egret` | Egret | + needs an external solver (ipopt/Gurobi) | | `opendss` | OpenDSS | | -| `surge` | surge | **Python 3.12–3.14 only** | +| `surge` | surge | **Python 3.12 to 3.14 only** | | `hope` | HOPE | + needs Julia at runtime | | `genx` | GenX | + needs a GenX.jl checkout; case submission needs SLURM `sbatch` | | `ltspice` | LTSpice | executable **auto-detected** (override with `ltspice.exe`) | @@ -66,7 +66,7 @@ The interactive wizard: 4. writes the MCP client config for **Claude Desktop**, **Claude Code**, and the **Codex CLI**. > In the interactive picker you must press **SPACE to toggle each tool**, then ENTER to -> confirm — pressing ENTER alone keeps only the preselected core tools. If your terminal +> confirm. Pressing ENTER alone keeps only the preselected core tools. If your terminal > doesn't render the checkbox well, use `--tools`/`--all` below instead. **Choose tools non-interactively** (recommended when scripting or if the picker misbehaves): @@ -85,7 +85,7 @@ or configured (and any already present in the targeted client config), so confir **preserves and updates** your existing setup instead of resetting to core. Paths such as LTSpice's are auto-detected and pre-filled, so you can usually just press Enter. -Useful flags: `--dry-run` (preview, write nothing — not even `config.toml`), +Useful flags: `--dry-run` (preview, write nothing, not even `config.toml`), `--yes` (non-interactive core only), `--tools ` / `--all` (pick tools without the picker), `--clients claude-desktop,codex` (choose which clients; `none` to skip). @@ -114,17 +114,17 @@ powermcp config set powerfactory.python_path "...\DIgSILENT\PowerFactory 2024\Py powermcp config set hope.repo_root "C:\src\HOPE" ``` -Resolution order for each key is **environment variable** (`POWERMCP_PSSE_BIN`, …) → -**config.toml** → **legacy default** → a clear "run `powermcp install`" error. +Resolution order for each key is **environment variable** (`POWERMCP_PSSE_BIN`, ...), +then **config.toml**, then **legacy default**, then a clear "run `powermcp install`" error. > **LTSpice is auto-detected** in standard install locations (modern ADI, legacy LTC, Wine), -> so `ltspice.exe` usually doesn't need to be set at all — the server resolver falls back to -> detection (env/config → auto-detect → legacy), and the wizard pre-fills the detected path. +> so `ltspice.exe` usually doesn't need to be set at all; the server resolver falls back to +> detection (env or config, then auto-detect, then legacy), and the wizard pre-fills the detected path. > Set it only for a non-standard install. ### Use PowerMCP in Claude Desktop -Claude Desktop has no CLI — it reads a JSON config file. Let the installer write/merge it: +Claude Desktop has no CLI; it reads a JSON config file. Let the installer write/merge it: ```bash powermcp install --clients claude-desktop @@ -139,7 +139,7 @@ servers you already have (and backing the file up once). The file lives at: Preview without writing anything: `powermcp install --clients claude-desktop --dry-run`. -**Manual setup** — open that file (in Claude Desktop: **Settings → Developer → Edit Config**) +**Manual setup**: open that file (in Claude Desktop: **Settings > Developer > Edit Config**) and add entries under `mcpServers`. Use the **absolute interpreter path**: Claude Desktop is a GUI app and does *not* inherit your shell PATH, so a bare `python`/`powermcp` usually won't be found. @@ -162,17 +162,17 @@ found. (JSON needs escaped backslashes on Windows. Find the interpreter with `python -c "import sys; print(sys.executable)"`.) -**Apply & verify:** fully **quit and reopen** Claude Desktop — closing the window isn't enough; +**Apply and verify:** fully **quit and reopen** Claude Desktop; closing the window isn't enough, exit it from the system tray / menu bar, then relaunch. The PowerMCP tools then appear under the -tools (🔨) control in the message box, and **Settings → Developer** lists each server with its +tools (🔨) control in the message box, and **Settings > Developer** lists each server with its connection status. -**Closed-source tools:** set the path first (`powermcp config set …`), confirm with +**Closed-source tools:** set the path first (`powermcp config set ...`), confirm with `powermcp doctor`, then restart Claude Desktop. ### Use PowerMCP in Claude Code -**Option A — let the installer do it (recommended):** +**Option A, let the installer do it (recommended):** ```bash powermcp install --clients claude-code @@ -183,7 +183,7 @@ This adds one MCP server per selected tool to the **user scope** of Claude Code Each entry uses the absolute interpreter path (` -m powermcp run `) so Claude Code can always launch it. Re-running is idempotent and prunes tools you deselect. -**Option B — add them manually with the Claude Code CLI:** +**Option B, add them manually with the Claude Code CLI:** ```bash # --scope user = available everywhere; the `--` separates Claude's flags from the command @@ -207,14 +207,14 @@ claude mcp get powermcp_pandapower # show one server's details ``` Inside a Claude Code session, run `/mcp` to see connected servers and their tools, then -just ask — e.g. *"create an empty pandapower network and run a power flow."* +just ask, for example *"create an empty pandapower network and run a power flow."* -**Scopes:** `--scope user` (you, everywhere — what the installer uses) · -`--scope project` (shared via a checked-in `.mcp.json`, prompts teammates for approval) · +**Scopes:** `--scope user` (you, everywhere, which is what the installer uses); +`--scope project` (shared via a checked-in `.mcp.json`, prompts teammates for approval); `--scope local` (this project only, private to you). **Closed-source tools:** set the software path first (e.g. -`powermcp config set psse.python_lib "…\PSSPY311"`) and confirm with `powermcp doctor` +`powermcp config set psse.python_lib "...\PSSPY311"`) and confirm with `powermcp doctor` before adding the server, otherwise the tool will report an actionable error on first call. **To remove a server:** `claude mcp remove powermcp_pandapower --scope user`. @@ -235,10 +235,10 @@ python PSSE/psse_mcp.py # uses ~/.powermcp/config.toml if present, else lega ## 4. Test the package (developers) -The test suite lives in [`../tests`](../tests) (more than 900 tests). It needs no licensed software — +The test suite lives in [`../tests`](../tests). It needs no licensed software, vendor engines are stubbed, and server launches are checked with the stdio loop monkeypatched. -### A. Quick loop — editable install +### A. Quick loop: editable install ```bash python -m venv .venv @@ -249,7 +249,7 @@ pytest -q `pip install -e .` picks up source edits live, so re-run `pytest` after each change. -### B. Full gate — build the wheel and test the installed artifact +### B. Full gate: build the wheel and test the installed artifact This is what CI should run: it proves the wheel ships correctly and resolves paths in the **installed (wheel) layout**, which differs from the editable/checkout layout. @@ -261,7 +261,7 @@ build-env\Scripts\python -m build # writes dist/powermcp-*.whl (+ sdis # 2) install the wheel into a clean venv python -m venv test-env -test-env\Scripts\python -m pip install dist\powermcp-0.3.0-py3-none-any.whl pytest +test-env\Scripts\python -m pip install dist\powermcp-*.whl pytest # 3) run the suite against the INSTALLED package (run from a dir without the repo on the path) copy ..\tests to a temp dir, then: test-env\Scripts\python -m pytest \tests -q @@ -298,12 +298,12 @@ yellow = a path/config is missing; it also reminds you which tools need external | `test_vendor_import.py` | PSS/E & PSLF import **without** the software and init the engine exactly once | | `test_clients.py` | idempotent merge, foreign-server preservation, prune, backup, Codex TOML | | `test_wizard.py` | tool selection (`--tools`/`--all`, preselection of installed/configured tools), Windows/surge filtering, non-interactive handling | -| `test_doctor.py` | dependency/path status, namespace-shadow guard | +| `test_doctor.py` | dependency/path status, namespace-shadow check | | `test_detect.py` | LTSpice executable auto-detection across install layouts | > **Licensed tools (PSS/E, PSLF, PowerFactory, PSCAD, PowerWorld, LTSpice)** can't run in CI. > The suite verifies they *import safely* and produce actionable errors; running an actual -> tool requires the software installed and a `powermcp config set …` path, then +> tool requires the software installed and a `powermcp config set ...` path, then > `powermcp doctor` and a live `powermcp run ` from your MCP client. --- @@ -317,8 +317,15 @@ powermcp/ runner.py # launches a server by tool id registry.py # the tool registry (single source of truth) config.py # ~/.powermcp/config.toml + get_path() + errors.py # the one result shape every tool reports paths.py # ~/.powermcp/runs/ writable dirs doctor.py # health checks clients/ # claude_desktop / claude_code / codex config writers _servers/ # (wheel only) the tool dirs, shipped verbatim at build time ``` + +## Persistent Tellegen Studies + +The optional `tellegen` server invokes the native CLI for saved goals, branching +history, comparisons and exact proposals. See [the Study integration guide](TELLEGEN.md) +for executable configuration, portable bundles and revision checks. diff --git a/powermcp/TELLEGEN.md b/powermcp/TELLEGEN.md new file mode 100644 index 0000000..c502221 --- /dev/null +++ b/powermcp/TELLEGEN.md @@ -0,0 +1,61 @@ +# Native Tellegen solving and Studies + +`powermcp run tellegen` exposes the installed Tellegen CLI directly. The browser +is optional. Build Tellegen with `cargo build -p tellegen-cli --features conic` +and set `POWERMCP_TELLEGEN_BINARY` (or `powermcp config set tellegen.binary`) +to its executable, or put `tellegen` on `PATH`. `powermcp doctor` runs +`tellegen capabilities` to confirm the binary answers `capabilities`. +`POWERIO_MCP_ALLOWED_ROOTS` applies to every input, output and Study path. + +Everything Tellegen reads or writes is PowerIO IR generation 2. `solve` runs +one formulation (`dcpf`, `dcopf`, `acpf`, `socwr`) over a module given as +`powerio_ir` or as a grid exchange `path` that PowerIO parses in the server +process, with Tellegen's own `edits` and `sensitivities` request fields, and +bounds long arrays to `max_elements`. `solve_module` returns the stored +`powerio.DcOpfSolution` module, inline or written to `out_path` through a +staged write that refuses to overwrite. `plan` runs the bounded capacity +search for a `CapacityPlanSpec` and returns the proposal with its exact +proposed solution module. `capabilities` and `contract` describe the installed +build; `contract` carries the generated schemas for every request. + +Tellegen consumes a balanced network or a calculation instance +(`powerio.DcOpfInstance`, `powerio.AcPfInstance`, `powerio.AcOpfInstance`) and +lowers nothing. A multiconductor value is refused in this process, before the +binary runs, with the powerio server's `to_balanced` named as the step that +lowers it; a module PowerIO marks with an error severity diagnostic is refused +on the same terms as at every other solver boundary. `solve` carries the shared +response tail for the module it handed over (`value_type`, `selection`, +`diagnostics` and `warnings`), and `solve_module` and `plan` carry it beside +what the returned module states (its `value_type`, `termination` and +`objective` when the solution states them). The emission `fidelity` and the +typed `edits` list belong to the powerio adapters; Tellegen's `edits` argument +is the native request object (`{"deltas": ..., "rates": ...}`). + +Read `study_contract` for the installed build's generated Rust schemas and +formulation capabilities. `study_create` accepts `CreateStudy`, including the +PowerIO generation-2 input, outer objective and decision space. `study_run` +accepts a revision and one native `StudyOperation`: inspect, branch, revise a +goal, compare, propose or attach evidence. Capacity, demand placement and +redistribution use the same bounded search as the browser. Exact proposals stay +unapplied. An explicit user invocation of `tellegen study run PATH` can apply a +reviewed proposal with its state, base state, goal and revision binding. + +`study_inspect` returns a compact continuation summary. Goal, state, experiment +and evidence queries return bounded JSON fragments with offsets; pin the +expected revision when reading several fragments. `study_export` validates the +bundle and returns its path, revision and SHA-256. Import that file into the +Tellegen Study panel or use `study_import` to create another filesystem copy. +Imported documents never restore approvals. + +Native operations save atomically and refuse stale revisions. If the process is +cancelled or times out, the adapter requests termination and allows 300 seconds +(`POWERMCP_TELLEGEN_CANCEL_GRACE_SECONDS`) for the current exact trial to finish and the cancelled planning record to save. +Inspect the saved revision before retrying. If that grace period expires, the +adapter kills the process and completed unsaved trials can be lost. A lock left by a terminated writer requires verifying that the +writer has exited before removing the lock. Configure a bounded solve budget to +keep operations within the five-minute process deadline. + +The shared adapter tests cover path containment, rejection of agent apply +requests, exact revision forwarding and server registration. The native +`capabilities` check requires a compiled Tellegen executable; browser/headless numerical parity +is exercised by Tellegen's Study acceptance suite. diff --git a/powermcp/__init__.py b/powermcp/__init__.py index 32cd926..557268a 100644 --- a/powermcp/__init__.py +++ b/powermcp/__init__.py @@ -1,12 +1,12 @@ -"""PowerMCP — MCP servers for power-system software. +"""PowerMCP: MCP servers for power-system software. A single distribution that bundles MCP servers for pandapower, PyPSA, ANDES, Egret, surge, OpenDSS, HOPE, PSCAD, PSS/E, PSLF, PowerWorld, PowerFactory and -LTSpice. Install the core (`pip install powermcp` → pandapower + PyPSA + -PowerIO) and add tools via extras (`pip install powermcp[psse]`). Configure and wire up MCP -clients with the `powermcp` CLI (`powermcp install`). +LTSpice. Install the core (`pip install powermcp`, which brings pandapower, +PyPSA and PowerIO) and add tools via extras (`pip install powermcp[psse]`). +Configure and connect MCP clients with the `powermcp` CLI (`powermcp install`). """ -__version__ = "0.3.0" +__version__ = "0.4.0" __all__ = ["__version__"] diff --git a/powermcp/doctor.py b/powermcp/doctor.py index 3136d12..873f95e 100644 --- a/powermcp/doctor.py +++ b/powermcp/doctor.py @@ -1,17 +1,17 @@ -"""`powermcp doctor` — check each tool's dependencies and configured paths. +"""`powermcp doctor`: check each tool's dependencies and configured paths. Dependency checks use ``importlib.util.find_spec`` (which locates a module without executing it) so the doctor never triggers a vendor engine's import-time side effects (e.g. PSS/E ``psseinit``) and never crashes on a broken DLL. Vendor engines that load from a captured directory (PSS/E, PSLF, PowerFactory) are not -import-probed at all — they are reported via their configured paths and verified +import-probed at all; they are reported via their configured paths and verified for real only at runtime. Two checks are shared rather than per tool and print below the table: the MCP SDK, which every server imports, and the configured roots used by servers that call the shared filesystem policy. Both matter here because a server that fails -at launch gives its MCP client nothing at all — the diagnosis only exists in a -stderr the client does not read — so the doctor has to catch it beforehand. +at launch gives its MCP client nothing at all, and the diagnosis only exists +in a stderr the client does not read, so the doctor has to catch it beforehand. """ from __future__ import annotations @@ -35,7 +35,12 @@ from . import config as cfg from .registry import Tool, all_tools, get_tool, install_hint from .runner import probe_installed -from .sandbox import ALLOWED_ROOTS_ENV, LEGACY_ROOT_ENVS, allowed_roots +from .sandbox import ( + ALLOWED_ROOTS_ENV, + LEGACY_ROOT_ENVS, + PathNotAllowed, + allowed_roots, +) # Engines imported from a captured local dir (not from PyPI). Do not import-probe. _PATH_LOADED = {"psse", "pslf", "powerfactory"} @@ -86,7 +91,7 @@ def _version_status(probe: str) -> tuple[str, str] | None: ``find_spec`` answers "importable", which is a different question from "new enough": an old powerio imports fine and then refuses tools this repo calls. - ``None`` when there is nothing to say — no declared floor, or it is met, or + ``None`` when there is nothing to say: no declared floor, or it is met, or the installed version does not parse and there is nothing to compare. """ req = _declared_requirement(probe) @@ -105,24 +110,58 @@ def _version_status(probe: str) -> tuple[str, str] | None: def _dep_status(t: Tool) -> tuple[str, str]: """Return (style, message) for the dependency column.""" if t.windows_only and sys.platform != "win32": - return "dim", "skipped — Windows-only" + return "dim", "skipped, Windows-only" if t.name == "surge" and not _surge_supported(): - return "yellow", f"needs Python 3.12–3.14 (have {sys.version_info.major}.{sys.version_info.minor})" + return "yellow", f"needs Python 3.12 to 3.14 (have {sys.version_info.major}.{sys.version_info.minor})" if t.name in _PATH_LOADED: - return "cyan", "vendor engine — loaded from configured path" + return "cyan", "vendor engine, loaded from a configured path" + if t.name == "tellegen": + return _tellegen_status() if t.probe: if not probe_installed(t.probe): - return "red", f"missing — {install_hint(t.extra)}" + return "red", f"missing; {install_hint(t.extra)}" stale = _version_status(t.probe) if stale: return stale return "green", "ok" +def _tellegen_status() -> tuple[str, str]: + """Whether the native Tellegen CLI is configured and answers `capabilities`. + + The binary is not a Python package, so the import probe says nothing; the + one check that matters is that the configured executable runs and answers + `capabilities` with JSON. + """ + import json + import subprocess + + from . import tellegen + + try: + command = tellegen._command(["capabilities"]) + except Exception as exc: # unconfigured, or the configured path is missing + return "yellow", f"native CLI not found; {exc}" + try: + completed = subprocess.run( + command, capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=15, check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return "red", f"{command[0]} does not run: {exc}" + if completed.returncode != 0: + return "red", f"{command[0]} capabilities failed: {completed.stderr.strip()[:200]}" + try: + json.loads(completed.stdout) + except json.JSONDecodeError: + return "red", f"{command[0]} capabilities returned no JSON" + return "green", f"ok, {command[0]}" + + def _sdk_status() -> tuple[str, str]: """Every server imports the MCP SDK, so no tool row would report it.""" if not probe_installed("mcp"): - return "red", f"mcp: missing — every server needs it; {install_hint(None)}" + return "red", f"mcp: missing; every server needs it; {install_hint(None)}" stale = _version_status("mcp") if stale: return stale @@ -132,14 +171,24 @@ def _sdk_status() -> tuple[str, str]: def _containment_status() -> tuple[str, str]: """Whether model supplied paths are confined, and to what. - Unset is a legitimate configuration, not a fault, but it is worth stating: - a tool argument is whatever the model was persuaded to ask for. + With no root variable set, powerio confines paths to the directory the + process started in. That default is narrower than an operator usually + intends and it is not written down anywhere, so report it as a setting to + make rather than as a policy already chosen. """ - roots = allowed_roots() - if not roots: + configured = any( + os.environ.get(name) for name in (ALLOWED_ROOTS_ENV, *LEGACY_ROOT_ENVS) + ) + try: + roots = allowed_roots() + except PathNotAllowed as exc: + return "red", f"MCP paths: {exc}" + listed = ", ".join(str(r) for r in roots) + if not configured: return "yellow", ( - f"MCP paths: unconfined — set {ALLOWED_ROOTS_ENV} to an " - f"{os.pathsep!r} separated list of directories to confine reads and writes" + f"MCP paths: confined to the startup directory {listed}; set " + f"{ALLOWED_ROOTS_ENV} to an {os.pathsep!r} separated list of " + "directories to name the roots explicitly" ) missing = [str(r) for r in roots if not r.is_dir()] if len(missing) == len(roots): @@ -149,12 +198,10 @@ def _containment_status() -> tuple[str, str]: ) if missing: return "yellow", ( - "MCP paths: confined to " - + ", ".join(str(r) for r in roots) - + "; these do not exist and admit nothing: " - + ", ".join(missing) + f"MCP paths: confined to {listed}; these do not exist and admit " + "nothing: " + ", ".join(missing) ) - return "green", "MCP paths: confined to " + ", ".join(str(r) for r in roots) + return "green", f"MCP paths: confined to {listed}" def _path_status(t: Tool) -> tuple[str, str]: @@ -162,7 +209,7 @@ def _path_status(t: Tool) -> tuple[str, str]: required = [ck for ck in t.config_keys if ck.required] optional = [ck for ck in t.config_keys if not ck.required] if not t.config_keys: - return "dim", "—" + return "dim", "-" missing = [] for ck in required: try: @@ -195,7 +242,7 @@ def run_doctor(tool: str | None = None) -> None: f"[{path_style}]{escape(path_msg)}[/]", ) if t.external_solvers and not (t.windows_only and sys.platform != "win32"): - solver_notes.append(f" • {t.display}: needs {', '.join(t.external_solvers)} available at runtime") + solver_notes.append(f" - {t.display}: needs {', '.join(t.external_solvers)} available at runtime") console = Console() console.print(table) diff --git a/powermcp/errors.py b/powermcp/errors.py new file mode 100644 index 0000000..4d2e8c3 --- /dev/null +++ b/powermcp/errors.py @@ -0,0 +1,54 @@ +"""One result shape for every PowerMCP tool. + +A tool reports success as ``{"status": "success", ...}`` and failure as +``{"status": "error", "message": }``, plus whatever result keys the tool +documents. A caller reads ``status`` once and handles either outcome the same +way against every bundled server. + +:func:`run_tool` applies that shape to a tool body. A refused path or a +rejected argument becomes the message a caller can act on. Any other exception +is logged with its traceback and reported by type and text, so a failure +reaches the caller as a result rather than as an MCP protocol error, and the +traceback stays out of the JSON-RPC stream on stdout. +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable + +from powermcp.sandbox import PathNotAllowed + +__all__ = ["run_tool", "tool_error", "tool_success"] + + +def tool_error(message: str, **fields: Any) -> dict[str, Any]: + """Report a failed tool call. + + ``message`` is written for whoever called the tool. ``fields`` carries any + additional key the tool documents on its failure branch, so a caller that + reads a key after checking ``status`` finds it on every branch. + """ + return {"status": "error", "message": message, **fields} + + +def tool_success(**fields: Any) -> dict[str, Any]: + """Report a successful tool call and its result keys.""" + return {"status": "success", **fields} + + +def run_tool(call: Callable[[], dict], *, logger: logging.Logger) -> dict[str, Any]: + """Run a tool body and report any failure through the error shape. + + ``PathNotAllowed`` and ``ValueError`` carry text already aimed at the + caller, so their message passes through unchanged. Every other exception is + logged through ``logger`` with its traceback and reported as + ``": "``. + """ + try: + return call() + except (PathNotAllowed, ValueError) as exc: + return tool_error(str(exc)) + except Exception as exc: # noqa: BLE001 - every failure leaves as a result + logger.exception("Tool call failed") + return tool_error(f"{type(exc).__name__}: {exc}") diff --git a/powermcp/registry.py b/powermcp/registry.py index 7f23eef..d658559 100644 --- a/powermcp/registry.py +++ b/powermcp/registry.py @@ -151,7 +151,17 @@ def resolve_module_root(self) -> Path: "powerio", "PowerIO", "open-source", windows_only=False, extra=None, server_dir=None, run_kind="package", module="powerio.mcp", probe="powerio", - notes="Format-neutral conversion, matrices, and auditable .pio.json packages. Its canonical MCP server owns package operations; pandapower, PyPSA, Egret, and ANDES resolve package states only when importing into a solver.", + notes="Format-neutral conversion, matrices, and auditable PowerIO IR modules. Its canonical MCP server owns module operations; pandapower, PyPSA, Egret, and ANDES resolve selected module states only when importing into a solver.", + ), + # tellegen is a Rust CLI (cargo build -p tellegen-cli --features conic), not + # a pip package: the server lives inside powermcp and exchanges PowerIO IR + # with the binary over stdin and stdout. + Tool( + "tellegen", "Tellegen", "open-source", windows_only=False, extra="tellegen", + server_dir=None, run_kind="package", module="powermcp.tellegen", probe="mcp", + config_keys=(ConfigKey("binary", "Path to the native Tellegen CLI executable (cargo build -p tellegen-cli --features conic)", "file", required=False),), + external_solvers=("Clarabel",), + notes="DC and AC power flow, DC OPF prices and dispatch, the SOCWR relaxation, sensitivities, capacity planning and durable Studies over the native tellegen CLI; exchanges PowerIO IR only. Applying a Study proposal is a human action, not a tool. Configure POWERMCP_TELLEGEN_BINARY or install tellegen on PATH.", ), # ---- CLOSED-SOURCE / VENDOR ---- Tool( diff --git a/powermcp/sandbox.py b/powermcp/sandbox.py index c609260..846a7de 100644 --- a/powermcp/sandbox.py +++ b/powermcp/sandbox.py @@ -11,8 +11,8 @@ ``powerio.mcp.sandbox`` imports nothing but the standard library, so there is no second copy to keep in step. Operators configure containment once, with ``POWERIO_MCP_ALLOWED_ROOTS`` (an ``os.pathsep`` separated list of directories) -or one of the legacy single root spellings powerio still reads. Unset, nothing -is constrained. +or one of the legacy single root spellings powerio still reads. With none set, +powerio confines paths to the directory the server process started in. Resolution happens before the check, so neither a ``..`` segment nor a symlink pointing out of a root gets through: it is the real target that is compared, @@ -34,6 +34,7 @@ checked_read_tree, decode_local_path, staged_directory_write, + staged_file_write, ) @@ -43,7 +44,7 @@ def ensure_checked_directory(value: str, *, purpose: str = "directory") -> str: ``checked_path(..., for_write=True)`` deliberately requires an existing parent. Generated run directories often have several missing parents, so walk back to the first existing directory and create each component only - after checking it. The explicit anchor guard matters on Windows: the + after checking it. The explicit anchor check matters on Windows: the parent of an unavailable drive or UNC anchor is the anchor itself. """ target = decode_local_path(value, purpose=purpose) @@ -71,7 +72,7 @@ def ensure_checked_directory(value: str, *, purpose: str = "directory") -> str: try: checked.mkdir() except FileExistsError: - # A cooperating process may have created it after our exists() + # A cooperating process may have created it after the exists() # check. Accept only a directory, never a file or dangling link. if not checked.is_dir(): raise PathNotAllowed( @@ -96,4 +97,5 @@ def ensure_checked_directory(value: str, *, purpose: str = "directory") -> str: "decode_local_path", "ensure_checked_directory", "staged_directory_write", + "staged_file_write", ] diff --git a/powermcp/solver_case.py b/powermcp/solver_case.py index 052aeab..a864757 100644 --- a/powermcp/solver_case.py +++ b/powermcp/solver_case.py @@ -1,275 +1,482 @@ -"""Resolve PowerIO inputs into one balanced state for a solver. - -PowerIO owns case parsing and the durable ``.pio.json`` package lifecycle. -PowerMCP owns the point where a concrete network enters one of its solver -servers. Keeping that boundary here gives every solver the same validation, -state-selection, diagnostic, and multiconductor rules without copying them. +"""Select one typed PowerIO state for a balanced solver. + +PowerIO owns parsing, IR validation, transformations, typed updates and format +emission. PowerMCP routes a declared PowerIO value to the solver that accepts +it: the caller names the collection entry, asks for the multiconductor to +balanced transformation explicitly, and states any what-if edit as a typed +update PowerIO validates and applies, in the caller's order, before the solver +sees the network. Nothing here re-parses, re-validates, or recomputes what +PowerIO already states. """ - from __future__ import annotations import json -from dataclasses import dataclass +import math +from dataclasses import dataclass, field from pathlib import Path from typing import Any import powerio - from powermcp.sandbox import checked_path, checked_read_tree -_PACKAGE_FORMATS = frozenset( - {"package", "pio", "pio-json", "pio_json", "pio-package", "pio_package"} -) +_IR_FORMATS = frozenset({"pio-ir", "pio"}) +IR_SCHEMA = "pio-ir" +IR_VERSION = 2 -@dataclass(frozen=True) -class SolverCase: - """One validated balanced state ready for a PowerMCP solver.""" +_QUIET_SEVERITIES = frozenset({"remark", "note"}) - network: powerio.BalancedNetwork - warnings: tuple[str, ...] = () - package: dict[str, Any] | None = None + +def diagnostic_messages(items: Any) -> tuple[str, ...]: + """Keep stable diagnostic codes beside their user-facing descriptions.""" + return tuple( + f"{item.code}: {item.message}" + for item in items + if item.severity not in _QUIET_SEVERITIES + ) -def _format_token(value: str | None) -> str | None: - return value.strip().lower().replace("_", "-") if value is not None else None +def unique_diagnostics(items: Any) -> list[Any]: + """The diagnostics in first-seen order, one entry per distinct report. + A module states the records of every stage that produced it, so a stage + whose diagnostics were collected when it ran states them again through the + module it handed on. Identity is the stable code, its severity, the + description, the target and the component id. + """ + seen: set[tuple[Any, ...]] = set() + unique: list[Any] = [] + for item in items: + key = (item.code, item.severity, item.message, item.target, item.id) + if key in seen: + continue + seen.add(key) + unique.append(item) + return unique + + +def check_diagnostics(items: Any) -> None: + failures = [item for item in items if item.severity == "error"] + if failures: + raise ValueError( + "PowerIO input fails validation: " + "; ".join(diagnostic_messages(failures)) + ) -def _package_document(text: str) -> dict[str, Any] | None: - """Return the parsed document only when ``text`` identifies a package.""" + +@dataclass(frozen=True) +class SolverCase: + """One selected state, its typed network and its source context. + + ``module`` is the single-entry module a writer receives; ``network`` its + balanced network. ``package`` keeps the IR context adapters already return + (schema, generation, producer, value type, selection). ``value_type`` is the + PowerIO structural type of the selected value, ``selection`` the collection + selectors that reached it, ``diagnostics`` the module's records, ``edits`` + the report of the typed updates applied, and ``lowering`` the multiconductor + readiness report when the caller asked for the balanced transformation. + """ + module: powerio.PioModule + network: powerio.BalancedNetwork + warnings: tuple[str, ...] = () + package: dict[str, Any] | None = None + value_type: str = "powerio.BalancedNetwork" + selection: dict[str, Any] = field(default_factory=dict) + diagnostics: tuple[dict[str, Any], ...] = () + edits: dict[str, Any] | None = None + lowering: dict[str, Any] | None = None + + def emit(self, format: str, destination: Any = None): + result = powerio.emit(self.module, format, destination) + check_diagnostics(result.diagnostics) + return result + + def response_fields(self, conversion: Any = None) -> dict[str, Any]: + """The shared tail every solver adapter splices into its response. + + ``conversion`` is the ``EmitResult`` of the adapter's own emission, so + its fidelity and diagnostics travel with the module's. + """ + diagnostics = list(self.diagnostics) + warnings = list(self.warnings) + fields: dict[str, Any] = { + "value_type": self.value_type, + "selection": dict(self.selection), + } + if conversion is not None: + diagnostics.extend(powerio.diagnostic_records(conversion.diagnostics)) + warnings.extend(diagnostic_messages(conversion.diagnostics)) + fields["fidelity"] = conversion.fidelity + fields["diagnostics"] = diagnostics + fields["warnings"] = list(dict.fromkeys(warnings)) + if self.edits is not None: + fields["edits"] = self.edits + if self.lowering is not None: + fields["lowering"] = self.lowering + if self.package is not None: + fields["package"] = self.package + return fields + + +def _document(text: str) -> dict[str, Any] | None: try: value = json.loads(text) except json.JSONDecodeError: return None if not isinstance(value, dict): return None - if value.get("model_kind") not in ("balanced", "multiconductor"): - return None - return value if isinstance(value.get("model"), dict) else None - - -def _diagnostic_messages(items: Any) -> tuple[str, ...]: - messages = [] - for item in items if isinstance(items, list) else []: - if not isinstance(item, dict) or item.get("severity") != "warning": - continue - code = item.get("code") - message = item.get("message") - if code and message: - messages.append(f"{code}: {message}") - elif message: - messages.append(str(message)) - return tuple(messages) - - -def _unique_messages(*groups: Any) -> tuple[str, ...]: - return tuple( - dict.fromkeys( - str(message) - for group in groups - for message in (group or ()) - if message - ) - ) + if value.get("schema") == IR_SCHEMA: + return value + return None -def _operating_point_indexes(points: Any) -> list[int]: - if not isinstance(points, dict) or not isinstance(points.get("points"), list): - return [] - return [ - point["index"] - for point in points["points"] - if isinstance(point, dict) and isinstance(point.get("index"), int) - ] - +def select_entry(module, time_index, scenario_id): + """Resolve the collection selectors to one entry and the module holding it. -def _study_commit_indexes(study: Any) -> list[int]: - if not isinstance(study, dict) or not isinstance(study.get("commits"), list): + Each level rebuilds the module around the entry it selected, so an entry + carries exactly one selector and the levels of a nested collection resolve + outermost first. A selector naming an absent scenario or an index past the + end raises :class:`ValueError` stating what the collection offers. + """ + selection = {} + value = module.value + while isinstance(value, (powerio.TimeSeries, powerio.ScenarioSet)): + if isinstance(value, powerio.ScenarioSet): + available = list(value.keys())[:20] + if scenario_id is None: + raise ValueError( + f"select scenario_id from the ScenarioSet before solving; scenarios: {available}" + ) + if scenario_id not in value: + raise ValueError( + f"scenario_id {scenario_id!r} names no entry of the ScenarioSet; " + f"scenarios: {available}" + ) + value = value[scenario_id] + selection["scenario_id"] = scenario_id + scenario_id = None + else: + if time_index is None: + raise ValueError( + f"select time_index from the TimeSeries before solving; {len(value)} entries" + ) + if isinstance(time_index, bool) or not isinstance(time_index, int) or time_index < 0: + raise ValueError("time_index must be a nonnegative integer") + if time_index >= len(value): + raise ValueError( + f"time_index {time_index} is past the end of the TimeSeries; " + f"{len(value)} entries" + ) + value = value[time_index] + selection["time_index"] = time_index + time_index = None + module = powerio.PioModule.from_value(value) + value = module.value + if time_index is not None or scenario_id is not None: + raise ValueError("state selector does not match the input collection") + return module, selection + + +# ---- typed edits ------------------------------------------------------------- + +# Each op names the PowerIO constructor, the component type the id refers to, +# and the value keys with the unit constructor that types them. +_OPERATING_POINT_OPS: dict[str, tuple[str, str, tuple[tuple[str, str], ...], bool]] = { + # op: (constructor, component_type, ((key, unit), ...), takes_terminal) + "set_load_active_power": ("set_load_active_power", "load", (("mw", "active_power"),), True), + "set_load_reactive_power": ("set_load_reactive_power", "load", (("mvar", "reactive_power"),), True), + "set_generator_active_power": ("set_generator_active_power", "generator", (("mw", "active_power"),), True), + "set_generator_reactive_power": ("set_generator_reactive_power", "generator", (("mvar", "reactive_power"),), True), + "set_generator_voltage_magnitude": ("set_generator_voltage_magnitude", "generator", (("vm_pu", "float"),), False), + "set_generator_in_service": ("set_generator_in_service", "generator", (("in_service", "bool"),), False), + "set_branch_in_service": ("set_branch_in_service", "branch", (("in_service", "bool"),), False), + "set_transformer_tap_ratio": ("set_transformer_tap_ratio", "transformer", (("tap_ratio", "float"),), False), + "set_transformer_phase_shift": ("set_transformer_phase_shift", "transformer", (("shift_degrees", "float"),), False), + "set_switch_closed": ("set_switch_closed", "switch", (("closed", "bool"),), False), +} +_NETWORK_OPS = { + "set_branch_thermal_rating": ("set_branch_thermal_rating", "branch", (("mva", "apparent_power"),), True), +} +_BUS_LOAD_OP = "set_bus_load_active_power" +_ALLOCATIONS = ("proportional_to_current_active_power", "equal") +EDIT_OPS: tuple[str, ...] = (*_OPERATING_POINT_OPS, *_NETWORK_OPS, _BUS_LOAD_OP) + + +def parse_edits(text: str | None) -> list[dict[str, Any]]: + """Decode the ``edits`` JSON argument: a list of ``{"op": ..., ...}`` objects.""" + if not text: return [] - return list(range(len(study["commits"]))) - - -def _available_indexes(indexes: list[int]) -> str: - """Describe state choices without flooding an MCP error response.""" - if len(indexes) <= 20: - return str(indexes) - if all( - index == indexes[0] + offset for offset, index in enumerate(indexes) - ): - return f"{indexes[0]}..{indexes[-1]} ({len(indexes)} available)" - preview = ", ".join(str(index) for index in indexes[:10]) - return f"[{preview}, ...] ({len(indexes)} available; last {indexes[-1]})" - - -def _index_inventory(indexes: list[int]) -> dict[str, Any]: - inventory: dict[str, Any] = { - "count": len(indexes), - "first": indexes[0], - "last": indexes[-1], - } - if len(indexes) <= 20: - inventory["indexes"] = indexes - return inventory - - -def _validated(package: powerio.Package) -> dict[str, Any]: - # A serialized package carries the validation summary from the time it was - # written. Recompute it after deserialization so a modified model cannot - # keep a stale ``status: ok`` and cross the solver boundary unchecked. - package.validate() - validation = package.validation() - if validation.get("status") in ("error", "fatal"): - raise ValueError( - "the PowerIO package fails validation; inspect it with " - "the canonical PowerIO diagnostics tool before solving it" - ) - return validation - - -def _resolve_package( - package: powerio.Package, - *, - original_document: dict[str, Any] | None, - input_warnings: tuple[str, ...], - operating_point: int | None, - study_commit: int | None, -) -> SolverCase: - """Validate and reduce a package to the one state a solver can consume.""" - if operating_point is not None and study_commit is not None: - raise ValueError("choose either operating_point or study_commit, not both") - if operating_point is not None and operating_point < 0: - raise ValueError("operating_point must be zero or greater") - if study_commit is not None and study_commit < 0: - raise ValueError("study_commit must be zero or greater") - - _validated(package) - if package.model_kind != "balanced": - raise ValueError( - "this solver requires a balanced package; explicitly lower the " - "package with PowerIO Package.lower_multiconductor_to_balanced() first" - ) - - points = package.operating_points() - study = package.study() - point_indexes = _operating_point_indexes(points) - commit_indexes = _study_commit_indexes(study) - selection: dict[str, Any] | None = None - if operating_point is not None: - if not point_indexes: - raise ValueError("the .pio.json package has no operating points") - package = package.materialize_operating_point(operating_point) - selection = {"kind": "operating_point", "index": operating_point} - elif study_commit is not None: - if not commit_indexes: - raise ValueError("the .pio.json package has no study commits") - package = package.materialize_study_commit(study_commit) - selection = {"kind": "study_commit", "index": study_commit} - if isinstance(study, dict) and study.get("base_operating_point") is not None: - selection["base_operating_point"] = study["base_operating_point"] - elif point_indexes or commit_indexes: - choices = [] - if point_indexes: - choices.append(f"operating_point from {_available_indexes(point_indexes)}") - if commit_indexes: - choices.append(f"study_commit from {_available_indexes(commit_indexes)}") + try: + edits = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"edits must be a JSON list of objects: {exc}") from exc + if not isinstance(edits, list) or not all(isinstance(edit, dict) for edit in edits): + raise ValueError("edits must be a JSON list of objects") + return edits + + +def _number(edit: dict[str, Any], key: str, position: int) -> float: + value = edit.get(key) + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError(f"edit #{position}: {key!r} must be a finite number") + return float(value) + + +def _flag(edit: dict[str, Any], key: str, position: int) -> bool: + value = edit.get(key) + if not isinstance(value, bool): + raise ValueError(f"edit #{position}: {key!r} must be true or false") + return value + + +def _identity(edit: dict[str, Any], component_type: str, position: int) -> powerio.ComponentId: + local_id = edit.get(component_type) + if local_id is None or isinstance(local_id, bool): + raise ValueError(f"edit #{position}: {component_type!r} names the component's stable id") + return powerio.ComponentId(component_type, str(local_id)) + + +def _typed_value(kind: str, edit: dict[str, Any], key: str, position: int) -> Any: + if kind == "active_power": + return powerio.ActivePower.megawatts(_number(edit, key, position)) + if kind == "reactive_power": + return powerio.ReactivePower.megavars(_number(edit, key, position)) + if kind == "apparent_power": + return powerio.ApparentPower.megavolt_amperes(_number(edit, key, position)) + if kind == "bool": + return _flag(edit, key, position) + return _number(edit, key, position) + + +def _build_updates(edits: list[dict[str, Any]]) -> list[tuple[str, Any]]: + """Validate every edit first, then build the typed updates as ordered steps. + + A step is one run of consecutive edits of a single update class, or one bus + demand reallocation, which PowerIO applies on its own at the calculation + level. The steps keep the caller's list order, so a later edit states the + value that stands after the edits before it. + """ + steps: list[tuple[str, Any]] = [] + for position, edit in enumerate(edits): + op = edit.get("op") + if op in _OPERATING_POINT_OPS or op in _NETWORK_OPS: + operating = op in _OPERATING_POINT_OPS + table = _OPERATING_POINT_OPS if operating else _NETWORK_OPS + constructor, component_type, values, takes_terminal = table[op] + identity = _identity(edit, component_type, position) + args = [_typed_value(kind, edit, key, position) for key, kind in values] + kwargs = {} + if takes_terminal and edit.get("terminal") is not None: + kwargs["terminal"] = str(edit["terminal"]) + klass = powerio.OperatingPointUpdate if operating else powerio.NetworkUpdate + update = getattr(klass, constructor)(identity, *args, **kwargs) + step = "operating" if operating else "network" + if steps and steps[-1][0] == step: + steps[-1][1].append(update) + else: + steps.append((step, [update])) + elif op == _BUS_LOAD_OP: + bus = edit.get("bus") + if isinstance(bus, bool) or not isinstance(bus, int): + raise ValueError(f"edit #{position}: 'bus' must be an integer bus id") + allocation = edit.get("allocation", _ALLOCATIONS[0]) + if allocation not in _ALLOCATIONS: + raise ValueError(f"edit #{position}: 'allocation' must be one of {list(_ALLOCATIONS)}") + total = powerio.ActivePower.megawatts(_number(edit, "mw", position)) + steps.append(("bus_load", (bus, total, allocation))) + else: + raise ValueError(f"edit #{position}: unknown op {op!r}; expected one of {list(EDIT_OPS)}") + return steps + + +def _report_payload(reports: list[Any]) -> dict[str, Any]: + changes = [] + connectivity = False + for report in reports: + connectivity = connectivity or bool(report.connectivity_changed) + for change in report.changes: + changes.append( + { + "component_type": change.component_id.component_type, + "local_id": change.component_id.local_id, + "field": change.field, + "terminal": change.terminal, + } + ) + return {"changes": changes, "connectivity_changed": connectivity} + + +def apply_edits( + module: powerio.PioModule, edits: list[dict[str, Any]] +) -> tuple[powerio.PioModule, dict[str, Any] | None]: + """Apply the edit vocabulary to a module with PowerIO's typed updates. + + The whole list is validated before anything is applied, and the edits then + apply in the caller's list order. Consecutive updates of one class go + through one atomic ``apply_updates`` batch on the module; each bus demand + reallocation is its own step and therefore sees the values the edits before + it produced. A bus demand allocation is a calculation level operation in + PowerIO, so it runs on a DC power flow instance built from the network at + that point and the edited network comes back as a fresh module. Returns the + module to continue with and the merged report, whose changes follow + application order, or ``None`` for an empty list. + """ + if not edits: + return module, None + steps = _build_updates(edits) + value = module.value + wrap = None + if isinstance(value, powerio.BalancedNetwork): + pass + elif isinstance(value, ( + powerio.DcPfInstance, powerio.AcPfInstance, powerio.DcOpfInstance, powerio.AcOpfInstance, + )): + wrap = powerio.CalculationUpdate + else: raise ValueError( - "the .pio.json package contains stored solver state data; select " - + " or ".join(choices) + f"edits apply to a BalancedNetwork or a calculation instance, not {type(value).__name__}" ) + reports = [] + try: + for step, payload in steps: + if step == "bus_load": + bus, total, allocation = payload + target = module if wrap else module.to_dc_pf_instance() + reports.append(powerio.apply_bus_load_active_power(target, bus, total, allocation=allocation)) + if not wrap: + module = powerio.PioModule.from_value(target.value.network) + else: + updates = [wrap(update) for update in payload] if wrap else payload + reports.append(powerio.apply_updates(module, updates)) + except (powerio.PowerIOError, TypeError) as exc: + # PowerIOError.__str__ already states the stable diagnostic code. + raise ValueError(f"edit rejected: {exc}") from exc + return module, _report_payload(reports) + + +# ---- resolution --------------------------------------------------------------- + +_MULTICONDUCTOR = ( + powerio.MulticonductorNetwork, + powerio.McAcPfInstance, + powerio.McAcOpfInstance, + powerio.McAcPfSolution, + powerio.McAcOpfSolution, +) - validation = _validated(package) - network = package.as_balanced() - package_context: dict[str, Any] | None = None - if original_document is not None: - source_maps = original_document.get("source_maps") - package_context = { - "powerio_version": original_document.get("powerio_version"), - "model_kind": package.model_kind, - "producer": original_document.get("producer"), - "origin": original_document.get("origin"), - "validation": validation, - "source_map_entries": ( - len(source_maps) if isinstance(source_maps, list) else 0 - ), - } - if original_document.get("package_id") is not None: - package_context["package_id"] = original_document["package_id"] - if point_indexes: - package_context["operating_points"] = _index_inventory(point_indexes) - if commit_indexes: - package_context["study_commits"] = _index_inventory(commit_indexes) - if selection is not None: - package_context["materialized"] = selection - return SolverCase( - network, - _unique_messages( - input_warnings, - network.read_warnings, - _diagnostic_messages(package.diagnostics()), - ), - package_context, - ) +def operating_point_module(module: powerio.PioModule) -> powerio.PioModule: + """The balanced network an operating point entry states.""" + return powerio.PioModule.from_value(module.value.network) def resolve_solver_case( *, file_path: str | None = None, network_json: str | None = None, + powerio_ir: str | None = None, source_format: str | None = None, + time_index: int | None = None, + scenario_id: str | None = None, operating_point: int | None = None, study_commit: int | None = None, + edits: str | list[dict[str, Any]] | None = None, + to_balanced: bool = False, + base_mva: float = 100.0, ) -> SolverCase: - """Resolve exactly one file or JSON input into a validated solver case. - - Ordinary case files are wrapped with :class:`powerio.Package`, so the same - PowerIO 0.9 validation and diagnostics run for file and package inputs. - Package metadata is returned only when the caller supplied a package. + """Resolve one file or IR input without selecting or lowering implicitly. + + ``powerio_ir`` is the serialized module the powerio server's ``parse``, + ``to_normalized`` or ``to_balanced`` tools return; ``network_json`` is its + compatibility alias and must carry the same generation-2 document. + ``operating_point`` is a compatibility spelling for ``time_index``. Study + history belongs to Tellegen; legacy package commits require migration. + ``edits`` are typed updates applied before validation; ``to_balanced`` + authorizes the multiconductor to balanced transformation, whose readiness + report is returned as ``lowering``. """ + if network_json and powerio_ir: + raise ValueError("pass powerio_ir or its alias network_json, not both") + network_json = powerio_ir or network_json or None if (file_path is None) == (network_json is None): - raise ValueError("provide exactly one of file_path or network_json") - - original_document: dict[str, Any] | None = None - input_warnings: tuple[str, ...] = () + raise ValueError("provide exactly one of file_path or powerio_ir") + if study_commit is not None: + raise ValueError("study_commit requires a Tellegen Study state; export its PowerIO IR before using this solver") + if operating_point is not None: + if time_index is not None: + raise ValueError("choose time_index or operating_point, not both") + time_index = operating_point + edit_list = parse_edits(edits) if isinstance(edits, str) or edits is None else list(edits) + token = source_format.strip().lower().replace("_", "-") if source_format else None + explicit_ir = token in _IR_FORMATS + document = None if file_path is not None: - # A directory format (for example PyPSA CSV) can contain many files. - # Checking only the directory itself would still permit a descendant - # symlink to escape the operator's configured MCP roots. - file_path = checked_path(file_path, purpose="file_path") - candidate = Path(file_path) - if candidate.is_dir(): - file_path = checked_read_tree(file_path, purpose="file_path") - candidate = Path(file_path) - explicit_package = _format_token(source_format) in _PACKAGE_FORMATS - if explicit_package or candidate.suffix.lower() == ".json": - try: - text = candidate.read_text(encoding="utf-8") - except OSError: - if explicit_package: - raise - else: - original_document = _package_document(text) - if original_document is not None: - package = powerio.Package.from_json(text) - elif explicit_package: - raise ValueError("input is not a .pio.json package") - if original_document is None: - package = powerio.Package.from_file(file_path, source_format) + path = Path(checked_path(file_path, purpose="file_path")) + if path.is_dir(): + path = Path(checked_read_tree(str(path), purpose="file_path")) + elif explicit_ir or path.suffix.lower() == ".json": + document = _document(path.read_text(encoding="utf-8")) + if explicit_ir and document is None: + raise ValueError("input is not PowerIO IR") + module = powerio.deserialize(path) if document is not None else powerio.parse(path, format=token) else: - original_document = _package_document(network_json) - if original_document is not None: - package = powerio.Package.from_json(network_json) - else: - if operating_point is not None or study_commit is not None: - raise ValueError("state selectors are only valid for .pio.json packages") - network = powerio.from_json(network_json) - input_warnings = tuple(network.read_warnings) - package = powerio.Package.from_balanced(network) - - return _resolve_package( - package, - original_document=original_document, - input_warnings=input_warnings, - operating_point=operating_point, - study_commit=study_commit, + document = _document(network_json) + if explicit_ir and document is None: + raise ValueError("input is not PowerIO IR") + payload = network_json.encode("utf-8") + module = powerio.deserialize(payload) if document is not None else powerio.parse(payload, format=token, name="input.json") + check_diagnostics(module.diagnostics) + diagnostics = list(module.diagnostics) + module, selection = select_entry(module, time_index, scenario_id) + check_diagnostics(module.diagnostics) + value = module.value + if isinstance(value, powerio.OperatingPoint): + module = operating_point_module(module) + value = module.value + selected_type = module.type_name + lowering = None + if isinstance(value, _MULTICONDUCTOR): + if not to_balanced or not isinstance(value, powerio.MulticonductorNetwork): + raise ValueError( + "this solver requires a balanced network; inspect PioModule.to_balanced_report() " + "and explicitly call to_balanced() first, or pass to_balanced=True" + ) + lowering = module.to_balanced_report(base_mva) + module = module.to_balanced(base_mva) + check_diagnostics(module.diagnostics) + diagnostics.extend(module.diagnostics) + value = module.value + module, edit_report = apply_edits(module, edit_list) + value = module.value + if isinstance(value, powerio.BalancedNetwork): + # Constructing an instance checks electrical identities and reference coverage. + checked = module.to_ac_pf_instance() + check_diagnostics(checked.diagnostics) + network = module.value + elif isinstance(value, ( + powerio.DcPfInstance, powerio.AcPfInstance, powerio.DcOpfInstance, powerio.AcOpfInstance, + powerio.DcPfSolution, powerio.AcPfSolution, powerio.DcOpfSolution, powerio.AcOpfSolution, + powerio.SocwrOpfSolution, + )): + network = module.value.network + else: + raise ValueError(f"unsupported solver input type: {type(value).__name__}") + context = None + if document is not None: + context = {"schema": IR_SCHEMA, "generation": document["version"], + "producer": document.get("producer"), "source_type": document["value"]["type"], + "selection": selection} + # A stage that handed its module on keeps its records here, and a stage + # whose module reached the end states them again; report each one once. + all_diagnostics = unique_diagnostics([*diagnostics, *module.diagnostics]) + warnings = tuple(dict.fromkeys(diagnostic_messages(all_diagnostics))) + return SolverCase( + module, + network, + warnings, + context, + value_type=selected_type, + selection=selection, + diagnostics=tuple(powerio.diagnostic_records(all_diagnostics)), + edits=edit_report, + lowering=lowering, ) diff --git a/powermcp/tellegen.py b/powermcp/tellegen.py new file mode 100644 index 0000000..2cbf806 --- /dev/null +++ b/powermcp/tellegen.py @@ -0,0 +1,525 @@ +"""Native Tellegen operations over the versioned CLI interface. + +Tellegen consumes and produces PowerIO IR generation 2. PowerMCP hands the +CLI a serialized module and returns what the CLI states: a solve response, a +stored solution module, a capacity proposal, or a Study summary. A grid +exchange file given by ``path`` is parsed by PowerIO in this process and +serialized to IR before it reaches Tellegen; PowerMCP never re-implements +either side. Applying a Study proposal is a human action and is not a tool. +""" +from __future__ import annotations + +import asyncio +import hashlib +import io +import json +import math +import os +import shutil +import signal +import subprocess +import sys +from pathlib import Path +from typing import Any, Optional + +from mcp.server.mcpserver import MCPServer +from powermcp.config import get, get_path +from powermcp.sandbox import checked_path, checked_read_tree, staged_file_write + +mcp = MCPServer("Tellegen") +MAX_BUNDLE_BYTES = 512 * 1024 * 1024 +OPERATIONS = frozenset({"inspect", "branch", "revise_goal", "compare", "propose", "record_evidence", "edit_demand", "restore_base"}) +FORMULATIONS = ("dcpf", "dcopf", "acpf", "socwr") +DEFAULT_MAX_ELEMENTS = 2000 + + +def _binary() -> str: + if os.environ.get("POWERMCP_TELLEGEN_BINARY") or get("tellegen", "binary"): + return get_path("tellegen", "binary") + executable = shutil.which("tellegen") + if executable: + return executable + raise RuntimeError( + "Install the native Tellegen CLI (cargo build -p tellegen-cli --features conic) and set " + "POWERMCP_TELLEGEN_BINARY or `powermcp config set tellegen.binary `" + ) + + +def _seconds(key: str, default: float) -> float: + value = os.environ.get(f"POWERMCP_TELLEGEN_{key.upper()}", get("tellegen", key, default)) + try: + seconds = float(value) + except (TypeError, ValueError): + raise ValueError(f"tellegen.{key} must be a finite positive duration") from None + if isinstance(value, bool) or not math.isfinite(seconds) or not 0 < seconds <= 86400: + raise ValueError(f"tellegen.{key} must be a duration in (0, 86400] seconds") + return seconds + + +def _command(arguments: list[str]) -> list[str]: + binary = _binary() + # A Python script stands in for the compiled CLI in tests and on hosts + # without a Rust toolchain; the protocol on stdin and stdout is the same. + if binary.endswith(".py"): + return [sys.executable, binary, *arguments] + return [binary, *arguments] + + +async def _call(arguments: list[str], request: Any = None, *, raw_stdin: Optional[str] = None) -> Any: + """Run one CLI command and decode its JSON result. + + ``request`` is JSON encoded onto stdin; ``raw_stdin`` passes text as is (a + serialized module). Progress events the CLI prints on stderr, one JSON + object naming its ``event`` per line, are returned under ``progress`` when + the result is an object. + """ + if raw_stdin is not None: + data = raw_stdin.encode() + else: + data = b"" if request is None else json.dumps(request, allow_nan=False).encode() + if len(data) > MAX_BUNDLE_BYTES: + raise ValueError("Tellegen input exceeds the 512 MiB Study limit") + timeout = _seconds("timeout_seconds", 1800) + grace = _seconds("cancel_grace_seconds", 300) + windows = sys.platform == "win32" + options = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if windows else {} + process = await asyncio.create_subprocess_exec( + *_command(arguments), stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, **options, + ) + communication = asyncio.create_task(process.communicate(data)) + try: + stdout, stderr = await asyncio.wait_for(asyncio.shield(communication), timeout=timeout) + except (asyncio.TimeoutError, asyncio.CancelledError) as stopped: + if process.returncode is None: + if windows: + try: + process.send_signal(signal.CTRL_BREAK_EVENT) + except OSError: + process.terminate() + else: + try: + process.terminate() + except ProcessLookupError: + pass + try: + await asyncio.wait_for(asyncio.shield(communication), timeout=grace) + except (asyncio.TimeoutError, asyncio.CancelledError): + if process.returncode is None: + process.kill() + await process.wait() + communication.cancel() + await asyncio.gather(communication, return_exceptions=True) + if isinstance(stopped, asyncio.CancelledError): + raise + raise RuntimeError( + "Tellegen execution timed out. Cancellation allows the current trial to " + "finish and saves completed planning evidence. Inspect the saved Study " + f"revision before retrying; a forced stop after {grace:g} seconds may " + "leave the previous revision. A remaining .lock requires checking that " + "its writer exited." + ) from None + if process.returncode: + raise RuntimeError(stderr.decode(errors="replace")[:2048] or "Tellegen failed without a diagnostic") + result = json.loads(stdout) + progress = [] + for line in stderr.decode(errors="replace").splitlines(): + line = line.strip() + if not line.startswith("{"): + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + # Only a JSON object naming its `event` is a progress event; any other + # line the CLI logged stays part of its stderr text. + if isinstance(event, dict) and "event" in event: + progress.append(event) + if progress and isinstance(result, dict): + result = {**result, "progress": progress} + return result + + +def _path(path: str, *, write: bool = False) -> str: + path = checked_path(path, purpose="Study bundle", for_write=write) + return str(path) + + +def _summary(value: dict[str, Any]) -> dict[str, Any]: + summary = dict(value.get("summary", value)) + goal = summary.get("active_goal") + if isinstance(goal, list) and len(goal) == 2: + summary["active_goal"] = {"id": goal[0], "request": goal[1]["request"], "anchor_state": goal[1]["anchor_state"]} + summary["recent_experiments"] = summary.get("recent_experiments", [])[:3] + if "experiment" in value: + summary["experiment"] = value["experiment"] + comparison = value.get("comparison") + if comparison: + summary["comparison"] = {key: comparison[key] for key in ("goal", "left", "right", "left_value", "right_value", "improvement")} + if "progress" in value: + summary["progress"] = value["progress"] + return summary + + +# ---- PowerIO IR hand-off ------------------------------------------------------- + +def _module_ir( + powerio_ir: str, + path: Optional[str], + source_format: Optional[str], + time_index: Optional[int], + scenario_id: Optional[str], +) -> tuple[str, dict[str, Any]]: + """Serialized generation-2 IR for one declared value, and the input side of the response. + + PowerIO parses a grid exchange ``path`` and serializes the module; an IR + document is deserialized so its identity is checked. A module PowerIO marks + with an error is refused here, before and after selection, exactly as the + balanced solver adapters refuse it. A collection entry is selected with + ``time_index`` or ``scenario_id`` and serialized on its own; an operating + point entry travels as the network it states. Tellegen accepts a balanced + network or a calculation instance and lowers nothing, so any other value is + named here rather than after a round trip through the native process. + + Returns the serialized module and the shared response tail the input + states: ``value_type``, the ``selection`` that reached it, ``diagnostics`` + and ``warnings``. + """ + import powerio + from powermcp.solver_case import ( + SolverCase, check_diagnostics, operating_point_module, select_entry, + diagnostic_messages, + ) + + if bool(powerio_ir) == (path is not None): + raise ValueError("provide exactly one of powerio_ir or path") + if path is not None: + path = checked_path(path, purpose="path") + if Path(path).is_dir(): + path = checked_read_tree(path, purpose="path") + module = powerio.parse(path, format=source_format) + else: + module = powerio.deserialize(io.StringIO(powerio_ir)) + check_diagnostics(module.diagnostics) + diagnostics = list(module.diagnostics) + selected, selection = select_entry(module, time_index, scenario_id) + check_diagnostics(selected.diagnostics) + if selected is not module: + diagnostics.extend(selected.diagnostics) + module = selected + if isinstance(module.value, powerio.OperatingPoint): + module = operating_point_module(module) + diagnostics.extend(module.diagnostics) + value = module.value + value_type = module.type_name + # The values the native CLI consumes: a balanced network becomes the default + # DC OPF instance, and every other kind is refused by name. + native = (powerio.BalancedNetwork, powerio.DcOpfInstance, powerio.AcPfInstance, powerio.AcOpfInstance) + if not isinstance(value, native): + raise ValueError( + "Tellegen tools take a balanced network or a calculation instance; lower a " + "multiconductor network with a powerio adapter's `to_balanced` first. " + f"This module states {value_type}." + ) + text = powerio.serialize(module).text + if text is None: + raise RuntimeError("PowerIO serialization returned no text") + network = value if isinstance(value, powerio.BalancedNetwork) else value.network + case = SolverCase( + module, network, + tuple(dict.fromkeys(diagnostic_messages(diagnostics))), + value_type=value_type, selection=selection, + diagnostics=tuple(powerio.diagnostic_records(diagnostics)), + ) + return text, case.response_fields() + + +def _counts(records: list[dict[str, Any]]) -> dict[str, int]: + counts: dict[str, int] = {} + for record in records: + counts[record["severity"]] = counts.get(record["severity"], 0) + 1 + return counts + + +def _module_summary(ir_text: str) -> dict[str, Any]: + """Value type, diagnostics and solved status of a serialized module Tellegen returned.""" + import powerio + from powermcp.solver_case import diagnostic_messages + + module = powerio.deserialize(io.StringIO(ir_text)) + records = powerio.diagnostic_records(module.diagnostics) + value_type = module.type_name + summary: dict[str, Any] = { + "value_type": value_type, + "diagnostics": records, + "diagnostics_counts": _counts(records), + "warnings": list(dict.fromkeys(diagnostic_messages(module.diagnostics))), + } + # powerio 0.11 exposes no solution fields on the Python value; the stored + # IR of a solution states the solver's termination and objective itself. + data = json.loads(ir_text).get("value", {}).get("data") + if isinstance(data, dict): + termination = data.get("termination") + if isinstance(termination, dict): + termination = termination.get("kind") + if isinstance(termination, str): + summary["termination"] = termination + objective = data.get("objective") + if isinstance(objective, (int, float)) and not isinstance(objective, bool): + summary["objective"] = float(objective) + return summary + + +def _over_input(tail: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]: + """What the returned module states, over the input side it was solved from.""" + diagnostics = [*tail["diagnostics"], *summary["diagnostics"]] + return { + **tail, **summary, + "diagnostics": diagnostics, + "diagnostics_counts": _counts(diagnostics), + "warnings": list(dict.fromkeys([*tail["warnings"], *summary["warnings"]])), + } + + +def _bounded(payload: Any, max_elements: int) -> Any: + """Truncate every array longer than ``max_elements`` to a counted head.""" + if isinstance(payload, list): + if len(payload) > max_elements: + return {"truncated": True, "count": len(payload), "head": [_bounded(item, max_elements) for item in payload[:max_elements]]} + return [_bounded(item, max_elements) for item in payload] + if isinstance(payload, dict): + return {key: _bounded(value, max_elements) for key, value in payload.items()} + return payload + + +def _json_argument(text: str, name: str, expected: type) -> Any: + if not text: + return expected() + try: + value = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"{name} must be JSON: {exc}") from exc + if not isinstance(value, expected): + raise ValueError(f"{name} must be a JSON {expected.__name__}") + return value + + +def _out_path(out_path: Optional[str], overwrite: bool) -> Optional[str]: + """Check a destination before any native process runs.""" + if out_path is None: + return None + out_path = checked_path(out_path, purpose="out_path", for_write=True) + if not overwrite and Path(out_path).exists(): + raise ValueError(f"{out_path} exists; pass overwrite=True to replace it") + return str(out_path) + + +def _deliver(ir_text: str, checked_out_path: Optional[str], overwrite: bool) -> dict[str, Any]: + """Write a module to an already checked path through staging, or return it inline.""" + if checked_out_path is None: + return {"powerio_ir": ir_text} + + def write(staging: str) -> dict[str, Any]: + Path(staging).write_text(ir_text, encoding="utf-8") + return {"path": staging} + + return staged_file_write(checked_out_path, overwrite, write) + + +# ---- tools -------------------------------------------------------------------- + +@mcp.tool() +async def capabilities() -> dict[str, Any]: + """The installed Tellegen build's formulation and operand support matrix, and its CLI path.""" + return {"binary": _binary(), "capabilities": await _call(["capabilities"])} + + +@mcp.tool() +async def solve( + powerio_ir: str = "", + path: Optional[str] = None, + source_format: Optional[str] = None, + time_index: Optional[int] = None, + scenario_id: Optional[str] = None, + formulation: str = "dcopf", + edits: str = "", + sensitivities: str = "", + max_elements: int = DEFAULT_MAX_ELEMENTS, +) -> dict[str, Any]: + """Solve one PowerIO network with Tellegen: DC power flow, DC OPF (prices, dispatch, flows), AC power flow, or the SOCWR relaxation. + + Provide serialized PowerIO IR or a grid exchange path (PowerIO parses it here). + Select a collection entry with time_index or scenario_id. `edits` is the + Tellegen request's `edits` object (`{"deltas": {...}, "rates": {...}}`) and + `sensitivities` its list; both are optional JSON. Arrays longer than + max_elements come back as `{"truncated": true, "count": n, "head": [...]}`. + """ + if formulation not in FORMULATIONS: + raise ValueError(f"formulation must be one of {list(FORMULATIONS)}") + if max_elements < 1: + raise ValueError("max_elements must be positive") + ir_text, tail = _module_ir(powerio_ir, path, source_format, time_index, scenario_id) + request = {"formulation": formulation} + request_edits = _json_argument(edits, "edits", dict) + request_sens = _json_argument(sensitivities, "sensitivities", list) + if request_edits: + request["edits"] = request_edits + if request_sens: + request["sensitivities"] = request_sens + response = await _call([json.dumps(request)], raw_stdin=ir_text) + return {"formulation": formulation, **tail, "response": _bounded(response, max_elements)} + + +@mcp.tool() +async def solve_module( + powerio_ir: str = "", + path: Optional[str] = None, + source_format: Optional[str] = None, + time_index: Optional[int] = None, + scenario_id: Optional[str] = None, + out_path: Optional[str] = None, + overwrite: bool = False, +) -> dict[str, Any]: + """Solve a stored module's DC OPF instance (a BalancedNetwork becomes the default instance) and return the powerio.DcOpfSolution module as PowerIO IR, written to out_path when given.""" + destination = _out_path(out_path, overwrite) + ir_text, tail = _module_ir(powerio_ir, path, source_format, time_index, scenario_id) + solution = await _call(["solve-module"], raw_stdin=ir_text) + solution_text = json.dumps(solution) + return {**_over_input(tail, _module_summary(solution_text)), **_deliver(solution_text, destination, overwrite)} + + +@mcp.tool() +async def plan( + spec: str, + powerio_ir: str = "", + path: Optional[str] = None, + source_format: Optional[str] = None, + time_index: Optional[int] = None, + scenario_id: Optional[str] = None, + out_path: Optional[str] = None, + overwrite: bool = False, + max_elements: int = DEFAULT_MAX_ELEMENTS, +) -> dict[str, Any]: + """Run Tellegen's bounded capacity planning search for a network and a CapacityPlanSpec (JSON; read `contract` for its schema). Returns the proposal and the exact proposed solution module.""" + destination = _out_path(out_path, overwrite) + specification = _json_argument(spec, "spec", dict) + if not specification: + raise ValueError("spec must be a CapacityPlanSpec object") + ir_text, tail = _module_ir(powerio_ir, path, source_format, time_index, scenario_id) + response = await _call(["plan"], {"module": json.loads(ir_text), "spec": specification}) + solution = response.get("solution_module") + result: dict[str, Any] = {**tail, "plan": _bounded(response.get("plan"), max_elements)} + if solution is not None: + solution_text = json.dumps(solution) + result["solution"] = _module_summary(solution_text) + if destination is not None: + result.update(_deliver(solution_text, destination, overwrite)) + else: + result["solution_powerio_ir"] = solution_text + return result + + +@mcp.tool() +async def contract() -> dict[str, Any]: + """The installed CLI's versioned contract: Tellegen and PowerIO versions and the generated JSON Schemas for Studies, planning, and the PowerIO IR module.""" + return await _call(["contract"]) + + +@mcp.tool() +async def study_contract() -> dict[str, Any]: + """Read the installed native formulation capabilities and generated Study request schemas.""" + return await _call(["contract"]) + + +@mcp.tool() +async def study_create( + path: str, + request: dict[str, Any], + input_path: Optional[str] = None, + input_format: Optional[str] = None, + time_index: Optional[int] = None, + scenario_id: Optional[str] = None, +) -> dict[str, Any]: + """Create a durable Study from PowerIO IR and a declared goal using the native CreateStudy schema. `input_path` names a grid exchange file PowerIO parses into the request's `input` (and `base_input` when absent).""" + request = dict(request) + if input_path is not None: + ir_text, _ = _module_ir("", input_path, input_format, time_index, scenario_id) + request["input"] = ir_text + request.setdefault("base_input", ir_text) + checked = _path(path, write=True) + return _summary(await _call(["study", "create", checked], request)) + + +@mcp.tool() +async def study_inspect(path: str, section: str = "summary", record_id: str | None = None, + offset: int = 0, expected_revision: int | None = None) -> dict[str, Any]: + """Inspect a saved Study or read bounded JSON fragments of a goal, state history, experiment or evidence.""" + checked = _path(path) + if section == "summary": + summary = await _call(["study", "inspect", checked]) + if expected_revision is not None and summary["revision"] != expected_revision: + raise ValueError("Study revision changed; restart the inspection") + return _summary(summary) + if offset < 0: + raise ValueError("offset must be nonnegative") + bundle = await _call(["study", "export", checked]) + document = bundle["document"] + if expected_revision is not None and document["revision"] != expected_revision: + raise ValueError("Study revision changed; restart the inspection") + if section == "goal": + record = document["goals"][record_id or document["active_goal"]] + elif section == "states": + record = document["states"] + elif section == "experiment": + record = document["experiments"][record_id] + elif section == "evidence": + artifact = bundle["artifacts"][record_id] + if artifact["kind"] != "evidence": + raise ValueError("Requested artifact is not evidence") + record = artifact["text"] + else: + raise ValueError("section must be summary, goal, states, experiment or evidence") + encoded = json.dumps(record) + fragment = encoded[offset:offset + 8192] + following = offset + len(fragment) + return {"id": document["id"], "revision": document["revision"], "encoding": "json", + "offset": offset, "fragment": fragment, "next_offset": following if following < len(encoded) else None} + + +@mcp.tool() +async def study_run(path: str, expected_revision: int, operation: dict[str, Any]) -> dict[str, Any]: + """Inspect, branch, revise a goal, compare, adjust demand, restore the base case, propose interventions or attach evidence using the native StudyOperation schema. Proposals stay unapplied; application requires an explicit native CLI user action.""" + if operation.get("kind") not in OPERATIONS: + raise ValueError("Unsupported agent operation. Apply the reviewed proposal through an explicit native CLI user action.") + checked = _path(path, write=True) + return _summary(await _call(["study", "run", checked, "--progress"], + {"expected_revision": expected_revision, "operation": operation})) + + +@mcp.tool() +async def study_import(source_path: str, path: str) -> dict[str, Any]: + """Validate and import a portable Study bundle into a new destination without restoring approvals or executing imported instructions.""" + source = Path(_path(source_path)) + with source.open("rb") as stream: + data = stream.read(MAX_BUNDLE_BYTES + 1) + if len(data) > MAX_BUNDLE_BYTES: + raise ValueError("Study bundle exceeds 512 MiB") + checked = _path(path, write=True) + return _summary(await _call(["study", "import", checked], json.loads(data))) + + +@mcp.tool() +async def study_export(path: str) -> dict[str, Any]: + """Validate the saved portable bundle and return its path and digest for transfer to another agent or browser.""" + checked = _path(path) + bundle = await _call(["study", "export", checked]) + data = Path(checked).read_bytes() + current = json.loads(data) + if current != bundle: + raise ValueError("Study changed during export; retry") + return {"path": checked, "id": bundle["document"]["id"], "revision": bundle["document"]["revision"], + "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data), "format": "tellegen-study"} + + +if __name__ == "__main__": + mcp.run() diff --git a/powermcp/wizard.py b/powermcp/wizard.py index e2df024..d994b34 100644 --- a/powermcp/wizard.py +++ b/powermcp/wizard.py @@ -1,7 +1,7 @@ """Interactive install wizard for `powermcp install`. -Flow: select tools (pandapower + PyPSA + PowerIO pre-checked) → capture local software -paths for closed-source tools → pip-install the chosen extras → write the +Flow: select tools (pandapower + PyPSA + PowerIO pre-checked), capture local +software paths for closed-source tools, pip-install the chosen extras, write the selected MCP client configs. Windows-only tools are hidden off Windows, and surge is hidden on Python versions it doesn't support. """ @@ -55,7 +55,7 @@ def _is_installed_or_configured(t: Tool) -> bool: """Whether the tool is already set up on this machine. Tools that need a local software path (PSS/E, PSLF, PowerFactory, LTSpice, - HOPE) count as set up only when those required paths are configured — not + HOPE) count as set up only when those required paths are configured, not merely because some Python dep is importable (e.g. PyYAML being present must not make HOPE look ready). Tools with no required path count as set up when their dependency is importable. @@ -128,7 +128,7 @@ def run_wizard( client_names = _parse_clients(clients) selected = _resolve_selection(yes=yes, tools=tools, select_all=select_all, client_names=client_names) if not selected: - console.print("[yellow]No tools selected — nothing to do.[/]") + console.print("[yellow]No tools selected, nothing to do.[/]") return console.print("[bold]Selected:[/] " + ", ".join(t.name for t in selected)) if not yes and not dry_run: # --dry-run writes nothing, including config.toml @@ -186,7 +186,7 @@ def _resolve_selection( def _interactive_select(client_names: list[str]) -> list[Tool]: if not _tty(): console.print( - "[yellow]No interactive terminal detected — defaulting to the core tools " + "[yellow]No interactive terminal detected; defaulting to the core tools " "(pandapower, PyPSA, PowerIO). Re-run with `--tools ` or `--all` to choose more.[/]" ) return [TOOLS[n] for n in CORE] @@ -207,7 +207,7 @@ def _interactive_select(client_names: list[str]) -> list[Tool]: picked = questionary.checkbox( "Select power-system tools to install:", choices=choices, - instruction="(↑/↓ move · SPACE toggles a tool · ENTER confirms — pandapower, PyPSA & PowerIO are preselected)", + instruction="(up/down moves, SPACE toggles a tool, ENTER confirms; pandapower, PyPSA and PowerIO are preselected)", ).ask() except Exception as exc: console.print( @@ -267,7 +267,7 @@ def _capture_paths(selected: list[Tool]) -> None: if not answer: if ck.required: console.print( - f"[yellow]{t.name}.{ck.key} left unset — {t.display} will report an " + f"[yellow]{t.name}.{ck.key} left unset; {t.display} will report an " f"actionable error until it is configured.[/]" ) continue @@ -283,7 +283,7 @@ def _capture_paths(selected: list[Tool]) -> None: f"'{path}' does not exist as a {ck.validate}. Save anyway?", default=False ).ask() except Exception: - keep = True # can't prompt — keep the path the user explicitly typed + keep = True # cannot prompt; keep the path the user explicitly typed if not keep: continue data.setdefault(t.name, {})[ck.key] = str(path) @@ -301,7 +301,7 @@ def _pip_install(selected: list[Tool], *, assume_yes: bool = False, dry_run: boo if assume_yes: proceed = True elif not _tty(): - console.print(f"[yellow]Non-interactive — skipping dependency install. Run:[/] pip install {spec}") + console.print(f"[yellow]Non-interactive; skipping dependency install. Run:[/] pip install {spec}") return else: import questionary @@ -333,7 +333,7 @@ def _write_clients(selected: list[Tool], client_names: list[str], dry_run: bool) except cfg.ConfigError: console.print( f"[yellow]Note:[/] {t.display} is configured for your MCP client(s) but " - f"{t.name}.{ck.key} is not set yet — set it with `powermcp config set {t.name}.{ck.key} `." + f"{t.name}.{ck.key} is not set yet; set it with `powermcp config set {t.name}.{ck.key} `." ) tool_names = [t.name for t in selected] results = configure(client_names, tool_names, dry_run=dry_run) diff --git a/pyproject.toml b/pyproject.toml index 7d2a83c..47bb5e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "hatchling.build" [project] name = "powermcp" -version = "0.3.0" +version = "0.4.0" description = "MCP servers for power-system software (PowerWorld, OpenDSS, PSS/E, pandapower, PyPSA, and more)" readme = "README.md" license = { file = "LICENSE" } @@ -44,12 +44,12 @@ dependencies = [ # imports the latter, so the floor tracks that, not just powerio's own # [mcp] extra requirement (mcp>=2,<3). "mcp>=2,<3", - # powerio supplies the conversion server, package model, native solver + # powerio supplies the conversion server, module model, native solver # writers, shared JSON transports, and path policy used by the bridges. - # PowerMCP uses APIs introduced in powerio 0.9.0. + # PowerMCP uses the typed PowerIO 0.11.3 module APIs. # powerio ships the server itself (`python -m powerio.mcp`), so this repo # bundles no copy of it and `powermcp run powerio` runs powerio's. - "powerio[mcp,matrix]>=0.9.0,<1", + "powerio[mcp,matrix]>=0.11.3,<0.12", # CLI / installer toolkit: # packaging: the doctor compares an installed version against the floor # declared right here, so it has to read a version specifier. @@ -64,6 +64,7 @@ dependencies = [ [project.optional-dependencies] # --- open-source tool engines --- +tellegen = [] # The native CLI installs separately. andes = ["andes"] egret = ["gridx-egret", "pyomo"] # PyPI dist is `gridx-egret`; imports as `egret` opendss = ["py_dss_toolkit"] @@ -103,7 +104,7 @@ powerfactory = ["numpy>=1.26", "matplotlib>=3.8", "pandas>=1.5"] # vendor `powe # powermcp[plexosdb]` until that's resolved upstream. plexosdb = ["r2x-plexos>=0.3.0", "r2x-plexos-to-sienna>=0.1.0", "r2x-sienna>=0.4.0"] # --- convenience groups --- -opensource = ["powermcp[andes]", "powermcp[egret]", "powermcp[opendss]", "powermcp[ltspice]", "powermcp[surge]", "powermcp[hope]", "powermcp[genx]"] +opensource = ["powermcp[tellegen]", "powermcp[andes]", "powermcp[egret]", "powermcp[opendss]", "powermcp[ltspice]", "powermcp[surge]", "powermcp[hope]", "powermcp[genx]"] 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]", "powermcp[plexosdb]"] @@ -152,4 +153,5 @@ include = [ exclude = [ "**/__pycache__", "**/*.py[cod]", "**/.pytest_cache", "PSCAD/tests", "HOPE/tests", "PLEXOSDB/tests", + ".venv*", "**/.venv*", ".uv-build-cache", "**/.uv-build-cache", ] diff --git a/surge/requirements.txt b/surge/requirements.txt index 5c61b45..49ba79b 100644 --- a/surge/requirements.txt +++ b/surge/requirements.txt @@ -1,3 +1,3 @@ surge-py>=0.1.5 mcp>=2,<3 -powerio[mcp,matrix]>=0.9.0,<1 +powerio[mcp,matrix]>=0.11.3,<0.12 diff --git a/surge/surge_mcp.py b/surge/surge_mcp.py index c96ccee..af4d5a9 100644 --- a/surge/surge_mcp.py +++ b/surge/surge_mcp.py @@ -933,6 +933,7 @@ def export_tables(output_dir: str) -> Dict[str, Any]: net = _require_network() def write_tables(staging: str) -> Dict[str, Any]: + os.makedirs(staging) written: List[str] = [] rows: Dict[str, int] = {} for fname, accessor in ( diff --git a/tests/conftest.py b/tests/conftest.py index 9b31aaa..0ed37e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,40 @@ from __future__ import annotations +import os import sys +import tempfile +from pathlib import Path import pytest +from powermcp.sandbox import ALLOWED_ROOTS_ENV, LEGACY_ROOT_ENVS + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture(scope="session", autouse=True) +def mcp_allowed_roots(tmp_path_factory): + """Name the roots the suite writes to, for the whole session. + + powerio confines MCP paths to the directory the process started in when no + root variable names one, so a test writing under pytest's temporary tree is + refused unless that tree is named. Roots are resolved through ``realpath`` + because the policy compares real targets, and ``/tmp`` is a symlink on + macOS while ``%TEMP%`` can carry a short name on Windows. A test that sets + its own roots overrides this with ``monkeypatch``. + """ + roots = [ + str(REPO_ROOT), + os.path.realpath(tmp_path_factory.getbasetemp()), + os.path.realpath(tempfile.gettempdir()), + ] + with pytest.MonkeyPatch.context() as patch: + patch.setenv(ALLOWED_ROOTS_ENV, os.pathsep.join(dict.fromkeys(roots))) + for name in LEGACY_ROOT_ENVS: + patch.delenv(name, raising=False) + yield + @pytest.fixture() def isolated_config(tmp_path, monkeypatch): diff --git a/tests/data/README.md b/tests/data/README.md new file mode 100644 index 0000000..b4e1e58 --- /dev/null +++ b/tests/data/README.md @@ -0,0 +1,13 @@ +# Test fixtures + +- `case9.m`: the MATPOWER 9 bus case (BSD 3-Clause, PSERC and contributors). +- `powerworld/ACTIVSg200.pwd`: the PowerWorld display fixture the powerio + tests decode; see `.gitignore` for why it stays tracked. +- `opendss/fourwire_linecode.dss`: an original four wire OpenDSS feeder from the + powerio test suite (`tests/data/dist/micro`, CC BY 4.0), used to exercise the + explicit multiconductor to balanced transformation at the solver boundary. +- `opendss/geometry_unresolved.dss`: a four conductor feeder whose line geometry + carries no resolved impedance matrix, so a balanced transformation of it + fails with a diagnostic instead of a network. +- `fake_tellegen.py`: a stand-in for the compiled `tellegen` CLI that speaks + its JSON protocol, so the tellegen server tests run without a Rust toolchain. diff --git a/tests/data/fake_tellegen.py b/tests/data/fake_tellegen.py new file mode 100644 index 0000000..80bd335 --- /dev/null +++ b/tests/data/fake_tellegen.py @@ -0,0 +1,130 @@ +"""A stand-in for the compiled `tellegen` CLI, speaking its JSON protocol. + +The PowerMCP tellegen server runs a `.py` binary through the current +interpreter, so this file exercises every subprocess path in CI without a Rust +toolchain. It records what it received so tests can assert on the hand-off: +`FAKE_TELLEGEN_RECORD` names a file that gets the argv and stdin of each call. +`FAKE_TELLEGEN_SLEEP` makes the process block that many seconds (cancellation +tests) and `FAKE_TELLEGEN_ROWS` sizes the arrays of a solve response. +""" +import json +import os +import signal +import sys +import time + + +def _record(argv, stdin_text): + path = os.environ.get("FAKE_TELLEGEN_RECORD") + if path: + with open(path, "a", encoding="utf-8") as handle: + handle.write(json.dumps({"argv": argv, "stdin": stdin_text}) + "\n") + + +def _sleep(): + seconds = float(os.environ.get("FAKE_TELLEGEN_SLEEP", "0") or 0) + if seconds <= 0: + return + + def cancelled(*_): + print("tellegen: cancelled; saved the completed trials", file=sys.stderr) + sys.exit(1) + + signal.signal(signal.SIGTERM, cancelled) + signal.signal(signal.SIGINT, cancelled) + time.sleep(seconds) + + +def _module(stdin_text): + module = json.loads(stdin_text) + if module.get("schema") != "pio-ir" or module.get("version") != 2: + print("tellegen: electrical inputs require PowerIO IR generation 2", file=sys.stderr) + sys.exit(1) + return module + + +def _solution(module): + # The real CLI returns a powerio.DcOpfSolution module. The stand-in keeps + # the network value, which PowerIO still deserializes, and marks itself as + # the producer so a test can tell the round trip happened. + module = dict(module) + module["producer"] = {"name": "fake-tellegen", "version": "0.0.0"} + return module + + +def main(argv): + arg = argv[1] if len(argv) > 1 else "" + stdin_text = "" if arg in ("capabilities", "contract", "-h", "--help") else sys.stdin.read() + _record(argv[1:], stdin_text) + if arg == "capabilities": + print(json.dumps([{"formulation": "dcopf", "available": True}, {"formulation": "acopf", "available": False}])) + return 0 + if arg == "contract": + print(json.dumps({"contract": "tellegen.cli/1", "tellegen_version": "0.3.0-fake", "powerio_version": "0.11.2", "schemas": {}})) + return 0 + if arg == "boom": + print("tellegen: boom", file=sys.stderr) + return 1 + _sleep() + if arg == "solve-module": + # Tellegen accepts a network or a DC OPF instance and returns a solution module. + print(json.dumps(_solution(_module(stdin_text)))) + return 0 + if arg == "plan": + request = json.loads(stdin_text) + module = request["module"] + if module.get("schema") != "pio-ir": + print("tellegen: unreadable planning request", file=sys.stderr) + return 1 + print(json.dumps({"plan": {"spec": request["spec"], "trials": [{"objective": 1.0}]}, "solution_module": _solution(module)})) + return 0 + if arg == "study": + command, path = argv[2], argv[3] + if command == "create": + request = json.loads(stdin_text) + bundle = {"document": {"id": request.get("id", "s"), "revision": 1, "active_goal": "g", + "goals": {"g": {"request": request.get("request", ""), "anchor_state": "base"}}, + "states": {"base": {}}, "experiments": {}}, + "artifacts": {}, "input_has_ir": request.get("input", "").startswith("{")} + with open(path, "w", encoding="utf-8") as handle: + json.dump(bundle, handle) + print(json.dumps({"id": bundle["document"]["id"], "revision": 1, "active_goal": ["g", bundle["document"]["goals"]["g"]]})) + return 0 + with open(path, encoding="utf-8") as handle: + bundle = json.load(handle) + if command == "inspect": + print(json.dumps({"id": bundle["document"]["id"], "revision": bundle["document"]["revision"]})) + return 0 + if command == "export": + print(json.dumps(bundle)) + return 0 + if command == "run": + request = json.loads(stdin_text) + if request["operation"].get("kind") == "apply": + with open(path + ".applied", "w", encoding="utf-8") as marker: + marker.write("applied") + if "--progress" in argv: + # A progress event names itself; the trial log line beside it is + # ordinary stderr text and must not be read as one. + print(json.dumps({"trial": 1, "objective": 1.0}), file=sys.stderr) + print(json.dumps({"event": "study_checkpoint", "index": 1}), file=sys.stderr) + bundle["document"]["revision"] += 1 + with open(path, "w", encoding="utf-8") as handle: + json.dump(bundle, handle) + print(json.dumps({"summary": {"id": bundle["document"]["id"], "revision": bundle["document"]["revision"]}, + "experiment": "e1"})) + return 0 + print("tellegen: unknown Study command", file=sys.stderr) + return 1 + # A solve request: the module on stdin, the request in argv. + request = json.loads(arg or "{}") + module = _module(stdin_text) + buses = module["value"]["data"].get("buses") or [] + rows = int(os.environ.get("FAKE_TELLEGEN_ROWS", str(len(buses)) if buses else "3")) + print(json.dumps({"formulation": request.get("formulation", "dcopf"), "status": "optimal", "objective": 1.0, + "request": request, "lmp": [{"id": i + 1, "value": 1.0} for i in range(rows)]})) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/data/opendss/fourwire_linecode.dss b/tests/data/opendss/fourwire_linecode.dss new file mode 100644 index 0000000..efd24ee --- /dev/null +++ b/tests/data/opendss/fourwire_linecode.dss @@ -0,0 +1,23 @@ +! Four wire line with an explicit neutral conductor (no Kron reduction). +! The neutral is grounded at the source bus (node 0) and carried as node 4 +! on the line; the wye loads return through it. +Clear +Set DefaultBaseFrequency=60 + +New Circuit.fourwire basekv=0.416 pu=1.0 phases=3 bus1=sourcebus MVAsc3=2000 MVAsc1=2100 + +New Linecode.lc4 nphases=4 basefreq=60 units=km +~ rmatrix = (0.211 | 0.049 0.211 | 0.049 0.049 0.211 | 0.049 0.049 0.049 0.211) +~ xmatrix = (0.747 | 0.673 0.747 | 0.651 0.673 0.747 | 0.673 0.651 0.673 0.747) +~ cmatrix = (10.0 | 0.0 10.0 | 0.0 0.0 10.0 | 0.0 0.0 0.0 10.0) +~ normamps=185 emergamps=240 + +New Line.l1 bus1=sourcebus.1.2.3.0 bus2=loadbus.1.2.3.4 phases=4 linecode=lc4 length=0.4 units=km + +New Load.la bus1=loadbus.1.4 phases=1 conn=wye kv=0.24 kw=8 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 +New Load.lb bus1=loadbus.2.4 phases=1 conn=wye kv=0.24 kw=6 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 +New Load.lc bus1=loadbus.3.4 phases=1 conn=wye kv=0.24 kw=10 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 + +Set VoltageBases=[0.416] +Calcvoltagebases +Solve diff --git a/tests/data/opendss/geometry_unresolved.dss b/tests/data/opendss/geometry_unresolved.dss new file mode 100644 index 0000000..ea31d22 --- /dev/null +++ b/tests/data/opendss/geometry_unresolved.dss @@ -0,0 +1,9 @@ +clear +new circuit.t basekv=4.16 phases=3 bus1=sourcebus +new wiredata.w rac=0.1859 gmr=0.0313 radius=0.4635 runits=mi gmrunits=ft radunits=in +new linegeometry.g nconds=4 nphases=3 reduce=no +~ cond=1 wire=w x=2.5 h=29 units=ft +~ cond=2 wire=w x=0 h=29 units=ft +~ cond=3 wire=w x=7 h=29 units=ft +~ cond=4 wire=w x=4 h=25 units=ft +new line.l bus1=sourcebus bus2=b geometry=g length=1 units=m diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 4906fdf..17ca74d 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -64,7 +64,7 @@ def test_namespace_shadow_not_false_positive(monkeypatch): # directory must not be mistaken for the installed library (PEP 420 namespace # shadow), so the dependency must report missing, not ok. Bypass surge's # Python-version gate (it is 3.12-3.14 only) so this exercises the probe path - # on every Python version — otherwise on 3.10/3.11 _dep_status short-circuits + # on every Python version; otherwise on 3.10/3.11 _dep_status short-circuits # to the "needs Python 3.12-3.14" warning before reaching the probe. monkeypatch.setattr(doctor, "_surge_supported", lambda: True) style, msg = doctor._dep_status(get_tool("surge")) @@ -73,7 +73,7 @@ def test_namespace_shadow_not_false_positive(monkeypatch): def test_tools_without_paths_show_dash(): style, msg = doctor._path_status(get_tool("pandapower")) - assert msg == "—" + assert msg == "-" def test_run_doctor_smoke(capsys): @@ -121,13 +121,14 @@ def test_the_floor_is_found_without_top_level_distribution_metadata(monkeypatch) monkeypatch.setattr( doctor, "requires", - lambda _distribution: ("powerio[mcp,matrix]>=0.9.0,<1",), + lambda _distribution: ("powerio[mcp,matrix]>=0.11.3,<0.12",), ) req = doctor._declared_requirement("powerio") assert req is not None assert doctor._canonical(req.name) == "powerio" - assert Version("0.9.0") in req.specifier - assert Version("1.0.0") not in req.specifier + assert Version("0.11.3") in req.specifier + assert Version("0.11.2") not in req.specifier + assert Version("0.12.0") not in req.specifier def test_an_out_of_date_dependency_under_another_name_is_caught(monkeypatch): @@ -169,7 +170,7 @@ def test_containment_status_reads_every_root_spelling(tmp_path, monkeypatch): for name in (ALLOWED_ROOTS_ENV,) + LEGACY_ROOT_ENVS: monkeypatch.delenv(name, raising=False) style, msg = doctor._containment_status() - assert style == "yellow" and "unconfined" in msg + assert style == "yellow" and "startup directory" in msg monkeypatch.setenv(ALLOWED_ROOTS_ENV, str(tmp_path)) style, msg = doctor._containment_status() @@ -190,3 +191,11 @@ def test_containment_status_reads_every_root_spelling(tmp_path, monkeypatch): assert style == "yellow" assert "every path is refused" not in msg assert str(tmp_path / "gone") in msg + + +def test_a_roots_variable_with_no_directory_is_refused(monkeypatch): + from powermcp.sandbox import ALLOWED_ROOTS_ENV + + monkeypatch.setenv(ALLOWED_ROOTS_ENV, " ") + style, msg = doctor._containment_status() + assert style == "red" and "at least one directory" in msg diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..77af04a --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,100 @@ +"""The shared tool result shape. + +Every bundled server reports through :mod:`powermcp.errors`, so a caller reads +``status`` once and gets the same answer from all of them. +""" + +from __future__ import annotations + +import logging + +import pytest + +from powermcp.errors import run_tool, tool_error, tool_success +from powermcp.sandbox import PathNotAllowed + + +@pytest.fixture() +def logger() -> logging.Logger: + return logging.getLogger("powermcp.tests.errors") + + +def test_tool_success_names_the_status(): + assert tool_success() == {"status": "success"} + + +def test_tool_success_carries_result_keys(): + result = tool_success(file_path="/tmp/out.png", rows=3) + assert result == {"status": "success", "file_path": "/tmp/out.png", "rows": 3} + + +def test_tool_error_names_the_status_and_the_message(): + assert tool_error("no such case") == {"status": "error", "message": "no such case"} + + +def test_tool_error_carries_the_keys_a_caller_reads_on_both_branches(): + result = tool_error("unknown plot_type", file_path=None) + assert result == {"status": "error", "message": "unknown plot_type", "file_path": None} + + +def test_run_tool_passes_a_successful_body_through(logger): + assert run_tool(lambda: tool_success(value=7), logger=logger) == { + "status": "success", + "value": 7, + } + + +@pytest.mark.parametrize("exception", [ValueError, PathNotAllowed]) +def test_an_argument_failure_keeps_its_own_message(logger, exception): + """The text of these two is written for the caller, so it passes through.""" + + def body() -> dict: + raise exception("`csv_path` is outside the allowed roots") + + assert run_tool(body, logger=logger) == { + "status": "error", + "message": "`csv_path` is outside the allowed roots", + } + + +def test_any_other_failure_is_named_by_type(logger): + def body() -> dict: + raise KeyError("Resource") + + result = run_tool(body, logger=logger) + assert result["status"] == "error" + assert result["message"] == "KeyError: 'Resource'" + + +def test_an_unexpected_failure_is_logged_with_its_traceback(logger, caplog): + def body() -> dict: + raise RuntimeError("sbatch is gone") + + with caplog.at_level(logging.ERROR, logger=logger.name): + run_tool(body, logger=logger) + + record = caplog.records[-1] + assert record.exc_info is not None + assert record.exc_info[0] is RuntimeError + + +def test_an_argument_failure_is_not_logged_as_an_incident(logger, caplog): + """A rejected argument is an answer to the caller, not a server fault.""" + + def body() -> dict: + raise ValueError("period must be positive") + + with caplog.at_level(logging.ERROR, logger=logger.name): + run_tool(body, logger=logger) + + assert caplog.records == [] + + +def test_a_keyboard_interrupt_is_not_turned_into_a_result(logger): + """Only failures of the tool become results; a shutdown signal propagates.""" + + def body() -> dict: + raise KeyboardInterrupt + + with pytest.raises(KeyboardInterrupt): + run_tool(body, logger=logger) diff --git a/tests/test_genx_server.py b/tests/test_genx_server.py index 6f1937c..58d38da 100644 --- a/tests/test_genx_server.py +++ b/tests/test_genx_server.py @@ -3,7 +3,7 @@ GenX itself is Julia (GenX.jl) and a SLURM cluster, neither of which CI has. What is testable without them is everything that decides *what gets run*: the SLURM script the server generates, the configuration resolution, the capacity -CSV analysis, and the error shape the tools hand back. That is also where the +CSV analysis, and the result shape the tools hand back. That is also where the risk lives -- the generated script is piped to `sbatch` and executes on the cluster under the user's own account. """ @@ -186,6 +186,28 @@ def test_submit_reports_a_missing_sbatch_instead_of_hanging(slurm, tmp_path, mon slurm.submit_case(str(case), 4, 32, case_name="ok") +def test_preview_reports_the_script_through_the_shared_shape(genx_server, tmp_path): + case = _make_case(tmp_path) + + result = genx_server.preview_genx_case(str(case), 4, 32, case_name="ok_name") + assert result["status"] == "success" + assert "--job-name=ok_name" in result["script"] + + +def test_an_engine_failure_reaches_the_caller_as_a_result(genx_server, tmp_path, monkeypatch): + """A RuntimeError from sbatch is reported, not raised at the MCP layer. + + Raising out of the tool would reach the caller as a protocol error, which + carries neither the status key nor a message a model can act on. + """ + case = _make_case(tmp_path) + monkeypatch.setenv("PATH", str(tmp_path / "empty-bin")) + + result = genx_server.submit_genx_case(str(case), 4, 32, case_name="ok_name") + assert result["status"] == "error" + assert "sbatch was not found" in result["message"] + + # --------------------------------------------------------------------------- # Capacity CSV analysis # --------------------------------------------------------------------------- @@ -198,7 +220,7 @@ def test_summarize_capacity_returns_json_serializable_data(genx_server, tmp_path csv.write_text(CAPACITY_CSV) result = genx_server.summarize_capacity(str(csv)) - assert result["success"] is True + assert result["status"] == "success" assert isinstance(result, dict) json.dumps(result) # would raise on a DataFrame @@ -211,19 +233,21 @@ def test_check_capacity_setting_detects_brownfield(genx_server, tmp_path): csv.write_text(CAPACITY_CSV) result = genx_server.check_capacity_setting(str(csv)) - assert result["success"] is True + assert result["status"] == "success" assert result["is_brownfield"] is True assert result["setting"] == "brownfield" def test_missing_required_column_is_reported_not_walked_into(genx_server, tmp_path): - """A missing column used to be printed to stdout -- into the JSON-RPC - stream -- and then hit as a KeyError two lines later.""" + """A capacity.csv without a Resource column is named, not walked into. + + Nothing reaches stdout: that is the JSON-RPC channel for a stdio server. + """ csv = tmp_path / "capacity.csv" csv.write_text("Zone,StartCap,RetCap,NewCap,EndCap\n1,0,0,0,0\n") result = genx_server.summarize_capacity(str(csv)) - assert result["success"] is False + assert result["status"] == "error" assert "Resource" in result["message"] @@ -236,7 +260,7 @@ def test_tools_return_the_error_shape_rather_than_raising(genx_server, tmp_path) genx_server.summarize_capacity(missing), genx_server.plot_capacity(missing, str(tmp_path), "EndCap", "s", "1"), ): - assert result["success"] is False + assert result["status"] == "error" assert isinstance(result["message"], str) and result["message"] @@ -245,7 +269,7 @@ def test_invalid_zone_is_rejected_with_the_available_ones(genx_server, tmp_path) csv.write_text(CAPACITY_CSV) result = genx_server.summarize_capacity(str(csv), zones=[99]) - assert result["success"] is False + assert result["status"] == "error" assert "99" in result["message"] @@ -257,7 +281,7 @@ def test_plot_capacity_writes_a_png(genx_server, tmp_path): result = genx_server.plot_capacity( str(csv), str(out), "EndCap", "Baseline", "1" ) - assert result["success"] is True, result + assert result["status"] == "success", result assert (out / "EndCap.png").is_file() @@ -266,14 +290,17 @@ def test_plot_capacity_rejects_an_unknown_plot_type(genx_server, tmp_path): csv.write_text(CAPACITY_CSV) result = genx_server.plot_capacity(str(csv), str(tmp_path), "Bogus", "s", "1") - assert result["success"] is False + assert result["status"] == "error" assert "Bogus" in result["message"] assert result["file_path"] is None def test_greenfield_early_return_keeps_the_shared_shape(genx_server, tmp_path): - """The greenfield branch used to omit file_path, so a caller reading it - after checking success hit a KeyError on that branch alone.""" + """The greenfield branch carries file_path like every other branch. + + A caller that reads file_path after checking status finds it whichever way + the tool went. + """ csv = tmp_path / "capacity.csv" csv.write_text( "Resource,Zone,StartCap,RetCap,NewCap,EndCap\n" @@ -281,7 +308,7 @@ def test_greenfield_early_return_keeps_the_shared_shape(genx_server, tmp_path): ) result = genx_server.plot_capacity(str(csv), str(tmp_path), "StartCap", "s", "1") - assert result["success"] is False + assert result["status"] == "error" assert result["setting"] == "greenfield" assert result["file_path"] is None @@ -300,5 +327,5 @@ def test_paths_outside_the_allowed_roots_are_refused(genx_server, tmp_path, monk monkeypatch.setenv("POWERIO_MCP_ALLOWED_ROOTS", str(allowed)) result = genx_server.summarize_capacity(str(outside / "capacity.csv")) - assert result["success"] is False + assert result["status"] == "error" assert "csv_path" in result["message"] diff --git a/tests/test_ltspice_engines.py b/tests/test_ltspice_engines.py new file mode 100644 index 0000000..7fc81f1 --- /dev/null +++ b/tests/test_ltspice_engines.py @@ -0,0 +1,92 @@ +"""The LTSpice server starts without matplotlib or spicelib installed. + +Neither package is needed to create a netlist, run LTspice or read a log, and +the two tools that do need one say which one to install. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys + +import pytest + +from powermcp.registry import get_tool + +_ENTRY = get_tool("ltspice").resolve_entry_script() + + +@pytest.fixture() +def ltspice(monkeypatch): + """The server module, imported with both engines unimportable. + + Binding a module name to None in sys.modules makes `import name` raise + ImportError, which is what a machine without the package does. + """ + for name in ("matplotlib", "matplotlib.pyplot", "spicelib", "spicelib.raw", + "spicelib.raw.raw_read"): + monkeypatch.setitem(sys.modules, name, None) + + spec = importlib.util.spec_from_file_location("ltspice_mcp_under_test", str(_ENTRY)) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, "ltspice_mcp_under_test", module) + spec.loader.exec_module(module) + return module + + +def test_the_server_registers_its_tools_without_either_engine(ltspice): + names = {tool.name for tool in asyncio.run(ltspice.mcp.list_tools())} + assert "create_simulation_session" in names + assert "plot_specific_traces" in names + assert "list_available_traces" in names + + +def test_a_session_can_still_be_created_without_either_engine(ltspice, tmp_path, monkeypatch): + monkeypatch.setattr(ltspice, "_output_dir", lambda: str(tmp_path / "runs")) + + result = asyncio.run(ltspice.create_simulation_session("* title\n.end\n")) + assert result["status"] == "success" + assert result["netlist_content"] == "* title\n.end\n" + + +def test_reading_traces_without_spicelib_says_what_to_install(ltspice, tmp_path): + raw = tmp_path / "circuit.raw" + raw.write_bytes(b"") + + result = asyncio.run(ltspice.list_available_traces(str(raw))) + assert result["status"] == "error" + assert "PyLTSpice" in result["message"] + + +def test_plotting_without_spicelib_says_what_to_install(ltspice, tmp_path): + raw = tmp_path / "circuit.raw" + raw.write_bytes(b"") + + result = asyncio.run(ltspice.plot_specific_traces(str(raw), str(tmp_path), ["V(out)"])) + assert result["status"] == "error" + assert "PyLTSpice" in result["message"] + + +def test_plotting_without_matplotlib_says_what_to_install(ltspice, tmp_path, monkeypatch): + """With spicelib present, the missing matplotlib is the one named.""" + monkeypatch.setattr(ltspice, "_raw_reader", lambda: object) + raw = tmp_path / "circuit.raw" + raw.write_bytes(b"") + + result = asyncio.run(ltspice.plot_specific_traces(str(raw), str(tmp_path), ["V(out)"])) + assert result["status"] == "error" + assert "matplotlib" in result["message"] + + +def test_a_refused_path_is_reported_before_either_engine_is_wanted(ltspice, tmp_path, monkeypatch): + allowed = tmp_path / "allowed" + allowed.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "circuit.raw").write_bytes(b"") + monkeypatch.setenv("POWERIO_MCP_ALLOWED_ROOTS", str(allowed)) + + result = asyncio.run(ltspice.list_available_traces(str(outside / "circuit.raw"))) + assert result["status"] == "error" + assert "raw_file_path" in result["message"] diff --git a/tests/test_opendss_engine.py b/tests/test_opendss_engine.py new file mode 100644 index 0000000..0a82d9a --- /dev/null +++ b/tests/test_opendss_engine.py @@ -0,0 +1,122 @@ +"""The OpenDSS server starts without a working OpenDSS engine. + +Building the py_dss_interface DSS object loads the OpenDSS engine library. Doing +that at import took down the whole server, including its tool listing, on any +machine where the library is missing or refuses to load. The engine is built on +the first tool call instead, and a failure there is reported as a result. +""" + +from __future__ import annotations + +import sys +import types + +import pytest + +from powermcp.registry import get_tool + +OPENDSS_DIR = str(get_tool("opendss").resolve_server_dir()) + + +class _RefusingDSS: + def __init__(self) -> None: + raise OSError("libopendssc.so: cannot open shared object file") + + +def _fake_toolkit() -> types.SimpleNamespace: + """py_dss_toolkit's dss_tools, with the calls the configuration tools make.""" + return types.SimpleNamespace( + configuration=types.SimpleNamespace( + compile_dss=lambda path: None, + circuit_readiness=lambda: {"ready": True}, + ), + update_dss=lambda _dss: None, + ) + + +_SERVER_PACKAGES = ("core", "utils", "opendss_tools") + + +def _drop_server_modules() -> None: + """Forget the server's own modules so the next import rebinds the fakes.""" + for name in list(sys.modules): + root = name.split(".", 1)[0] + if root in _SERVER_PACKAGES: + del sys.modules[name] + + +@pytest.fixture() +def opendss(monkeypatch): + """The server's configuration tools, built on a DSS that refuses to load. + + The server's packages are named `core`, `utils` and `opendss_tools`, which + are generic enough to collide with anything else on sys.path, so both the + path entry and the imported modules are withdrawn afterwards. + """ + interface = types.ModuleType("py_dss_interface") + interface.DSS = _RefusingDSS + toolkit = types.ModuleType("py_dss_toolkit") + toolkit.dss_tools = _fake_toolkit() + monkeypatch.setitem(sys.modules, "py_dss_interface", interface) + monkeypatch.setitem(sys.modules, "py_dss_toolkit", toolkit) + + saved_path = list(sys.path) + saved_modules = dict(sys.modules) + sys.path.insert(0, OPENDSS_DIR) + _drop_server_modules() + try: + from core.server import create_mcp + import core.engine as engine + import opendss_tools.configuration as configuration + + engine._dss = None + yield types.SimpleNamespace( + create_mcp=create_mcp, configuration=configuration, engine=engine + ) + finally: + _drop_server_modules() + sys.modules.update( + {k: v for k, v in saved_modules.items() if k.split(".", 1)[0] in _SERVER_PACKAGES} + ) + sys.path[:] = saved_path + + +def test_the_server_builds_its_tools_without_a_working_engine(opendss): + """Building the server must not touch the engine.""" + server = opendss.create_mcp() + assert server is not None + + +def test_an_engine_that_refuses_to_load_is_reported_as_a_result(opendss, tmp_path): + case = tmp_path / "case.dss" + case.write_text("New Circuit.test\n") + + result = opendss.configuration.compile_opendss_file(str(case)) + assert result["status"] == "error" + assert "libopendssc.so" in result["message"] + + +def test_a_path_outside_the_allowed_roots_is_refused(opendss, tmp_path, monkeypatch): + allowed = tmp_path / "allowed" + allowed.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + case = outside / "case.dss" + case.write_text("New Circuit.test\n") + monkeypatch.setenv("POWERIO_MCP_ALLOWED_ROOTS", str(allowed)) + + result = opendss.configuration.compile_opendss_file(str(case)) + assert result["status"] == "error" + assert "dss_file" in result["message"] + + +def test_a_compile_that_works_reports_the_success_shape(opendss, tmp_path): + """With a DSS instance that builds, the tool reports through payload.""" + opendss.engine._dss = object() + case = tmp_path / "case.dss" + case.write_text("New Circuit.test\n") + + result = opendss.configuration.compile_opendss_file(str(case)) + assert result["status"] == "success" + assert result["payload"]["circuit_loaded"] is True + assert result["payload"]["circuit_readiness"] == {"ready": True} diff --git a/tests/test_plexosdb_results.py b/tests/test_plexosdb_results.py new file mode 100644 index 0000000..2fa8d98 --- /dev/null +++ b/tests/test_plexosdb_results.py @@ -0,0 +1,158 @@ +"""The PLEXOSDB tools report r2x failures rather than raising them. + +r2x is not installed in CI and needs a real PLEXOS XML study, so the r2x +packages the two tools import are substituted with fakes that raise. What is +under test is the shape the tool hands back, not the translation itself. +""" + +from __future__ import annotations + +import importlib.util +import sys +import types + +import pytest + +from powermcp.registry import get_tool + + +@pytest.fixture() +def plexosdb(monkeypatch): + """The connector module, built on a substituted upstream server.""" + from mcp.server.mcpserver import MCPServer + + upstream = types.ModuleType("plexosdb_mcp.server") + upstream.MCPServerState = type("MCPServerState", (), {}) + upstream.build_mcp_server = lambda: MCPServer("plexosdb") + upstream.main = lambda argv=None: None + + package = types.ModuleType("plexosdb_mcp") + package.__path__ = [] + package.server = upstream + + monkeypatch.setitem(sys.modules, "plexosdb_mcp", package) + monkeypatch.setitem(sys.modules, "plexosdb_mcp.server", upstream) + + path = get_tool("plexosdb").resolve_entry_script() + spec = importlib.util.spec_from_file_location("plexosdb_main_under_test", str(path)) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, "plexosdb_main_under_test", module) + spec.loader.exec_module(module) + return module + + +def _fake_r2x(monkeypatch, exception: Exception) -> None: + """Install r2x packages whose parser raises on use.""" + + class Parser: + @staticmethod + def from_context(context): + raise exception + + monkeypatch.setitem( + sys.modules, + "r2x_core", + types.SimpleNamespace(PluginContext=lambda **kwargs: object()), + ) + monkeypatch.setitem( + sys.modules, + "r2x_plexos", + types.SimpleNamespace( + PLEXOSConfig=lambda **kwargs: object(), PLEXOSParser=Parser + ), + ) + monkeypatch.setitem( + sys.modules, + "r2x_plexos_to_sienna", + types.SimpleNamespace( + PlexosToSiennaConfig=object, plexos_to_sienna=lambda *a, **k: None + ), + ) + monkeypatch.setitem( + sys.modules, + "r2x_sienna", + types.SimpleNamespace(SiennaExporter=object, SiennaExporterConfig=object), + ) + + +def test_an_r2x_failure_in_translate_is_reported_not_raised( + plexosdb, monkeypatch, tmp_path +): + """An r2x exception reaches the caller as a result, naming its type.""" + study = tmp_path / "Study.xml" + study.write_text("") + _fake_r2x(monkeypatch, KeyError("Horizon")) + + result = plexosdb.translate_to_sienna( + xml_path=str(study), + model_name="Base", + output_path=str(tmp_path / "system.json"), + ) + assert result["status"] == "error" + assert result["message"] == "KeyError: 'Horizon'" + + +def test_an_r2x_failure_in_compare_is_reported_not_raised( + plexosdb, monkeypatch, tmp_path +): + study = tmp_path / "Study.xml" + study.write_text("") + _fake_r2x(monkeypatch, RuntimeError("model 'Peak' was not found")) + + result = plexosdb.compare_solutions( + xml_path_a=str(study), + model_name_a="Base", + xml_path_b=str(study), + model_name_b="Peak", + ) + assert result["status"] == "error" + assert "model 'Peak' was not found" in result["message"] + + +@pytest.mark.parametrize("tool_name", ["translate_to_sienna", "compare_solutions"]) +def test_a_path_outside_the_allowed_roots_is_refused( + plexosdb, monkeypatch, tmp_path, tool_name +): + allowed = tmp_path / "allowed" + allowed.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "Study.xml").write_text("") + monkeypatch.setenv("POWERIO_MCP_ALLOWED_ROOTS", str(allowed)) + + arguments = { + "translate_to_sienna": { + "xml_path": str(outside / "Study.xml"), + "model_name": "Base", + "output_path": str(allowed / "system.json"), + }, + "compare_solutions": { + "xml_path_a": str(outside / "Study.xml"), + "model_name_a": "Base", + "xml_path_b": str(outside / "Study.xml"), + "model_name_b": "Peak", + }, + }[tool_name] + + result = getattr(plexosdb, tool_name)(**arguments) + assert result["status"] == "error" + assert "xml_path" in result["message"] + + +def test_a_refused_path_is_reported_before_r2x_is_imported( + plexosdb, monkeypatch, tmp_path +): + """Containment runs first, so no r2x package has to be importable.""" + allowed = tmp_path / "allowed" + allowed.mkdir() + monkeypatch.setenv("POWERIO_MCP_ALLOWED_ROOTS", str(allowed)) + for name in ("r2x_core", "r2x_plexos", "r2x_plexos_to_sienna", "r2x_sienna"): + monkeypatch.delitem(sys.modules, name, raising=False) + + result = plexosdb.translate_to_sienna( + xml_path=str(tmp_path / "outside.xml"), + model_name="Base", + output_path=str(allowed / "system.json"), + ) + assert result["status"] == "error" + assert "xml_path" in result["message"] diff --git a/tests/test_powerfactory_paths.py b/tests/test_powerfactory_paths.py index 81715de..506d6ea 100644 --- a/tests/test_powerfactory_paths.py +++ b/tests/test_powerfactory_paths.py @@ -52,13 +52,13 @@ def __init__(self, cfg): self.cfg = cfg def run_pipeline(self): - return {"success": True, "output_dir": self.cfg.output_dir} + return {"status": "success", "output_dir": self.cfg.output_dir} monkeypatch.setattr(server, "_load_modules", lambda: (Config, Agent)) monkeypatch.setattr(server, "_pf", lambda function, *args: function(*args)) result = json.loads(server.run_simulation(cfg_path=str(config))) - assert result["success"] is True + assert result["status"] == "success" assert result["output_dir"] == str(generated) assert generated.is_dir() diff --git a/tests/test_powerfactory_results.py b/tests/test_powerfactory_results.py new file mode 100644 index 0000000..c8a87d8 --- /dev/null +++ b/tests/test_powerfactory_results.py @@ -0,0 +1,121 @@ +"""PowerFactory tools report through the shared result shape. + +The tools return JSON strings rather than dicts, so the shape has to survive +serialization. PowerFactory itself is Windows-only vendor software; these tests +substitute the agent module, which is where every result originates. +""" + +from __future__ import annotations + +import builtins +import importlib +import json + +import pytest + + +@pytest.fixture() +def server(): + """The server module, with print() restored after import. + + The module redirects print() to stderr at import so that no tool output + reaches stdout, which is the JSON-RPC channel. + """ + original_print = builtins.print + try: + return importlib.import_module("PowerFactory.MCP_PowerFactory") + finally: + builtins.print = original_print + + +def test_an_agent_failure_becomes_the_error_shape(server, monkeypatch): + class Agent: + @staticmethod + def short_circuit(open_digsilent): + return False, "No study case is active" + + monkeypatch.setattr(server, "_load_modules", lambda: (None, Agent)) + monkeypatch.setattr(server, "_pf", lambda function, *args: function(*args)) + + result = json.loads(server.run_short_circuit()) + assert result == {"status": "error", "message": "No study case is active"} + + +def test_an_agent_success_becomes_the_success_shape(server, monkeypatch): + class Agent: + @staticmethod + def short_circuit(open_digsilent): + return True, "Short-circuit calculation OK" + + monkeypatch.setattr(server, "_load_modules", lambda: (None, Agent)) + monkeypatch.setattr(server, "_pf", lambda function, *args: function(*args)) + + result = json.loads(server.run_short_circuit()) + assert result == {"status": "success", "message": "Short-circuit calculation OK"} + + +def test_a_refused_deletion_keeps_its_own_key_on_the_error_branch(server, monkeypatch): + """`deleted` is documented on both branches, so it is present on both.""" + + class Agent: + @staticmethod + def delete_component(*args): + return False, "Component not found: Bus 99" + + monkeypatch.setattr(server, "_load_modules", lambda: (None, Agent)) + monkeypatch.setattr(server, "_pf", lambda function, *args: function(*args)) + + result = json.loads(server.delete_component("bus", "Bus 99", confirmation="yes")) + assert result["status"] == "error" + assert result["deleted"] is False + assert "Bus 99" in result["message"] + + +def test_a_missing_required_argument_is_reported_not_raised(server): + result = json.loads(server.import_project(file_path="")) + assert result == {"status": "error", "message": "file_path is required"} + + +def test_an_unsupported_component_category_names_the_supported_ones(server): + result = json.loads(server.list_components(component_type="flux capacitors")) + assert result["status"] == "error" + assert "flux capacitors" in result["message"] + assert "buses" in result["supported_component_types"] + + +def test_a_stopped_pipeline_carries_the_step_that_stopped_it(server, monkeypatch, tmp_path): + """run_pipeline's report is the tool result, so it carries the shape.""" + + class Config: + output_dir = str(tmp_path) + export_pfd = 0 + open_digsilent = 0 + + @classmethod + def from_json(cls, path): + return cls() + + class Agent: + def __init__(self, cfg): + self.cfg = cfg + + def run_pipeline(self): + return { + "connect": {"ok": False, "msg": "PowerFactory is not running"}, + "status": "error", + "message": ( + "Pipeline stopped at step 'connect': " + "PowerFactory is not running" + ), + } + + monkeypatch.setattr(server, "_load_modules", lambda: (Config, Agent)) + monkeypatch.setattr(server, "_pf", lambda function, *args: function(*args)) + + config = tmp_path / "config.json" + config.write_text("{}") + + result = json.loads(server.run_simulation(cfg_path=str(config))) + assert result["status"] == "error" + assert "connect" in result["message"] + assert result["connect"]["ok"] is False diff --git a/tests/test_powerio_server.py b/tests/test_powerio_server.py index d390b2f..28a157c 100644 --- a/tests/test_powerio_server.py +++ b/tests/test_powerio_server.py @@ -1,20 +1,4 @@ -"""Tests for the PowerIO conversion server, solver integrations, and the -registry/runner wiring. - -The server under test is powerio's own ``powerio.mcp.server``: this repo runs -that module and keeps no copy of it, so these tests are the consumer suite over -a dependency's surface. powerio is a core dependency, so it is normally present; -the importorskip below stays as insurance for stripped-down environments. -The decorated tools stay ordinary callables, so most cases exercise them -in-process; ``test_transport.py`` covers what only a real MCP transport shows. -The launch test lives here rather than in test_runner.py so it skips with the -rest of the module. - -tests/data/case9.m is vendored verbatim from -https://github.com/MATPOWER/matpower/tree/master/data (BSD-3). -tests/data/powerworld/ACTIVSg200.pwd is vendored from powerio's test suite -(eigenergy/powerio); ACTIVSg200 is a public Texas A&M synthetic grid. -""" +"""Consumer tests for PowerIO modules, solver adapters, and runner wiring.""" from __future__ import annotations @@ -29,7 +13,7 @@ import pytest -pytest.importorskip("powerio", minversion="0.9.0") +pytest.importorskip("powerio", minversion="0.11.3") import powerio # noqa: E402 from powerio.mcp import server as powerio_mcp # noqa: E402 @@ -68,305 +52,166 @@ """ -def test_parse_json_round_trips(): - r = powerio_mcp.parse(path=str(CASE9)) - assert r["schema"] == "powerio.parse" - assert r["powerio_version"] == powerio.__version__ - assert r["domain"] == "transmission" - assert r["model"] == "balanced" - assert r["json_format"] == "model-json" - assert r["source_format"] == "matpower" - assert isinstance(r["warnings"], list) - assert r["summary"]["elements"]["buses"] == 9 - assert powerio.from_json(r["json"]).n_buses == 9 +# 2-bus case whose pandapower emission raises no diagnostic, so the shared +# response tail carries an empty warnings list. +CLEAN_CASE = """function mpc = clean +mpc.version = '2'; +mpc.baseMVA = 100.0; +mpc.bus = [ +\t1 3 0 0 0 0 1 1.0 0.0 345.0 1 1.1 0.9; +\t2 1 50 10 0 0 1 1.0 0.0 345.0 1 1.1 0.9; +]; +mpc.gen = [ +\t1 60 10 0 0 1.0 100 1 100 0; +]; +mpc.branch = [ +\t1 2 0.01 0.1 0.0 250 250 250 0 0 1 -360 360; +]; +""" -def test_tool_surface_is_canonical(): +def test_parse_ir_round_trips(): + result = powerio_mcp.parse(path=str(CASE9)) + assert result["value_type"] == "powerio.BalancedNetwork" + assert result["summary"]["elements"]["buses"] == 9 + document = json.loads(result["powerio_ir"]) + assert (document["schema"], document["version"]) == ("pio-ir", 2) + assert powerio.deserialize(result["powerio_ir"].encode()).value.n_buses == 9 + + +def test_registered_tools_are_canonical(): tools = {tool.name: tool for tool in asyncio.run(powerio_mcp.mcp.list_tools())} - names = set(tools) - required_names = { - "convert", - "save", - "summary", - "parse", - "normalize", - "matrix", - "diagnostics", - "display", - } - assert required_names <= names - for name in ("parse", "summary", "normalize", "matrix", "display"): + assert {"parse", "emit", "summarize", "to_normalized", "calc_matrix", + "diagnostics", "display", "about", "to_balanced", "to_balanced_report"} <= tools.keys() + for name in ("summarize", "to_normalized", "calc_matrix"): props = tools[name].input_schema["properties"] - assert "from_format" in props - assert "format" not in props - parse_props = tools["parse"].input_schema["properties"] - assert "transport" in parse_props - convert_props = tools["convert"].input_schema["properties"] - assert "to_format" in convert_props and "from_format" in convert_props - assert "package_json" in convert_props - assert "to" not in convert_props and "format" not in convert_props - for name in ("summary", "normalize", "matrix"): - assert "package_json" in tools[name].input_schema["properties"] - save_schema = tools["save"].input_schema - assert save_schema["required"] == ["out_path"] - save_props = save_schema["properties"] - assert "to_format" in save_props and "from_format" in save_props - assert "package_json" in save_props - assert "to" not in save_props and "format" not in save_props + assert {"path", "content", "powerio_ir", "format"} <= props.keys() + props = tools["emit"].input_schema["properties"] + assert {"format", "destination", "overwrite", "source_format"} <= props.keys() def test_normalize_returns_dense_one_based_ids(): - r = powerio_mcp.normalize(path=str(CASE9)) - case = powerio.from_json(r["json"]) + result = powerio_mcp.to_normalized(path=str(CASE9)) + case = powerio.deserialize(result["powerio_ir"].encode()).value assert [b["id"] for b in case.buses] == list(range(1, 10)) -def test_parse_transport_accepted_downstream(): - r = powerio_mcp.parse(path=str(CASE9)) - assert powerio.from_json(r["json"]).n_buses == 9 - - -def test_matrix_bprime(): - m = powerio_mcp.matrix("bprime", path=str(CASE9)) - assert m["schema"] == "powerio.matrix" - assert m["powerio_version"] == powerio.__version__ - assert m["domain"] == "transmission" - assert m["model"] == "balanced" - assert m["json_format"] == "model-json" - assert m["source_format"] == "matpower" - assert isinstance(m["warnings"], list) - assert m["format"] == "coo" - assert m["shape"] == [9, 9] - assert m["nnz"] > 0 - assert isinstance(m["nnz"], int) - # plain Python scalars, not numpy types - assert type(m["data"][0]) is float - assert type(m["row"][0]) is int - assert type(m["col"][0]) is int - - -def test_matrix_accepts_json_transport(): - transport = powerio_mcp.parse(path=str(CASE9))["json"] - from_json = powerio_mcp.matrix("bprime", json=transport) - from_path = powerio_mcp.matrix("bprime", path=str(CASE9)) - assert from_json["shape"] == from_path["shape"] - assert from_json["nnz"] == from_path["nnz"] - - -def test_matrix_unknown_kind(): - with pytest.raises(ValueError): - powerio_mcp.matrix("nope", path=str(CASE9)) - - -def test_convert_powermodels(): - r = powerio_mcp.convert(to_format="powermodels-json", path=str(CASE9)) - assert isinstance(r["warnings"], list) - assert len(json.loads(r["text"])["bus"]) == 9 +@pytest.mark.parametrize("kind, shape", [("bprime", [9, 9]), ("lacpf", [18, 18]), ("weighted_laplacian", [9, 9])]) +def test_matrices_accept_paths_and_ir(kind, shape): + ir = powerio_mcp.parse(path=str(CASE9))["powerio_ir"] + direct = powerio_mcp.calc_matrix(kind, path=str(CASE9)) + restored = powerio_mcp.calc_matrix(kind, powerio_ir=ir) + assert direct["shape"] == restored["shape"] == shape + assert direct["data"] == restored["data"] + assert direct["row"] == restored["row"] + assert direct["col"] == restored["col"] + assert type(direct["data"][0]) is float + assert type(direct["row"][0]) is int + assert direct["nnz"] > 0 def test_summary_fields(): - s = powerio_mcp.summary(path=str(CASE9)) - assert s["schema"] == "powerio.summary" - assert s["powerio_version"] == powerio.__version__ - assert s["domain"] == "transmission" - assert s["model"] == "balanced" - assert s["json_format"] == "model-json" - assert isinstance(s["warnings"], list) - assert s["elements"]["buses"] == 9 - assert s["base_mva"] == 100.0 - assert s["source_format"] == "matpower" - assert s["topology"]["connected_components"] == 1 - assert s["elements"]["branches"] == 9 - assert s["topology"]["connectivity_report"] - - -def test_exactly_one_input_enforced(): - with pytest.raises(ValueError): - powerio_mcp.summary() - with pytest.raises(ValueError): - powerio_mcp.summary(path="x", content="y") - with pytest.raises(ValueError): - powerio_mcp.matrix("bprime") - with pytest.raises(ValueError): - powerio_mcp.matrix("bprime", path=str(CASE9), json="{}") - - -def test_inline_matpower_content_defaults_to_matpower(): - assert powerio_mcp.convert(to_format="psse", content=CASE9.read_text())["text"] - - -def test_matrix_lacpf(): - m = powerio_mcp.matrix("lacpf", path=str(CASE9)) - assert m["format"] == "coo" - assert m["shape"] == [18, 18] - assert m["nnz"] > 0 - assert type(m["data"][0]) is float - assert type(m["row"][0]) is int - - -def test_save_writes_file(tmp_path): + result = powerio_mcp.summarize(path=str(CASE9)) + assert result["domain"] == "transmission" + assert result["electrical_model"] == "balanced" + assert result["base_mva"] == 100.0 + assert result["source_format"] == "matpower" + assert result["elements"]["buses"] == result["elements"]["branches"] == 9 + assert result["topology"]["connected_components"] == 1 + assert result["topology"]["connectivity_report"] + assert isinstance(result["diagnostics"], list) + + +def test_input_and_matrix_validation(): + for kwargs in ({}, {"path": "x", "content": "y"}, {"powerio_ir": "{}"}): + with pytest.raises(ValueError): + powerio_mcp.summarize(**kwargs) + with pytest.raises(ValueError, match="unknown matrix"): + powerio_mcp.calc_matrix("nope", path=str(CASE9)) + + +def test_emission_and_atomic_overwrite(tmp_path): + ir = powerio_mcp.parse(path=str(CASE9))["powerio_ir"] out = tmp_path / "case9.json" - r = powerio_mcp.save( - to_format="powermodels-json", out_path=str(out), path=str(CASE9) - ) - assert r["path"] == str(out) - assert r["bytes_written"] == out.stat().st_size - assert isinstance(r["warnings"], list) + result = powerio_mcp.emit("powermodels-json", destination=str(out), powerio_ir=ir) + assert result["path"] == str(out) assert len(json.loads(out.read_text())["bus"]) == 9 - - -def test_save_refuses_overwrite(tmp_path): - out = tmp_path / "case9.m" - out.write_text("existing") + before = out.read_bytes() with pytest.raises(ValueError, match="overwrite"): - powerio_mcp.save(out_path=str(out), path=str(CASE9)) - r = powerio_mcp.save( - out_path=str(out), path=str(CASE9), overwrite=True - ) - assert r["bytes_written"] == out.stat().st_size - + powerio_mcp.emit("powermodels-json", destination=str(out), path=str(CASE9)) + assert out.read_bytes() == before + powerio_mcp.emit("powermodels-json", destination=str(out), path=str(CASE9), overwrite=True) + assert powerio.parse(out).value.n_buses == 9 -def test_save_accepts_json_transport(tmp_path): - transport = powerio_mcp.parse(path=str(CASE9))["json"] - out = tmp_path / "case9.m" - powerio_mcp.save(out_path=str(out), json=transport) - assert powerio.parse_file(out).n_buses == 9 - - -def test_package_transport_flows_through_core_tools(tmp_path): - parsed = powerio_mcp.parse(path=str(CASE9), transport="package") - assert parsed["schema"] == "powerio.parse" - assert parsed["transport"] == "package" - assert parsed["json_format"] == "package" - assert parsed["domain"] == "transmission" - assert parsed["model"] == "balanced" - assert "package_json" in parsed - - package = json.loads(parsed["package_json"]) - assert package["model_kind"] == "balanced" - assert package["model"]["kind"] == "balanced" - - package_json = parsed["package_json"] - assert powerio_mcp.summary(package_json=package_json)["elements"]["buses"] == 9 - - matrix = powerio_mcp.matrix("bprime", package_json=package_json) - assert matrix["kind"] == "bprime" - assert matrix["shape"] == [9, 9] - out = tmp_path / "case9.m" - powerio_mcp.save(out_path=str(out), package_json=package_json) - assert powerio.parse_file(out).n_buses == 9 - - diag = powerio_mcp.diagnostics(package_json) - assert diag["schema"] == "powerio.diagnostics" - assert diag["model_kind"] == "balanced" - assert diag["summary"]["status"] in {"ok", "info", "warning", "error", "fatal"} - assert isinstance(diag["summary"]["text"], str) - assert isinstance(diag["diagnostics"], list) +def test_ir_diagnostics_and_same_format_fidelity(): + result = powerio_mcp.parse(path=str(CASE9)) + diagnostics = powerio_mcp.diagnostics(result["powerio_ir"]) + assert diagnostics["summary"]["status"] == "ok" + assert isinstance(diagnostics["diagnostics"], list) + emitted = powerio_mcp.emit("matpower", content=CASE9.read_text(), source_format="matpower") + assert emitted["text"] == CASE9.read_text() -def test_pypsa_interchange_accepts_static_package(tmp_path): - package_json = powerio.Package.from_file(CASE9).to_json() - out = tmp_path / "case9-package.nc" - result = pypsa_mcp.import_case_from_json(package_json, str(out)) - +def test_pypsa_interchange_accepts_module(tmp_path): + ir = powerio.serialize(powerio.parse(CASE9)).text + out = tmp_path / "case9-module.nc" + result = pypsa_mcp.import_case_from_json(ir, str(out)) assert result["status"] == "success", result - assert result["package"]["model_kind"] == "balanced" - assert result["package"]["source_map_entries"] > 0 + assert result["package"]["schema"] == "pio-ir" assert len(pypsa.Network(str(out)).buses) == 9 -def test_pandapower_interchange_accepts_static_package(): +def test_pandapower_interchange_accepts_module(): panda_dir = str(TOOLS["pandapower"].resolve_server_dir()) if panda_dir not in sys.path: sys.path.insert(0, panda_dir) - import panda_mcp # noqa: E402 - - result = panda_mcp.load_network_from_json( - powerio.Package.from_file(CASE9).to_json() - ) - + import panda_mcp + result = panda_mcp.load_network_from_json(powerio.serialize(powerio.parse(CASE9)).text) assert result["status"] == "success", result - assert result["package"]["model_kind"] == "balanced" + assert result["package"]["schema"] == "pio-ir" assert len(panda_mcp._current_net.bus) == 9 -def test_solver_interchange_requires_explicit_package_state(tmp_path): - package = powerio.Package.from_file(CASE9) - package.set_operating_points( - { - "time_axis": {"periods": 1, "labels": ["dispatch"]}, - "points": [ - { - "index": 0, - "updates": [ - { - "element": { - "table": "generators", - "source_uid": "generators:0", - }, - "fields": {"pg": 123.0}, - } - ], - } - ], - } - ) +def test_pandapower_response_states_an_empty_warnings_list(): + """The shared response tail reaches the caller whole, empty lists included. - rejected = pypsa_mcp.import_case_from_json( - package.to_json(), str(tmp_path / "unselected.nc") - ) - assert rejected["status"] == "error" - assert "operating_point from [0]" in rejected["message"] + A caller reads ``warnings`` the same way from every adapter, so a load that + raised nothing states an empty list rather than dropping the key. + """ + panda_dir = str(TOOLS["pandapower"].resolve_server_dir()) + if panda_dir not in sys.path: + sys.path.insert(0, panda_dir) + import panda_mcp - out = tmp_path / "selected.nc" - selected = pypsa_mcp.import_case_from_json( - package.to_json(), str(out), operating_point=0 - ) - assert selected["status"] == "success", selected - assert selected["package"]["materialized"] == { - "kind": "operating_point", - "index": 0, - } - assert pypsa.Network(str(out)).generators.iloc[0].p_set == pytest.approx(123.0) - - -def test_solver_interchange_materializes_study_commit(tmp_path): - package = powerio.Package.from_file(CASE9) - document = json.loads(package.to_json()) - document["study"] = { - "label": "load study", - "commits": [ - { - "label": "add load", - "edits": [ - { - "kind": "demand_delta", - "bus": {"table": "buses", "source_uid": "buses:0"}, - "p_mw": 7.0, - "q_mvar": 3.0, - } - ], - } - ], - } + module = powerio.parse(CLEAN_CASE.encode(), format="matpower", name="clean.m") + result = panda_mcp.load_network_from_json(powerio.serialize(module).text) + assert result["status"] == "success", result + assert result["warnings"] == [] + assert result["diagnostics"] == [] + assert result["value_type"] == "powerio.BalancedNetwork" + assert result["selection"] == {} - out = tmp_path / "study.nc" - result = pypsa_mcp.import_case_from_json( - json.dumps(document), str(out), study_commit=0 - ) - assert result["status"] == "success", result - assert result["package"]["materialized"] == {"kind": "study_commit", "index": 0} - assert (pypsa.Network(str(out)).loads.p_set == 7.0).any() +def test_solver_interchange_requires_explicit_state(tmp_path): + value = powerio.parse(CASE9).value + series = powerio.TimeSeries([value, value], time_points=[powerio.TimePoint("base", duration_seconds=3600), powerio.TimePoint("later", duration_seconds=3600)]) + ir = powerio.serialize(powerio.PioModule.from_value(series)).text + rejected = pypsa_mcp.import_case_from_json(ir, str(tmp_path / "unselected.nc")) + assert rejected["status"] == "error" + assert "time_index" in rejected["message"] + selected = pypsa_mcp.import_case_from_json(ir, str(tmp_path / "selected.nc"), time_index=1) + assert selected["status"] == "success", selected + assert selected["package"]["selection"]["time_index"] == 1 -def test_save_exactly_one_input(tmp_path): - out = tmp_path / "x.m" - with pytest.raises(ValueError): - powerio_mcp.save(out_path=str(out)) - with pytest.raises(ValueError): - powerio_mcp.save(out_path=str(out), path="a", json="{}") +def test_solver_interchange_rejects_unavailable_study_commit(tmp_path): + ir = powerio.serialize(powerio.parse(CASE9)).text + out = tmp_path / "study.nc" + result = pypsa_mcp.import_case_from_json(ir, str(out), study_commit=0) + assert result["status"] == "error" + assert "Study" in result["message"] + assert not out.exists() def test_pypsa_import_case_from_any(tmp_path): @@ -380,7 +225,7 @@ def test_pypsa_import_case_from_any(tmp_path): def test_pypsa_import_case_from_json(tmp_path): - transport = powerio_mcp.parse(path=str(CASE9))["json"] + transport = powerio_mcp.parse(path=str(CASE9))["powerio_ir"] out = tmp_path / "case9.nc" r = pypsa_mcp.import_case_from_json(transport, str(out)) assert r["status"] == "success", r @@ -487,7 +332,11 @@ def test_pypsa_import_overwrite_zero_s_nom(tmp_path): def test_pypsa_import_missing_file(tmp_path): - r = pypsa_mcp.import_case_from_any("/nope/missing.m", str(tmp_path / "x.nc")) + # The absent case sits inside an allowed root, so the refusal states what is + # wrong with the file rather than where it is. + r = pypsa_mcp.import_case_from_any( + str(tmp_path / "missing.m"), str(tmp_path / "x.nc") + ) assert r["status"] == "error" assert "not found" in r["message"].lower() @@ -584,7 +433,7 @@ def test_registry_entry(): assert t.windows_only is False assert t.probe == "powerio" # The server ships in powerio's own wheel, so there is no bundled dir here - # and no local file enumerating powerio's tool surface. + # and no local file enumerating powerio's registered tools. assert t.run_kind == "package" assert t.module == "powerio.mcp" assert t.server_dir is None @@ -615,9 +464,9 @@ def test_launch_powerio_runs_once(record_mcp_run): assert transport == "stdio" -def test_inline_convert_stages_no_temp_files(monkeypatch): - # Inline conversion goes through powerio.convert_str entirely in memory; - # touching tempfile would be a regression to the old staging path. +def test_inline_emission_stages_no_temp_files(monkeypatch): + # `emit` writes an in-memory result when no destination names a file, so + # touching tempfile would mean it staged through disk. import tempfile def boom(*args, **kwargs): @@ -625,8 +474,8 @@ def boom(*args, **kwargs): monkeypatch.setattr(tempfile, "mkstemp", boom) monkeypatch.setattr(tempfile, "NamedTemporaryFile", boom) - r = powerio_mcp.convert( - to_format="psse", content=CASE9.read_text(), from_format="matpower" + r = powerio_mcp.emit( + format="psse", content=CASE9.read_text(), source_format="matpower" ) assert r["text"] @@ -712,31 +561,29 @@ def __reduce__(self): def test_matrix_laplacian(): - m = powerio_mcp.matrix("laplacian", path=str(CASE9)) + m = powerio_mcp.calc_matrix("weighted_laplacian", path=str(CASE9)) assert m["format"] == "coo" assert m["shape"] == [9, 9] def test_matrix_bad_json_raises_valueerror(): with pytest.raises(ValueError): - powerio_mcp.matrix("bprime", json="{not valid json") + powerio_mcp.calc_matrix("bprime", powerio_ir="{not valid json") -def test_convert_oserror_normalizes_to_valueerror(monkeypatch): - # An OSError from convert_str (e.g. disk full) must surface as ValueError, - # not leak as a raw OSError. - def boom(content, to, from_): +def test_emit_oserror_normalizes_to_valueerror(monkeypatch): + def boom(*args, **kwargs): raise OSError("disk full") + monkeypatch.setattr(powerio, "emit", boom) + with pytest.raises(ValueError, match="disk full"): + powerio_mcp.emit("psse", content=CASE9.read_text(), source_format="matpower") - monkeypatch.setattr(powerio, "convert_str", boom) - with pytest.raises(ValueError): - powerio_mcp.convert(to_format="psse", content="x", from_format="matpower") def test_allowed_roots_rejects_read_outside_root(tmp_path, monkeypatch): - # POWERIO_MCP_ALLOWED_ROOTS is unset for every other test in this file, so - # `_check_allowed_path` is a no-op there; this is the one place the - # containment check itself is exercised, on both the reject and admit side. + # The session fixture names the repository and the temporary tree for every + # other test in this file. This test names its own root, so both the refusal + # and the admission run against a directory the test controls. root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" / "case9.m" @@ -754,7 +601,7 @@ def test_allowed_roots_admits_read_inside_root(tmp_path, monkeypatch): case.write_text(CASE9.read_text()) monkeypatch.setenv("POWERIO_MCP_ALLOWED_ROOTS", str(root)) r = powerio_mcp.parse(path=str(case)) - assert r["schema"] == "powerio.parse" + assert r["value_type"] == "powerio.BalancedNetwork" def test_allowed_roots_rejects_write_outside_root(tmp_path, monkeypatch): @@ -764,8 +611,8 @@ def test_allowed_roots_rejects_write_outside_root(tmp_path, monkeypatch): outside_out.parent.mkdir() monkeypatch.setenv("POWERIO_MCP_ALLOWED_ROOTS", str(root)) with pytest.raises(ValueError, match="outside allowed MCP roots"): - powerio_mcp.save( - out_path=str(outside_out), content=CASE9.read_text(), to_format="psse" + powerio_mcp.emit( + destination=str(outside_out), content=CASE9.read_text(), format="psse", source_format="matpower" ) @@ -774,13 +621,13 @@ def test_allowed_roots_admits_write_inside_root(tmp_path, monkeypatch): root.mkdir() monkeypatch.setenv("POWERIO_MCP_ALLOWED_ROOTS", str(root)) out = root / "case9.raw" - r = powerio_mcp.save(out_path=str(out), content=CASE9.read_text(), to_format="psse") + r = powerio_mcp.emit(destination=str(out), content=CASE9.read_text(), format="psse", source_format="matpower") assert r["path"] == str(out) assert out.exists() def test_unreadable_file_maps_cleanly(tmp_path): - # PermissionError must surface as the documented ValueError shape, like + # PermissionError must be reported as the documented ValueError shape, like # FileNotFoundError, not leak raw through the tool. (Ported from the # canonical server's suite at powerio 0.1.1.) import os @@ -791,29 +638,26 @@ def test_unreadable_file_maps_cleanly(tmp_path): locked.write_text("function mpc = x\n") locked.chmod(0o000) try: - with pytest.raises(ValueError, match="cannot read input"): - powerio_mcp.convert(to_format="psse", path=str(locked)) - with pytest.raises(ValueError, match="cannot read input"): - powerio_mcp.summary(path=str(locked)) + with pytest.raises(ValueError, match="Permission denied"): + powerio_mcp.emit(format="psse", path=str(locked)) + with pytest.raises(ValueError, match="Permission denied"): + powerio_mcp.summarize(path=str(locked)) finally: locked.chmod(0o644) -def test_wrong_schema_json_maps_cleanly(): - # Wrong-schema (but well-formed) JSON keeps the one error shape too; the - # malformed-JSON case is covered above. Pinned to the diagnostic code, since - # powerio 0.9.0 replaced the old "parse failed" prose with coded messages. +def test_wrong_schema_ir_maps_cleanly(): for bad in ("{}", "[]", "null", '{"buses": "nope"}'): - with pytest.raises(ValueError, match=r"PARSE\.SOURCE\.MALFORMED"): - powerio_mcp.matrix("bprime", json=bad, json_format="model-json") + with pytest.raises(ValueError): + powerio_mcp.calc_matrix("bprime", powerio_ir=bad) -def test_legacy_json_format_token_still_accepted(): - # Responses state `model-json` since powerio 0.9, but the old `powerio-json` - # spelling stays valid as an input so an older client keeps working. - transport = powerio_mcp.parse(path=str(CASE9))["json"] - m = powerio_mcp.matrix("bprime", json=transport, json_format="powerio-json") - assert m["shape"] == [9, 9] + +def test_legacy_model_json_requires_migration(): + from powermcp.solver_case import resolve_solver_case + with pytest.raises(ValueError): + resolve_solver_case(network_json='{"model_kind":"balanced","model":{}}') + # --------------------------------------------------------------------------- @@ -834,7 +678,7 @@ def test_andes_load_network_from_any(tmp_path, andes_mcp): def test_andes_load_network_from_json(tmp_path, andes_mcp): - transport = powerio_mcp.parse(path=str(CASE9))["json"] + transport = powerio_mcp.parse(path=str(CASE9))["powerio_ir"] out = tmp_path / "case9_from_json.m" r = andes_mcp.load_network_from_json(transport, str(out)) assert r["status"] == "success", r @@ -842,8 +686,29 @@ def test_andes_load_network_from_json(tmp_path, andes_mcp): assert r["info"]["buses"] == 9 +def test_andes_case_file_replaces_an_existing_case(tmp_path, andes_mcp): + out = tmp_path / "case9.m" + out.write_text("stale", encoding="utf-8") + r = andes_mcp.load_network_from_any(str(CASE9), str(out)) + assert r["status"] == "success", r + assert "mpc.bus" in out.read_text(encoding="utf-8") + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics") +def test_andes_case_file_is_never_written_through_a_link(tmp_path, andes_mcp): + """The staged install replaces one regular file and refuses anything else.""" + target = tmp_path / "target.m" + target.write_text("keep", encoding="utf-8") + out = tmp_path / "case9.m" + out.symlink_to(target) + r = andes_mcp.load_network_from_any(str(CASE9), str(out)) + assert r["status"] == "error" + assert "regular file" in r["message"] + assert target.read_text(encoding="utf-8") == "keep" + + def test_andes_load_missing_file(tmp_path, andes_mcp): - r = andes_mcp.load_network_from_any("/nope/missing.m", str(tmp_path / "x.m")) + r = andes_mcp.load_network_from_any(str(tmp_path / "missing.m"), str(tmp_path / "x.m")) assert r["status"] == "error" assert "not found" in r["message"].lower() @@ -852,8 +717,8 @@ def test_andes_load_missing_file(tmp_path, andes_mcp): # pandapower-json plus folder and Parquet formats routed through generic verbs. # --------------------------------------------------------------------------- -def test_convert_to_pandapower_json(): - r = powerio_mcp.convert(to_format="pandapower-json", path=str(CASE9)) +def test_emit_to_pandapower_json(): + r = powerio_mcp.emit(format="pandapower-json", path=str(CASE9)) assert r["text"] assert json.loads(r["text"]) # well-formed JSON @@ -861,34 +726,34 @@ def test_convert_to_pandapower_json(): def test_pandapower_json_round_trips_through_transport(): # pandapower-json is a plain text format, so it flows through the existing # save/parse tools with no dedicated tool. - transport = powerio_mcp.parse(path=str(CASE9))["json"] + transport = powerio_mcp.parse(path=str(CASE9))["powerio_ir"] out = powerio_mcp.parse( - content=powerio_mcp.convert(to_format="pandapower-json", path=str(CASE9))[ + content=powerio_mcp.emit(format="pandapower-json", path=str(CASE9))[ "text" ], - from_format="pandapower-json", + format="pandapower-json", ) - assert json.loads(out["json"]) + assert json.loads(out["powerio_ir"]) assert json.loads(transport) def test_pypsa_csv_folder_round_trip(tmp_path): - # pypsa-csv is a directory format: write through save(to_format="pypsa-csv"), read + # pypsa-csv is a directory format: write through save(format="pypsa-csv"), read # back through parse via a folder path (powerio 0.3.3 folded the dedicated # read/write_pypsa_csv_folder tools into the bare verbs). out_dir = tmp_path / "pypsa_csv" - w = powerio_mcp.save(to_format="pypsa-csv", out_path=str(out_dir), path=str(CASE9)) + w = powerio_mcp.emit(format="pypsa-csv", destination=str(out_dir), path=str(CASE9)) assert w["files"], w assert (out_dir / "buses.csv").exists() r = powerio_mcp.parse(path=str(out_dir)) assert r["summary"]["elements"]["buses"] == 9 - assert json.loads(r["json"]) + assert json.loads(r["powerio_ir"]) def test_pypsa_csv_folder_accepts_transport(tmp_path): - transport = powerio_mcp.parse(path=str(CASE9))["json"] + transport = powerio_mcp.parse(path=str(CASE9))["powerio_ir"] out_dir = tmp_path / "from_json" - w = powerio_mcp.save(to_format="pypsa-csv", out_path=str(out_dir), json=transport) + w = powerio_mcp.emit(format="pypsa-csv", destination=str(out_dir), powerio_ir=transport) assert (out_dir / "generators.csv").exists(), w @@ -899,18 +764,20 @@ def test_read_pypsa_csv_missing_folder_maps_cleanly(tmp_path): def test_gridfm_round_trip(tmp_path): out_dir = tmp_path / "gfm" - w = powerio_mcp.save(to_format="gridfm", out_path=str(out_dir), path=str(CASE9)) - assert w["files"], w - r = powerio_mcp.parse( - path=str(out_dir), from_format="gridfm", options={"scenario": 0} - ) - assert r["summary"]["elements"]["buses"] == 9 - assert json.loads(r["json"]) + emitted = powerio_mcp.emit("gridfm", destination=str(out_dir), path=str(CASE9)) + assert emitted["files"] + parsed = powerio_mcp.parse(path=str(out_dir), format="gridfm") + assert parsed["summary"]["collection"] == "ScenarioSet" + scenarios = parsed["summary"]["scenarios"] + assert len(scenarios) == 1 + restored = powerio.deserialize(parsed["powerio_ir"].encode()).value + scenario = restored[scenarios[0]["id"]] + assert scenario.n_buses == 9 def test_gridfm_missing_dir_maps_cleanly(tmp_path): with pytest.raises(ValueError): - powerio_mcp.parse(path=str(tmp_path / "nope"), from_format="gridfm") + powerio_mcp.parse(path=str(tmp_path / "nope"), format="gridfm") # --------------------------------------------------------------------------- @@ -920,11 +787,7 @@ def test_gridfm_missing_dir_maps_cleanly(tmp_path): def test_display_decodes_pwd(): r = powerio_mcp.display(str(ACTIVSG200_PWD)) - assert r["schema"] == "powerio.display" - assert r["powerio_version"] == powerio.__version__ - assert r["domain"] == "display" - assert r["model"] == "display" - assert r["source_format"] == "powerworld-pwd" + assert r["format"] == "powerworld-pwd" assert r["canvas"]["width"] > 0 and r["canvas"]["height"] > 0 subs = r["substations"] assert subs, "expected at least one substation" @@ -995,17 +858,19 @@ def test_opendss_registration_excludes_distribution_wrapper(monkeypatch): def test_powerio_to_opendss_composition(monkeypatch, tmp_path): configuration = _load_opendss_configuration(monkeypatch) - dss_path = tmp_path / "feeder.dss" - save_result = powerio_mcp.save( - out_path=str(dss_path), - json=MINIMAL_BMOPF, - json_format="bmopf-json", + dss_dir = tmp_path / "feeder" + save_result = powerio_mcp.emit( + destination=str(dss_dir), + format="opendss", + content=MINIMAL_BMOPF, + source_format="bmopf-json", ) - assert save_result["path"] == str(dss_path) + assert save_result["dir"] == str(dss_dir) + dss_path = next(Path(path) for path in save_result["files"] if path.endswith(".dss")) assert dss_path.exists() result = configuration.compile_opendss_file(str(dss_path)) - assert result["success"] is True + assert result["status"] == "success" assert result["payload"]["dss_file"] == str(dss_path) @@ -1024,4 +889,65 @@ def test_opendss_without_containment_does_not_scan_the_parent_tree( result = configuration.compile_opendss_file(str(dss_path)) - assert result["success"] is True + assert result["status"] == "success" + + +# ---- 0.11 boundary: powerio_ir, typed edits, lowering, response tail ----------- + + +def test_adapters_take_powerio_ir_and_report_the_shared_tail(tmp_path): + ir = powerio.serialize(powerio.parse(CASE9)).text + out = tmp_path / "case9.nc" + result = pypsa_mcp.import_case_from_json(powerio_ir=ir, output_path=str(out)) + assert result["status"] == "success", result + assert result["value_type"] == "powerio.BalancedNetwork" + assert result["selection"] == {} + assert result["fidelity"] in {"canonical", "exact_same_format"} + assert isinstance(result["diagnostics"], list) + assert result["package"]["schema"] == "pio-ir" + both = pypsa_mcp.import_case_from_json(powerio_ir=ir, network_json=ir, output_path=str(tmp_path / "x.nc")) + assert both["status"] == "error" and "not both" in both["message"] + missing = pypsa_mcp.import_case_from_json(powerio_ir=ir) + assert missing["status"] == "error" and "output_path" in missing["message"] + + +def test_typed_edits_reach_the_pandapower_model(): + panda_dir = str(TOOLS["pandapower"].resolve_server_dir()) + if panda_dir not in sys.path: + sys.path.insert(0, panda_dir) + import panda_mcp + base = powerio.parse(CASE9).value + load_id = base.loads[0].get("uid") or "loads:0" + ir = powerio.serialize(powerio.parse(CASE9)).text + edits = json.dumps([ + {"op": "set_load_active_power", "load": load_id, "mw": 91.5}, + {"op": "set_branch_in_service", "branch": base.branches[0].get("uid") or "branches:0", "in_service": False}, + ]) + result = panda_mcp.load_network_from_json(powerio_ir=ir, edits=edits) + assert result["status"] == "success", result + assert result["edits"]["connectivity_changed"] is True + assert {change["component_type"] for change in result["edits"]["changes"]} == {"load", "branch"} + assert 91.5 in set(round(float(p), 3) for p in panda_mcp._current_net.load["p_mw"]) + assert not panda_mcp._current_net.line["in_service"].all() or not panda_mcp._current_net.trafo["in_service"].all() + rejected = panda_mcp.load_network_from_json(powerio_ir=ir, edits='[{"op": "teleport"}]') + assert rejected["status"] == "error" and "unknown op" in rejected["message"] + + +def test_multiconductor_input_needs_the_explicit_lowering_flag(tmp_path): + feeder = Path(__file__).resolve().parent / "data" / "opendss" / "fourwire_linecode.dss" + refused = pypsa_mcp.import_case_from_any(str(feeder), str(tmp_path / "refused.nc")) + assert refused["status"] == "error" and "to_balanced" in refused["message"] + lowered = pypsa_mcp.import_case_from_any( + str(feeder), str(tmp_path / "lowered.nc"), to_balanced=True, base_mva=1.0 + ) + assert lowered["status"] == "success", lowered + assert lowered["value_type"] == "powerio.MulticonductorNetwork" + assert "ready" in lowered["lowering"] + assert (tmp_path / "lowered.nc").exists() + + +def test_matrix_names_its_axes(): + matrix = powerio_mcp.calc_matrix("bprime", path=str(CASE9)) + assert matrix["shape"] == [9, 9] + assert matrix["row_ids"] == matrix["col_ids"] + assert len(matrix["row_ids"]) == 9 diff --git a/tests/test_publish_workflow.py b/tests/test_publish_workflow.py index 5eca92b..ab344e2 100644 --- a/tests/test_publish_workflow.py +++ b/tests/test_publish_workflow.py @@ -5,10 +5,35 @@ import re from pathlib import Path - -WORKFLOW = ( - Path(__file__).resolve().parents[1] / ".github" / "workflows" / "publish.yml" -).read_text(encoding="utf-8") +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 + import tomli as tomllib # type: ignore[no-redef] + + +REPO_ROOT = Path(__file__).resolve().parents[1] + +WORKFLOW = (REPO_ROOT / ".github" / "workflows" / "publish.yml").read_text( + encoding="utf-8" +) + + +def test_sdist_excludes_local_virtual_environments_and_build_caches(): + """A checkout carrying a local venv must not ship it to PyPI. + + hatchling walks the working tree, so a developer venv or a uv build cache + beside the sources lands in the sdist unless the exclude list names it. + """ + pyproject = tomllib.loads( + (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + ) + exclude = pyproject["tool"]["hatch"]["build"]["targets"]["sdist"]["exclude"] + assert { + ".venv*", + "**/.venv*", + ".uv-build-cache", + "**/.uv-build-cache", + } <= set(exclude) def test_publish_workflow_actions_use_full_commit_shas(): diff --git a/tests/test_registry.py b/tests/test_registry.py index 5f81459..4ed67d1 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -17,7 +17,7 @@ def test_package_versions_match(): project = tomllib.loads((registry.REPO_ROOT / "pyproject.toml").read_text())["project"] - assert project["version"] == __version__ == "0.3.0" + assert project["version"] == __version__ == "0.4.0" def test_core_tools_present_and_have_no_extra(): @@ -55,7 +55,7 @@ def test_closed_source_path_tools_declare_config_keys(): def test_windows_only_flags(): for name in ("psse", "pslf", "powerfactory", "pscad", "powerworld"): assert TOOLS[name].windows_only is True - for name in ("pandapower", "pypsa", "andes", "egret", "surge", "opendss", "hope", "ltspice"): + for name in ("pandapower", "pypsa", "andes", "egret", "surge", "opendss", "hope", "ltspice", "tellegen"): assert TOOLS[name].windows_only is False diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 5c9682d..a7835e0 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -31,8 +31,7 @@ REPO = pathlib.Path(__file__).resolve().parent.parent -# Every spelling powerio reads, so a test can clear them all before asserting -# that an unconfigured installation constrains nothing. +# Every spelling powerio reads for an allowed root, so a test can clear them all. ROOT_ENVS = (powermcp.sandbox.ALLOWED_ROOTS_ENV,) + powermcp.sandbox.LEGACY_ROOT_ENVS # Every server tool that takes a path from the model, and the argument it takes. @@ -99,15 +98,23 @@ "translate_to_sienna": ["xml_path", "output_path"], "compare_solutions": ["xml_path_a", "xml_path_b"], }, + # Every tellegen tool reaches the filesystem through these three helpers: + # `_module_ir` for a grid exchange input, `_out_path` for a written module, + # `_path` for every Study bundle argument. + "powermcp/tellegen.py": { + "_module_ir": ["path"], + "_out_path": ["out_path"], + "_path": ["path"], + }, } def test_the_policy_is_powerios(monkeypatch): """The policy has one implementation, which both sides reach by identity. - The drift this guards against already happened once: two copies read - different environment variables, so an operator could configure containment - and get it on one server and not another. + The drift this rules out already happened once: two copies read different + environment variables, so an operator could configure containment and get it + on one server and not another. """ assert powermcp.sandbox.checked_path is powerio.mcp.sandbox.checked_path assert powermcp.sandbox.allowed_roots is powerio.mcp.sandbox.allowed_roots @@ -138,11 +145,19 @@ def test_every_root_spelling_configures_containment(tmp_path, monkeypatch, env): checked_path(str(tmp_path / "secret.m"), purpose="file_path") -def test_unset_roots_constrain_nothing(tmp_path, monkeypatch): +def test_unset_roots_confine_to_the_startup_directory(monkeypatch): + """The directory is not named: powerio captures it at import time. + + That makes it the pytest invocation directory, which a future runner may + change. One existing root that admits a path beneath it is the stable + statement of the policy. + """ for name in ROOT_ENVS: monkeypatch.delenv(name, raising=False) - assert allowed_roots() == () - assert checked_path(str(tmp_path / "anywhere.m"), purpose="p") + (root,) = allowed_roots() + assert root.is_dir() + inside = root / "case.m" + assert checked_path(str(inside), purpose="p") == str(inside) def test_a_path_inside_a_root_is_admitted(tmp_path, monkeypatch): @@ -245,7 +260,7 @@ def _checked_arguments(server: str) -> dict[str, set[str]]: Reads the server source rather than importing it: a bridge server pulls in the simulator it wraps, which is not installed in every environment, so importing to introspect would skip the check exactly where it matters. The - guard is a syntactic property and the AST shows it. + check is a syntactic property and the AST shows it. """ tree = ast.parse((REPO / server).read_text(encoding="utf-8")) return { @@ -298,6 +313,7 @@ def test_staged_directory_write_preserves_unrelated_files(tmp_path): (output / "buses.csv").write_text("old") def write(staging): + pathlib.Path(staging).mkdir() path = pathlib.Path(staging) / "buses.csv" path.write_text("new") return {"dir": staging, "files": [str(path)]} @@ -341,7 +357,7 @@ def test_psse_command_name_is_not_a_spec_path(): ) -def test_psse_prohibited_command_contract_matches_the_server(): +def test_psse_prohibited_command_list_matches_the_server(): assert set(PSSE_PROHIBITED_COMMANDS) == psse_mcp._PROHIBITED_PSSPY_COMMANDS @@ -436,8 +452,8 @@ def test_psse_path_metadata_matches_the_bundled_specs(): # A parameter name or description that mentions a file, path, directory or -# folder. Deliberately broad: every hit must be either guarded or recorded as -# reviewed, so regenerating the bundled specs cannot quietly unguard one. +# folder. Deliberately broad: every hit must be either checked or recorded as +# reviewed, so regenerating the bundled specs cannot quietly drop a check. PSSE_PATHISH_NAME = re.compile( r"(file|fname|path|folder|zip|csv|xml|iplname|rspname|autoname)", re.I ) diff --git a/tests/test_sdk_imports.py b/tests/test_sdk_imports.py index abdb6ca..c80a8c1 100644 --- a/tests/test_sdk_imports.py +++ b/tests/test_sdk_imports.py @@ -27,6 +27,7 @@ "HOPE", "LTSpice", "OpenDSS", + "PLEXOSDB", "PSCAD", "PSLF", "PSSE", diff --git a/tests/test_solver_case.py b/tests/test_solver_case.py index 3c47c04..69a094e 100644 --- a/tests/test_solver_case.py +++ b/tests/test_solver_case.py @@ -1,119 +1,343 @@ -"""PowerIO's package model is the single case boundary used by solver tools.""" - +"""Typed modules, explicit state selection and validation at the solver boundary.""" from __future__ import annotations import json import os from pathlib import Path -import powerio import pytest -from powermcp.sandbox import PathNotAllowed -from powermcp.solver_case import _available_indexes, _index_inventory, resolve_solver_case +pytest.importorskip("powerio", minversion="0.11.3") + +import powerio # noqa: E402 + +from powermcp.sandbox import PathNotAllowed # noqa: E402 +from powermcp.solver_case import resolve_solver_case, unique_diagnostics # noqa: E402 CASE9 = Path(__file__).parent / "data" / "case9.m" -def test_case_file_uses_powerio_package_validation(monkeypatch): - monkeypatch.setattr( - powerio, - "parse_file", - lambda *args, **kwargs: pytest.fail("use Package.from_file for case inputs"), - ) - resolved = resolve_solver_case(file_path=str(CASE9)) +def ir(module): + return powerio.serialize(module).text + +def test_case_file_constructs_a_validated_instance(): + resolved = resolve_solver_case(file_path=str(CASE9)) assert resolved.network.n_buses == 9 + assert isinstance(resolved.module, powerio.PioModule) assert resolved.package is None + assert "mpc.bus" in resolved.emit("matpower").text @pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics") -def test_directory_case_refuses_a_symlinked_descendant_outside_roots( - tmp_path, monkeypatch -): +def test_directory_case_refuses_a_symlinked_descendant_outside_roots(tmp_path, monkeypatch): root = tmp_path / "allowed" root.mkdir() dataset = root / "dataset" - powerio.parse_file(CASE9).write_pypsa_csv_folder(dataset) + powerio.emit(powerio.parse(CASE9), "pypsa-csv", dataset) outside = tmp_path / "outside-buses.csv" (dataset / "buses.csv").replace(outside) (dataset / "buses.csv").symlink_to(outside) monkeypatch.setenv("POWERIO_MCP_ALLOWED_ROOTS", str(root)) - with pytest.raises(PathNotAllowed, match="outside its allowed MCP root"): resolve_solver_case(file_path=str(dataset), source_format="pypsa-csv") -def test_package_file_preserves_auditable_context(tmp_path): - package = powerio.Package.from_file(CASE9) - document = json.loads(package.to_json()) - document["package_id"] = "dispatch-input" +def test_ir_preserves_auditable_context(tmp_path): source = tmp_path / "case.pio.json" - source.write_text(json.dumps(document)) - + source.write_text(ir(powerio.parse(CASE9))) resolved = resolve_solver_case(file_path=str(source)) + assert resolved.network.n_buses == 9 + assert resolved.package["schema"] == "pio-ir" + assert resolved.package["generation"] == 2 + assert resolved.package["producer"]["name"] == "powerio" + assert resolved.package["selection"] == {} + + +def test_invalid_identities_cannot_reuse_stale_diagnostics(): + document = json.loads(ir(powerio.parse(CASE9))) + buses = document["value"]["data"]["buses"] + buses[1]["id"] = buses[0]["id"] + document["diagnostics"] = [] + with pytest.raises((ValueError, RuntimeError)): + resolve_solver_case(network_json=json.dumps(document)) + +def test_nested_collections_require_explicit_selection(): + network = powerio.parse(CASE9).value + series = powerio.TimeSeries([network, network], time_points=[powerio.TimePoint("h0"), powerio.TimePoint("h1")]) + scenarios = powerio.ScenarioSet({"base": series, "alternative": series}) + payload = ir(powerio.PioModule.from_value(scenarios)) + with pytest.raises(ValueError, match="scenario_id"): + resolve_solver_case(network_json=payload) + with pytest.raises(ValueError, match="time_index"): + resolve_solver_case(network_json=payload, scenario_id="base") + resolved = resolve_solver_case(network_json=payload, scenario_id="alternative", time_index=1) assert resolved.network.n_buses == 9 - assert resolved.package["package_id"] == "dispatch-input" - assert resolved.package["validation"]["status"] == "ok" - assert resolved.package["source_map_entries"] > 0 + assert resolved.package["selection"] == {"scenario_id":"alternative", "time_index":1} + for index in (-1, True): + with pytest.raises(ValueError, match="nonnegative integer"): + resolve_solver_case(network_json=payload, scenario_id="base", time_index=index) + with pytest.raises(ValueError, match="past the end of the TimeSeries; 2 entries"): + resolve_solver_case(network_json=payload, scenario_id="base", time_index=2) + with pytest.raises(ValueError) as absent: + resolve_solver_case(network_json=payload, scenario_id="missing", time_index=0) + assert "names no entry of the ScenarioSet" in str(absent.value) + assert "alternative" in str(absent.value) + + +def test_each_collection_level_selects_on_its_own_module(): + """One selector resolves per level, so no level depends on another's order. + + PowerIO IR admits a TimeSeries inside a ScenarioSet, so the nested case + here is a ScenarioSet of TimeSeries; the single level cases pin the same + property for a collection of one kind. + """ + network = powerio.parse(CASE9).value + series = powerio.TimeSeries( + [network, network], time_points=[powerio.TimePoint("h0"), powerio.TimePoint("h1")] + ) + by_time = resolve_solver_case(network_json=ir(powerio.PioModule.from_value(series)), time_index=1) + assert by_time.selection == {"time_index": 1} + assert isinstance(by_time.module.value, powerio.BalancedNetwork) -def test_package_validation_is_recomputed_after_deserialization(): - document = json.loads(powerio.Package.from_file(CASE9).to_json()) - network = document["model"]["balanced_network"] - network["buses"][1]["id"] = network["buses"][0]["id"] - assert document["validation"]["status"] == "ok" + alone = powerio.ScenarioSet({"only": network}) + by_scenario = resolve_solver_case( + network_json=ir(powerio.PioModule.from_value(alone)), scenario_id="only" + ) + assert by_scenario.selection == {"scenario_id": "only"} + assert isinstance(by_scenario.module.value, powerio.BalancedNetwork) + + scenarios = powerio.ScenarioSet({"base": series, "alternative": series}) + nested = resolve_solver_case( + network_json=ir(powerio.PioModule.from_value(scenarios)), + scenario_id="alternative", + time_index=0, + ) + assert nested.selection == {"scenario_id": "alternative", "time_index": 0} + assert isinstance(nested.module.value, powerio.BalancedNetwork) + assert "mpc.bus" in nested.emit("matpower").text - with pytest.raises(ValueError, match="fails validation"): - resolve_solver_case(network_json=json.dumps(document)) +def test_calculation_instance_keeps_its_type(): + module = powerio.parse(CASE9).to_dc_opf_instance() + resolved = resolve_solver_case(network_json=ir(module)) + assert isinstance(resolved.module.value, powerio.DcOpfInstance) + assert resolved.network.n_buses == 9 + assert resolved.emit("matpower").text -@pytest.mark.parametrize( - ("field", "empty_value"), - [ - ( - "operating_points", - {"time_axis": {"periods": 0, "labels": []}, "points": []}, - ), - ("study", {"label": "empty study", "commits": []}), - ], -) -def test_empty_package_state_metadata_remains_a_static_case(field, empty_value): - document = json.loads(powerio.Package.from_file(CASE9).to_json()) - document[field] = empty_value - resolved = resolve_solver_case(network_json=json.dumps(document)) +def test_multiconductor_input_requires_explicit_lowering(): + payload = '{"meta":{"frequency":50},"bus":{"b":{"terminal_names":["a","b","c","n"]}}}' + with pytest.raises(ValueError, match="explicitly call to_balanced"): + resolve_solver_case(network_json=payload, source_format="bmopf-json") + + +def test_a_retired_package_document_is_not_powerio_ir(): + """A 0.9 Package carries no `pio-ir` schema, so no writer ever sees it. + + Named as PowerIO IR it is refused for what it is not; unnamed, powerio + refuses it as an unknown JSON format. Either way the migration is the one + the README states: re-parse the original case and pass its `powerio_ir`. + """ + package = '{"model_kind":"balanced","model":{}}' + with pytest.raises(ValueError, match="not PowerIO IR"): + resolve_solver_case(network_json=package, source_format="pio-ir") + with pytest.raises(ValueError, match="unknown or unsupported case format"): + resolve_solver_case(network_json=package) - assert resolved.network.n_buses == 9 - assert "materialized" not in resolved.package +def test_study_commit_requires_a_tellegen_study(): + with pytest.raises(ValueError, match="Tellegen Study"): + resolve_solver_case(file_path=str(CASE9), study_commit=0) -def test_exactly_one_interchange_input_is_required(): + +def test_exactly_one_input_and_matching_selectors_are_required(): with pytest.raises(ValueError, match="exactly one"): resolve_solver_case() with pytest.raises(ValueError, match="exactly one"): resolve_solver_case(file_path="case.m", network_json="{}") + with pytest.raises(ValueError, match="does not match"): + resolve_solver_case(file_path=str(CASE9), time_index=0) -def test_large_state_inventory_is_compact(): - indexes = list(range(8760)) +DIST = Path(__file__).parent / "data" / "opendss" / "fourwire_linecode.dss" - assert _available_indexes(indexes) == "0..8759 (8760 available)" - assert _index_inventory(indexes) == { - "count": 8760, - "first": 0, - "last": 8759, - } +def test_powerio_ir_is_the_primary_argument_and_network_json_its_alias(): + payload = ir(powerio.parse(CASE9)) + primary = resolve_solver_case(powerio_ir=payload) + alias = resolve_solver_case(network_json=payload) + assert primary.network.n_buses == alias.network.n_buses == 9 + assert primary.value_type == "powerio.BalancedNetwork" + assert primary.selection == {} + assert isinstance(primary.diagnostics, tuple) + with pytest.raises(ValueError, match="not both"): + resolve_solver_case(powerio_ir=payload, network_json=payload) -def test_sparse_large_state_indexes_do_not_materialize_the_range(): - indexes = [*range(20), 1_000_000_000] - assert _available_indexes(indexes) == ( - "[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...] " - "(21 available; last 1000000000)" - ) +def test_response_fields_carry_the_shared_tail(): + resolved = resolve_solver_case(file_path=str(CASE9)) + conversion = resolved.emit("matpower") + fields = resolved.response_fields(conversion) + assert fields["value_type"] == "powerio.BalancedNetwork" + assert fields["selection"] == {} + assert fields["fidelity"] in {"exact_same_format", "canonical"} + assert isinstance(fields["diagnostics"], list) and isinstance(fields["warnings"], list) + assert "edits" not in fields and "lowering" not in fields and "package" not in fields + + +def test_typed_edits_apply_before_the_solver_sees_the_network(): + base = powerio.parse(CASE9).value + load = base.loads[0] + load_id = load.get("uid") or "loads:0" + branch_id = base.branches[0].get("uid") or "branches:0" + edits = json.dumps([ + {"op": "set_load_active_power", "load": load_id, "mw": 91.5}, + {"op": "set_branch_thermal_rating", "branch": branch_id, "mva": 123.0}, + {"op": "set_bus_load_active_power", "bus": base.loads[1]["bus"], "mw": 77.0, "allocation": "equal"}, + ]) + resolved = resolve_solver_case(file_path=str(CASE9), edits=edits) + network = resolved.network + assert network.loads[0]["p"] == pytest.approx(91.5) + assert network.branches[0]["rate_a"] == pytest.approx(123.0) + assert network.loads[1]["p"] == pytest.approx(77.0) + assert resolved.edits["connectivity_changed"] is False + fields = {(change["component_type"], change["field"]) for change in resolved.edits["changes"]} + assert ("load", "active_power") in fields or any(c["component_type"] == "load" for c in resolved.edits["changes"]) + assert any(change["component_type"] == "branch" for change in resolved.edits["changes"]) + # The emitted case carries the edit, so every solver adapter sees it. + assert "91.5" in resolved.emit("matpower").text + # The source module is untouched: a fresh resolution states the original demand. + assert resolve_solver_case(file_path=str(CASE9)).network.loads[0]["p"] == pytest.approx(base.loads[0]["p"]) + + +def two_loads_on_one_bus(): + """case9 with its first demand split into two 25 MW loads on the same bus.""" + document = json.loads(ir(powerio.parse(CASE9))) + loads = document["value"]["data"]["loads"] + shared = dict(loads[0]) + document["value"]["data"]["loads"] = [ + {**shared, "p": 25.0, "uid": "load-A"}, + {**shared, "p": 25.0, "uid": "load-B"}, + *loads[1:], + ] + document["value"]["data"]["generated_uids"] = [] + return json.dumps(document), shared["bus"] + + +def test_edits_apply_in_the_order_the_caller_listed_them(): + payload, bus = two_loads_on_one_bus() + reallocate = {"op": "set_bus_load_active_power", "bus": bus, "mw": 50.0, + "allocation": "proportional_to_current_active_power"} + set_load_a = {"op": "set_load_active_power", "load": "load-A", "mw": 10.0} + + # The reallocation runs first and the direct edit states the final value. + resolved = resolve_solver_case(powerio_ir=payload, edits=json.dumps([reallocate, set_load_a])) + assert resolved.network.loads[0]["p"] == pytest.approx(10.0) + assert resolved.network.loads[1]["p"] == pytest.approx(25.0) + assert [(change["local_id"], change["field"]) for change in resolved.edits["changes"]] == [ + ("load-A", "load_active_power"), + ] + + # Reversed, the reallocation sees 10 and 25 MW and splits 50 MW over them. + resolved = resolve_solver_case(powerio_ir=payload, edits=json.dumps([set_load_a, reallocate])) + assert resolved.network.loads[0]["p"] == pytest.approx(50.0 * 10.0 / 35.0) + assert resolved.network.loads[1]["p"] == pytest.approx(50.0 * 25.0 / 35.0) + assert [change["local_id"] for change in resolved.edits["changes"]] == ["load-A", "load-A", "load-B"] + + +def test_consecutive_updates_of_one_class_are_one_batch_in_list_order(): + payload, _ = two_loads_on_one_bus() + edits = json.dumps([ + {"op": "set_load_active_power", "load": "load-A", "mw": 10.0}, + {"op": "set_load_active_power", "load": "load-B", "mw": 40.0}, + {"op": "set_branch_thermal_rating", "branch": "1-4", "mva": 123.0}, + {"op": "set_load_active_power", "load": "load-A", "mw": 5.0}, + ]) + resolved = resolve_solver_case(powerio_ir=payload, edits=edits) + assert resolved.network.loads[0]["p"] == pytest.approx(5.0) + assert resolved.network.loads[1]["p"] == pytest.approx(40.0) + assert resolved.network.branches[0]["rate_a"] == pytest.approx(123.0) + assert [(change["component_type"], change["local_id"]) for change in resolved.edits["changes"]] == [ + ("load", "load-A"), ("load", "load-B"), ("branch", "1-4"), ("load", "load-A"), + ] + + +def test_a_rejected_edit_states_its_diagnostic_code_once(): + with pytest.raises(ValueError) as rejected: + resolve_solver_case(file_path=str(CASE9), + edits='[{"op": "set_load_active_power", "load": "loads:999", "mw": 1.0}]') + message = str(rejected.value) + assert message.startswith("edit rejected: ") + codes = [word for word in message.split() if word.isupper() and "." in word] + assert codes and message.count(codes[0]) == 1 + + +def test_edits_are_validated_as_a_whole_before_anything_applies(): + base = powerio.parse(CASE9).value + load_id = base.loads[0].get("uid") or "loads:0" + for bad, message in ( + ('[{"op": "set_load_active_power", "load": "%s", "mw": 91.5}, {"op": "teleport"}]' % load_id, "unknown op"), + ('[{"op": "set_load_active_power", "load": "%s", "mw": "big"}]' % load_id, "finite number"), + ('[{"op": "set_branch_in_service", "branch": "branches:0", "in_service": "no"}]', "true or false"), + ('[{"op": "set_bus_load_active_power", "bus": 5, "mw": 1.0, "allocation": "random"}]', "allocation"), + ('{"op": "set_load_active_power"}', "JSON list"), + ): + with pytest.raises(ValueError, match=message): + resolve_solver_case(file_path=str(CASE9), edits=bad) + with pytest.raises(ValueError, match="edit rejected"): + resolve_solver_case(file_path=str(CASE9), edits='[{"op": "set_load_active_power", "load": "loads:999", "mw": 1.0}]') + + +def test_unique_diagnostics_keeps_one_entry_per_report(): + """Equal reports collapse to the first; a distinct one keeps its place.""" + lowered = powerio.parse(DIST).to_balanced(1.0) + first, second = list(lowered.diagnostics)[:2] + assert (first.code, first.message) != (second.code, second.message) + kept = unique_diagnostics([first, first, second]) + assert [(item.code, item.message) for item in kept] == [ + (first.code, first.message), + (second.code, second.message), + ] + assert unique_diagnostics([]) == [] + + +def test_multiconductor_lowering_is_an_explicit_choice_with_a_report(): + with pytest.raises(ValueError, match="to_balanced"): + resolve_solver_case(file_path=str(DIST)) + resolved = resolve_solver_case(file_path=str(DIST), to_balanced=True, base_mva=1.0) + assert isinstance(resolved.network, powerio.BalancedNetwork) + assert resolved.network.n_buses >= 2 + assert resolved.lowering is not None + assert "ready" in resolved.lowering + fields = resolved.response_fields() + assert fields["lowering"] == resolved.lowering + assert fields["value_type"] == "powerio.MulticonductorNetwork" + reported = [(record["code"], record["message"]) for record in fields["diagnostics"]] + assert any(code.startswith("TRANSFORM.MULTI_TO_BALANCED.") for code, _ in reported) + assert len(reported) == len(set(reported)) + + +def test_operating_point_entries_reach_the_solver_as_their_network(): + network = powerio.parse(CASE9).value + document = json.loads(ir(powerio.PioModule.from_value(network))) + series = { + "schema": document["schema"], "version": document["version"], "producer": document["producer"], + "value": { + "type": "powerio.TimeSeries>", + "data": { + "network": document["value"]["data"], + "time_points": [{"label": "h0"}, {"label": "h1"}], + "values": [{"quantities": {}}, {"quantities": {}}], + }, + }, + } + resolved = resolve_solver_case(powerio_ir=json.dumps(series), time_index=1) + assert resolved.network.n_buses == 9 + assert resolved.selection == {"time_index": 1} + assert "mpc.bus" in resolved.emit("matpower").text def test_temporary_integration_module_names_are_absent(): diff --git a/tests/test_tellegen_server.py b/tests/test_tellegen_server.py new file mode 100644 index 0000000..6b202ad --- /dev/null +++ b/tests/test_tellegen_server.py @@ -0,0 +1,371 @@ +"""Native Study adapter behavior and path checks.""" +import asyncio + +import pytest + +pytest.importorskip("powerio", minversion="0.11.3") + +from powermcp import tellegen # noqa: E402 + + +def test_apply_is_not_an_agent_operation(monkeypatch): + async def forbidden(*args): + pytest.fail("rejected operation reached the native process") + monkeypatch.setattr(tellegen, "_call", forbidden) + with pytest.raises(ValueError, match="explicit native CLI"): + asyncio.run(tellegen.study_run("study.json", 0, {"kind": "apply"})) + + +def test_paths_checked_before_native_execution(tmp_path, monkeypatch): + monkeypatch.setenv("POWERIO_MCP_ALLOWED_ROOTS", str(tmp_path)) + async def forbidden(*args): + pytest.fail("out-of-root path reached the native process") + monkeypatch.setattr(tellegen, "_call", forbidden) + with pytest.raises(ValueError): + asyncio.run(tellegen.study_inspect(str(tmp_path.parent / "outside.json"))) + + +@pytest.mark.parametrize("kind", ["propose", "edit_demand", "restore_base"]) +def test_mutation_keeps_revision_binding_and_returns_compact_result(tmp_path, monkeypatch, kind): + path = tmp_path / "study.json" + path.write_text("{}") + received = [] + async def native(args, request): + received.append((args, request)) + return {"summary": {"id": "s", "revision": 4, "active_goal": ["g", {"request": "lower prices", "anchor_state": "a", "objective": {"large": []}}]}, "experiment": "e", "inspected_view": {"large": []}} + monkeypatch.setattr(tellegen, "_call", native) + operation = {"kind": kind, "state": "a", "goal": "g"} + result = asyncio.run(tellegen.study_run(str(path), 3, operation)) + assert received == [(["study", "run", str(path), "--progress"], {"expected_revision": 3, "operation": operation})] + assert result["revision"] == 4 and result["experiment"] == "e" + assert "inspected_view" not in result and "objective" not in result["active_goal"] + + +def test_registered_tools_include_native_study_operations(): + names = {t.name for t in asyncio.run(tellegen.mcp.list_tools())} + assert {"study_contract", "study_create", "study_inspect", "study_run", "study_import", "study_export"} <= names + + +def test_cancellation_requests_graceful_native_save(monkeypatch): + async def run(): + started, terminated, saved = asyncio.Event(), asyncio.Event(), asyncio.Event() + class Process: + returncode = None + async def communicate(self, data): + started.set() + await terminated.wait() + saved.set() + self.returncode = 0 + return b'{"revision":1}', b'' + def send_signal(self, value): + terminated.set() + def terminate(self): + terminated.set() + def kill(self): + pytest.fail("cooperative cancellation must allow the completed save") + async def wait(self): + return self.returncode + async def spawn(*args, **kwargs): + return Process() + monkeypatch.setattr(tellegen, "_binary", lambda: "tellegen") + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + task = asyncio.create_task(tellegen._call(["study", "run", "study.json"], {})) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert terminated.is_set() and saved.is_set() + asyncio.run(run()) + + +@pytest.mark.parametrize("value", [0, -1, "nan", "inf", "invalid", 86401]) +def test_invalid_execution_duration_is_rejected(value, monkeypatch): + monkeypatch.setenv("POWERMCP_TELLEGEN_TIMEOUT_SECONDS", str(value)) + with pytest.raises(ValueError, match="duration"): + tellegen._seconds("timeout_seconds", 1800) + + +# ---- end to end over the fake binary -------------------------------------------- + +import json +import os +import time +from pathlib import Path + +import powerio + +FAKE = Path(__file__).parent / "data" / "fake_tellegen.py" +CASE9 = Path(__file__).parent / "data" / "case9.m" +DIST = Path(__file__).parent / "data" / "opendss" / "fourwire_linecode.dss" +UNRESOLVED = Path(__file__).parent / "data" / "opendss" / "geometry_unresolved.dss" + + +@pytest.fixture +def fake_binary(tmp_path, monkeypatch): + record = tmp_path / "record.jsonl" + monkeypatch.setenv("POWERMCP_TELLEGEN_BINARY", str(FAKE)) + monkeypatch.setenv("FAKE_TELLEGEN_RECORD", str(record)) + monkeypatch.delenv("FAKE_TELLEGEN_SLEEP", raising=False) + + def calls(): + if not record.exists(): + return [] + return [json.loads(line) for line in record.read_text().splitlines()] + + return calls + + +def test_registered_tools_list_solving_planning_and_studies(): + names = {t.name for t in asyncio.run(tellegen.mcp.list_tools())} + assert names == { + "capabilities", "contract", "solve", "solve_module", "plan", + "study_contract", "study_create", "study_inspect", "study_run", "study_import", "study_export", + } + + +def test_unconfigured_binary_names_the_configuration(monkeypatch): + monkeypatch.delenv("POWERMCP_TELLEGEN_BINARY", raising=False) + monkeypatch.setattr(tellegen.shutil, "which", lambda name: None) + monkeypatch.setattr(tellegen, "get", lambda *args: None) + with pytest.raises(RuntimeError, match="tellegen.binary"): + tellegen._binary() + + +def test_capabilities_and_contract_over_the_fake_binary(fake_binary): + caps = asyncio.run(tellegen.capabilities()) + assert caps["binary"].endswith("fake_tellegen.py") + assert caps["capabilities"][0]["formulation"] == "dcopf" + assert asyncio.run(tellegen.contract())["contract"] == "tellegen.cli/1" + assert [call["argv"] for call in fake_binary()] == [["capabilities"], ["contract"]] + + +def test_solve_hands_generation_two_ir_to_the_binary(fake_binary): + result = asyncio.run(tellegen.solve(path=str(CASE9))) + assert result["formulation"] == "dcopf" + assert result["selection"] == {} + assert result["value_type"] == "powerio.BalancedNetwork" + assert result["diagnostics"] == [] and result["warnings"] == [] + # fidelity and the typed edit report belong to the powerio adapters. + assert "fidelity" not in result and "lowering" not in result + assert result["response"]["status"] == "optimal" + assert len(result["response"]["lmp"]) == 9 + (call,) = fake_binary() + assert json.loads(call["argv"][0]) == {"formulation": "dcopf"} + module = json.loads(call["stdin"]) + assert module["schema"] == "pio-ir" and module["version"] == 2 + assert module["value"]["type"] == "powerio.BalancedNetwork" + + +def test_the_input_side_diagnostics_travel_with_every_response(fake_binary): + """The module's own records reach the caller, not only the returned module's.""" + lowered = powerio.parse(DIST).to_balanced(1.0) + ir = powerio.serialize(lowered).text + codes = {record["code"] for record in tellegen._module_ir(ir, None, None, None, None)[1]["diagnostics"]} + assert codes and all(code.startswith("TRANSFORM.MULTI_TO_BALANCED") for code in codes) + + solved = asyncio.run(tellegen.solve(powerio_ir=ir)) + assert {record["code"] for record in solved["diagnostics"]} == codes + # A balanced transformation states remarks, and `warnings` carries error and + # warning severities only, so the records are the ones that carry them. + assert {record["severity"] for record in solved["diagnostics"]} == {"remark"} + assert solved["warnings"] == [] + + module = asyncio.run(tellegen.solve_module(powerio_ir=ir)) + # The stand-in echoes the module it was given, so its records repeat the + # input side here; a real solution module carries none of its own. + assert codes <= {record["code"] for record in module["diagnostics"]} + assert module["diagnostics_counts"]["remark"] >= len(codes) + assert set(module["warnings"]) == set(solved["warnings"]) + assert module["value_type"] == "powerio.BalancedNetwork" # the stand-in echoes the input + assert module["selection"] == {} + + planned = asyncio.run(tellegen.plan(json.dumps({"budget_mw": 1}), powerio_ir=ir)) + assert {record["code"] for record in planned["diagnostics"]} == codes + assert planned["value_type"] == "powerio.BalancedNetwork" + + +def test_multiconductor_input_fails_before_the_native_process(fake_binary, tmp_path): + """PowerMCP names the missing lowering instead of a subprocess diagnostic.""" + study = tmp_path / "study.json" + for call in ( + lambda: tellegen.solve(path=str(DIST)), + lambda: tellegen.solve_module(path=str(DIST)), + lambda: tellegen.plan(json.dumps({"budget_mw": 1}), path=str(DIST)), + lambda: tellegen.study_create(str(study), {"id": "s1", "request": "r"}, input_path=str(DIST)), + ): + with pytest.raises(ValueError, match="balanced network or a calculation instance"): + asyncio.run(call()) + assert fake_binary() == [] + assert not study.exists() + + +def test_an_error_diagnostic_is_refused_before_the_native_process(fake_binary, tmp_path): + """A module PowerIO marks with an error severity never reaches the binary.""" + study = tmp_path / "study.json" + for call in ( + lambda: tellegen.solve(path=str(UNRESOLVED)), + lambda: tellegen.solve_module(path=str(UNRESOLVED)), + lambda: tellegen.plan(json.dumps({"budget_mw": 1}), path=str(UNRESOLVED)), + lambda: tellegen.study_create(str(study), {"id": "s1", "request": "r"}, input_path=str(UNRESOLVED)), + ): + with pytest.raises(ValueError, match="fails validation"): + asyncio.run(call()) + assert fake_binary() == [] + assert not study.exists() + + +def test_a_marked_balanced_module_is_refused_like_every_other_adapter(fake_binary, monkeypatch): + """The severity gate is the one `resolve_solver_case` applies, on the same wording.""" + network = powerio.parse(CASE9).value + + class Diagnostic: + code = "TEST.REFUSED" + severity = "error" + message = "the input states an unresolved identity" + target = None + + class Marked: + diagnostics = (Diagnostic(),) + value = network + + monkeypatch.setattr(powerio, "parse", lambda *args, **kwargs: Marked()) + with pytest.raises(ValueError, match="fails validation: TEST.REFUSED"): + asyncio.run(tellegen.solve(path=str(CASE9))) + assert fake_binary() == [] + + +def test_solve_passes_edits_sensitivities_and_bounds_arrays(fake_binary): + ir = powerio.serialize(powerio.parse(CASE9)).text + result = asyncio.run(tellegen.solve( + powerio_ir=ir, formulation="dcpf", + edits='{"deltas": {"5": 10.0}}', sensitivities='[{"kind": "lmp"}]', max_elements=2, + )) + (call,) = fake_binary() + assert json.loads(call["argv"][0]) == { + "formulation": "dcpf", "edits": {"deltas": {"5": 10.0}}, "sensitivities": [{"kind": "lmp"}], + } + assert result["response"]["lmp"] == {"truncated": True, "count": 9, "head": [{"id": 1, "value": 1.0}, {"id": 2, "value": 1.0}]} + with pytest.raises(ValueError, match="formulation"): + asyncio.run(tellegen.solve(powerio_ir=ir, formulation="acopf")) + with pytest.raises(ValueError, match="exactly one"): + asyncio.run(tellegen.solve()) + with pytest.raises(ValueError, match="edits must be JSON"): + asyncio.run(tellegen.solve(powerio_ir=ir, edits="{not json")) + + +def test_solve_selects_a_collection_entry(fake_binary): + network = powerio.parse(CASE9).value + series = powerio.TimeSeries([network, network], time_points=[powerio.TimePoint("h0"), powerio.TimePoint("h1")]) + ir = powerio.serialize(powerio.PioModule.from_value(series)).text + with pytest.raises(ValueError, match="time_index"): + asyncio.run(tellegen.solve(powerio_ir=ir)) + result = asyncio.run(tellegen.solve(powerio_ir=ir, time_index=1)) + assert result["selection"] == {"time_index": 1} + (call,) = fake_binary() + assert json.loads(call["stdin"])["value"]["type"] == "powerio.BalancedNetwork" + + +def test_solve_module_writes_through_staging_and_refuses_overwrite(fake_binary, tmp_path): + out = tmp_path / "solution.pio.json" + result = asyncio.run(tellegen.solve_module(path=str(CASE9), out_path=str(out))) + assert result["path"] == str(out) + assert result["value_type"] == "powerio.BalancedNetwork" # the stand-in echoes the network + assert json.loads(out.read_text())["producer"]["name"] == "fake-tellegen" + with pytest.raises(ValueError, match="overwrite"): + asyncio.run(tellegen.solve_module(path=str(CASE9), out_path=str(out))) + inline = asyncio.run(tellegen.solve_module(path=str(CASE9))) + assert json.loads(inline["powerio_ir"])["producer"]["name"] == "fake-tellegen" + assert [call["argv"] for call in fake_binary()] == [["solve-module"], ["solve-module"]] + + +def test_plan_sends_module_and_spec_and_returns_the_proposal(fake_binary, tmp_path): + spec = {"objective": {"kind": "weighted_lmp"}, "budget_mw": 100} + result = asyncio.run(tellegen.plan(json.dumps(spec), path=str(CASE9))) + assert result["plan"]["spec"] == spec + assert result["solution"]["value_type"] == "powerio.BalancedNetwork" + assert json.loads(result["solution_powerio_ir"])["producer"]["name"] == "fake-tellegen" + (call,) = fake_binary() + request = json.loads(call["stdin"]) + assert request["module"]["schema"] == "pio-ir" and request["spec"] == spec + with pytest.raises(ValueError, match="CapacityPlanSpec"): + asyncio.run(tellegen.plan("", path=str(CASE9))) + + +def test_study_create_fills_the_input_from_a_grid_exchange_file(fake_binary, tmp_path): + study = tmp_path / "study.json" + result = asyncio.run(tellegen.study_create( + str(study), {"id": "s1", "title": "t", "request": "lower prices", "formulation": "dcopf"}, + input_path=str(CASE9), + )) + assert result["revision"] == 1 + assert result["active_goal"] == {"id": "g", "request": "lower prices", "anchor_state": "base"} + (call,) = fake_binary() + request = json.loads(call["stdin"]) + assert json.loads(request["input"])["schema"] == "pio-ir" + assert request["base_input"] == request["input"] + assert json.loads(study.read_text())["input_has_ir"] + + +def test_apply_never_reaches_the_binary_and_progress_is_returned(fake_binary, tmp_path): + study = tmp_path / "study.json" + asyncio.run(tellegen.study_create(str(study), {"id": "s1", "input": "{}", "request": "r"})) + with pytest.raises(ValueError, match="explicit native CLI"): + asyncio.run(tellegen.study_run(str(study), 1, {"kind": "apply", "proposal": "p"})) + assert not (tmp_path / "study.json.applied").exists() + result = asyncio.run(tellegen.study_run(str(study), 1, {"kind": "inspect", "state": "base"})) + assert result["revision"] == 2 and result["experiment"] == "e1" + # The stand-in also logs a JSON trial line; only the event is progress. + assert result["progress"] == [{"event": "study_checkpoint", "index": 1}] + assert fake_binary()[-1]["argv"] == ["study", "run", str(study), "--progress"] + + +def test_binary_failure_surfaces_its_diagnostic(fake_binary): + with pytest.raises(RuntimeError, match="tellegen: boom"): + asyncio.run(tellegen._call(["boom"])) + + +def test_timeout_terminates_within_the_grace_window(fake_binary, monkeypatch): + monkeypatch.setenv("FAKE_TELLEGEN_SLEEP", "30") + monkeypatch.setenv("POWERMCP_TELLEGEN_TIMEOUT_SECONDS", "0.5") + monkeypatch.setenv("POWERMCP_TELLEGEN_CANCEL_GRACE_SECONDS", "5") + started = time.monotonic() + with pytest.raises(RuntimeError, match="timed out"): + asyncio.run(tellegen._call(["capabilities-slow"], raw_stdin=json.dumps({"schema": "pio-ir", "version": 2, "value": {"data": {}}}))) + assert time.monotonic() - started < 15 + + +def test_paths_are_contained_for_every_filesystem_argument(fake_binary, tmp_path, monkeypatch): + root = tmp_path / "root" + root.mkdir() + monkeypatch.setenv("POWERIO_MCP_ALLOWED_ROOTS", str(root)) + outside = tmp_path / "outside.pio.json" + with pytest.raises(Exception, match="outside"): + asyncio.run(tellegen.solve(path=str(CASE9))) + inside = root / "case9.m" + inside.write_text(CASE9.read_text()) + with pytest.raises(Exception, match="outside"): + asyncio.run(tellegen.solve_module(path=str(inside), out_path=str(outside))) + assert fake_binary() == [] + + +REAL = os.environ.get("TELLEGEN_BIN") + + +@pytest.mark.skipif(not REAL, reason="set TELLEGEN_BIN to a compiled tellegen CLI") +def test_real_binary_solves_case9(monkeypatch): + monkeypatch.setenv("POWERMCP_TELLEGEN_BINARY", REAL) + caps = asyncio.run(tellegen.capabilities()) + assert any(entry["formulation"] == "dcopf" and entry["available"] for entry in caps["capabilities"]) + result = asyncio.run(tellegen.solve(path=str(CASE9))) + assert result["response"]["status"] in {"optimal", "Optimal", "feasible", "Feasible"} + assert "objective" in result["response"] + assert result["value_type"] == "powerio.BalancedNetwork" + assert result["selection"] == {} and result["diagnostics"] == [] + solution = asyncio.run(tellegen.solve_module(path=str(CASE9))) + assert solution["value_type"] == "powerio.DcOpfSolution" + assert solution["termination"] == "converged" + assert solution["objective"] > 0 + with pytest.raises(ValueError, match="balanced network or a calculation instance"): + asyncio.run(tellegen.solve(path=str(DIST))) + contract = asyncio.run(tellegen.contract()) + assert contract["contract"] == "tellegen.cli/1" diff --git a/tests/test_transport.py b/tests/test_transport.py index 1e0e621..742aa23 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,18 +1,9 @@ -"""The powerio server driven the way a model drives it: over stdio, through -the MCP SDK. +"""Canonical PowerIO tools through the PowerMCP stdio runner. Every other powerio test calls the tool functions in process, which skips the -SDK's argument handling entirely. That gap hid a real defect: the SDK rewrites -a string argument whose text parses as JSON into the parsed object before -validation, which destroyed every `json` / `content` / `package_json` argument -carrying JSON before the tool saw it. powerio 0.9.0 annotates those arguments as -bare `str` on its registered tools, which is what stops the rewriting, and the -tests below are what hold it closed. Nothing in an in-process suite can see any -of this, which is why this file drives the real transport. - -Launching through `python -m powermcp run powerio` also covers the runner and -registry wiring end to end, so a broken launch fails here rather than only for -a user. +SDK's argument handling. The SDK parses a string argument whose text is JSON +before validation, so only a real stdio session proves a ``powerio_ir`` +argument reaches the tool intact. """ from __future__ import annotations @@ -25,15 +16,13 @@ import pytest -pytest.importorskip("powerio", minversion="0.9.0") +pytest.importorskip("powerio", minversion="0.11.3") from mcp import ClientSession, StdioServerParameters # noqa: E402 from mcp.client.stdio import stdio_client # noqa: E402 CASE9 = Path(__file__).resolve().parent / "data" / "case9.m" -# The SDK waits forever by default, so a server that starts and then blocks -# would hang the suite with nothing to fail it. TIMEOUT = 60.0 @@ -51,7 +40,6 @@ async def go(): ) async with stdio_client(params) as (read, write): async with ClientSession(read, write, read_timeout_seconds=TIMEOUT) as session: - # wait_for rather than asyncio.timeout: this runs on 3.10 too. await asyncio.wait_for(session.initialize(), TIMEOUT) return await steps(session) @@ -63,43 +51,42 @@ def _payload(result): return json.loads(result.content[0].text) -def test_launch_serves_the_canonical_tool_surface(): +def test_launch_serves_the_canonical_tools(): async def steps(session): return sorted(t.name for t in (await session.list_tools()).tools) required = { - "convert", + "about", + "calc_matrix", "diagnostics", "display", - "matrix", - "normalize", + "emit", "parse", - "save", - "summary", + "summarize", + "to_balanced", + "to_balanced_report", + "to_normalized", } assert required <= set(_run(steps)) def test_a_path_argument_survives_the_transport(): async def steps(session): - return _payload(await session.call_tool("summary", {"path": str(CASE9)})) + return _payload(await session.call_tool("summarize", {"path": str(CASE9)})) summary = _run(steps) - assert summary["schema"] == "powerio.summary" assert summary["elements"]["buses"] == 9 def test_non_json_content_survives_the_transport(): - # MATPOWER text does not parse as JSON, so the SDK leaves it alone. This is - # the control for the two JSON carrying cases below. async def steps(session): return _payload( await session.call_tool( - "convert", + "emit", { - "to_format": "psse", + "format": "psse", "content": CASE9.read_text(), - "from_format": "matpower", + "source_format": "matpower", }, ) ) @@ -108,37 +95,31 @@ async def steps(session): def test_the_json_transport_round_trips_over_the_transport(): - # Recorded the SDK rewriting a string that parses as JSON, which made every - # argument not annotated exactly `str` unusable. powerio 0.9.0's bare `str` - # annotations close it for every mcp 2.x, so this now asserts the round trip. async def steps(session): parsed = _payload(await session.call_tool("parse", {"path": str(CASE9)})) - assert parsed["json_format"] == "model-json" + assert parsed["value_type"] == "powerio.BalancedNetwork" return _payload( await session.call_tool( - "summary", {"json": parsed["json"], "json_format": "model-json"} + "summarize", {"powerio_ir": parsed["powerio_ir"]} ) ) assert _run(steps)["elements"]["buses"] == 9 -def test_the_package_transport_reaches_summary_over_the_transport(): - # Same SDK rewriting as above. `diagnostics` always took `package_json` as a - # required bare `str`, so it kept working while `summary` refused the same - # package text; both take it now. +def test_ir_diagnostics_and_summary_over_the_transport(): async def steps(session): parsed = _payload( await session.call_tool( - "parse", {"path": str(CASE9), "transport": "package"} + "parse", {"path": str(CASE9)} ) ) - package = parsed["package_json"] + package = parsed["powerio_ir"] assert not ( - await session.call_tool("diagnostics", {"package_json": package}) + await session.call_tool("diagnostics", {"powerio_ir": package}) ).is_error return _payload( - await session.call_tool("summary", {"package_json": package}) + await session.call_tool("summarize", {"powerio_ir": package}) ) assert _run(steps)["elements"]["buses"] == 9 diff --git a/tests/test_vendor_import.py b/tests/test_vendor_import.py index a9e67b9..8c8c424 100644 --- a/tests/test_vendor_import.py +++ b/tests/test_vendor_import.py @@ -1,4 +1,4 @@ -"""Tier-3 regression guard for the vendor-engine refactors. +"""Tier-3 regression check for the vendor-engine refactors. The key invariant: importing a vendor server module on a machine WITHOUT the vendor software must succeed and must NOT initialize the engine. The engine is @@ -46,8 +46,9 @@ def test_psse_import_side_effect_free_then_inits_once(monkeypatch): def test_plexosdb_import_side_effect_free(monkeypatch): - """plexosdb_mcp.main is an always-imports thin re-export (like powerio_mcp.py), - not a lazy _ensure_*() style module -- so "side-effect-free" here means the + """plexosdb_mcp.main is an always-imports thin re-export of the plexosdb-mcp + server object, not a lazy _ensure_*() style module, so "side-effect-free" + here means the module builds its FastMCP server using only the (mocked) upstream plexosdb_mcp.server factory, with no real plexosdb database opened, no PLEXOS XML touched, and no PLEXOS license required.