Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion agentigrid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,71 @@ Adding a generator supports two modes, chosen from the goal's wording (and named

### Custom Sweep Metric / Predicate (named registry)

Beyond the default cost metric and standard V-band/loading feasibility, a sweep can select a **named, verified primitive** (`agentigrid/engine/sweep_metrics.py`) — not free-form code. Selection is by name; the registry is extensible (`register_metric` / `register_predicate`) for later capabilities.
Beyond the default cost metric and standard V-band/loading feasibility, a sweep can select a **named, verified primitive** (`agentigrid/exago_engine/sweep_metrics.py`) — not free-form code. Selection is by name; the registry is extensible (`register_metric` / `register_predicate`) for later capabilities.

- **`metric: "max_delta_v"`** — ranks buses by the worst *system-wide* voltage step `max_b |V_candidate[b] − V_base[b]|` caused by switching in a load block (`search.switched_load_mw`) at the candidate bus. A power-quality flag; the largest step need not be at the switched bus. Requires (and triggers) a one-time base-case solve for the reference voltages.
- **`feasibility_predicate: "reactive_adequacy"`** — replaces standard feasibility with a reactive-headroom test: a feasible OPF must exist with the added unit forced to `(P = Pmax, Q = Qmax)` (the sweep pins `Qmin = Qmax`). Returns the limiting quantity when it fails.

## Transient Stability (GridKit)

`--tool gridkit` runs transient-stability studies with GridKit's `DynamicSimulation`
on a GridKit case (`*.case.json`). The LLM turns the goal into one fault screen
(one bus short circuit to ground per run: given buses, every bus, or a POI and the
buses one branch away); AgentiGrid adds the fault devices to a copy of the case,
runs GridKit, and judges each run with PJM checks (angle stability, voltage recovery
0.70 pu at 2.5 s, 3% damping, final voltage). PJM tests that need elements GridKit
does not have (line/generator trips, reclosing, relays, load ride-through) are
listed as SKIPPED, never passed.

```bash
agentigrid --tool gridkit --config configs/local_config.yaml \
data/gridkit/examples/IEEE39.case.json \
"Assume a short circuit fault at each bus of the system, tell me if the system can survive without blackout."
```

**Starting from the latest ExaGO steady state.** A fault screen with
`"start_from": "latest_steady_state"` (the LLM sets it when the goal asks for "the most
recent steady state"; `--from-exago` forces it for the whole session) uses the GridKit
case only for the dynamic models; the operating point and network come from the last
simulation of the newest ExaGO journal in `workdir/exago/` (OPFLOW/PFLOW save their
solved case, `opflowout.m` / `pflowout.m`, in the iteration folder). Bus voltages,
machine `p0`/`q0`, loads, shunts and branches are taken from ExaGO; machines of
generators that are off in ExaGO are removed with their controls. That start gets its
own no-fault run before any fault. The screen is not run if that simulation did not
converge, was a sweep, or has an online generator without a dynamic model in the
GridKit case. The converted case is saved as `from_exago.case.json` in the session
folder.

**Saved solutions** (`exago.save_solutions` in the config, or
`--set exago.save_solutions=<value>`). Single solves (base case, `modify`, `select`)
always keep their solved case. For an action with many solves (sweep, explore,
boundary, contingency, reserve):

| Value | Kept |
|---|---|
| `best` (default) | the best run: cheapest feasible sweep candidate (or best by the sweep's metric), largest hosting capacity (boundary), Pareto-best variant (explore); the most recent if the action has no best (contingency, reserve) |
| `newest` | the most recent run |
| `all` | every run |
| `none` | nothing is written; GridKit cannot start from the latest ExaGO steady state |

Only the solution files are removed; run folders and their input cases stay. After an
`explore`, pruning waits for the next action, so a variant chosen by `select` keeps
its solution.

**ContingencyAnalysis.** With `"application": "ContingencyAnalysis"` (the LLM sets it
when the goal asks for contingency analysis), one GridKit `ContingencyAnalysis` process
runs all bus faults of the screen, writing `output_<fault index>.csv` per fault in the
screen's `contingency/` folder; the checks and verdict are the same as with
`DynamicSimulation`. `ContingencyAnalysis` runs every fault device in the case file, so
its case copy holds only the requested faults (fault devices shipped with the case,
such as a demo fault, are left out of the copy). With `DynamicSimulation` they stay in
the copy but are never switched on.

Link the GridKit binaries and cases first (see [applications/gridkit/README.md](applications/gridkit/README.md)
and [data/gridkit/README.md](data/gridkit/README.md)). Sessions are written to
`workdir/gridkit/session_<time>/` (element map, case copies, one folder per fault,
`journal.json`); logs to `logs/gridkit/`.

## Search Modes

- **Boundary finding** — "Find the maximum load scaling factor before infeasibility"
Expand Down Expand Up @@ -410,10 +470,19 @@ pip install -e .

Copy or symlink ExaGO binaries into `applications/exago/` (see [applications/exago/README.md](applications/exago/README.md)) and place your own network data files in `data/exago/datafiles/`; ExaGO's example data is linked into `data/exago/examples/` (see [data/exago/README.md](data/exago/README.md)).

For transient studies, link GridKit's binaries into `applications/gridkit/` and its example cases into `data/gridkit/examples/` (see the READMEs in those folders).

## Configuration

Edit `configs/default_config.yaml` or pass `--config path/to/config.yaml`. CLI arguments override config file values.

One config file holds the settings for both tools: the `exago:` and `data:` sections
for ExaGO, and the `gridkit:` section for GridKit (binary, timeout per run, the
solver values written into each run's solver `.json`, and the default fault start).
The LLM, iteration limit, worker count and output folders are shared. PJM pass/fail
limits are not configurable; they live in `agentigrid/gridkit_engine/criteria.py`
with their sources.

Set your API key as an environment variable:
```bash
export ANTHROPIC_API_KEY="your-key-here"
Expand Down
98 changes: 92 additions & 6 deletions agentigrid/agentigrid/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import logging
import sys
import threading
from dataclasses import fields
from dataclasses import fields, replace
from datetime import datetime
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -201,6 +201,23 @@ def build_parser() -> argparse.ArgumentParser:
help="Override any config value via dotted key, e.g. "
"--set report.network_summary_max_generators=40 (repeatable)",
)
parser.add_argument(
"--tool",
choices=["exago", "gridkit"],
default="exago",
help="Simulation tool: exago (steady state, .m case) or gridkit "
"(transient stability, .case.json case). Default: exago",
)
parser.add_argument(
"--from-exago",
action="store_true",
default=False,
help="GridKit only: start from the most recent ExaGO steady state "
"(last simulation of the newest journal in workdir/exago) instead of "
"the case's own operating point, for every fault screen. Without it, "
"the LLM chooses per fault screen (start_from), e.g. when the goal "
"asks for the most recent steady state.",
)
parser.add_argument(
"--version",
action="version",
Expand Down Expand Up @@ -351,7 +368,8 @@ def _listen():
controller.request_stop()
print("[Steering] Stop requested.")
elif lower == "save":
save_dir = Path(f"workdir/saved_session_{datetime.now().strftime('%Y%m%d_%H%M%S')}")
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
save_dir = controller._config.output.workdir / f"saved_session_{stamp}"
controller.save_session(save_dir)
print(f"[Steering] Session saved to: {save_dir}")
elif lower == "status":
Expand Down Expand Up @@ -382,7 +400,7 @@ def _listen():

def run_search(cfg: AppConfig, goal: str, quiet: bool = False) -> None:
"""Run the LLM-driven search loop."""
from agentigrid.engine.agent_loop import AgentLoopController
from agentigrid.exago_engine.agent_loop import AgentLoopController

controller = AgentLoopController(cfg, quiet=quiet)

Expand All @@ -393,6 +411,69 @@ def run_search(cfg: AppConfig, goal: str, quiet: bool = False) -> None:
controller.run(cfg.search.base_case, goal)


def exago_output(cfg: AppConfig) -> AppConfig:
"""ExaGO runs write to an ``exago/`` subfolder of the output folders,
as GridKit runs write to ``gridkit/``."""
return replace(cfg, output=replace(
cfg.output,
workdir=Path(cfg.output.workdir) / "exago",
logs_dir=Path(cfg.output.logs_dir) / "exago",
))


def run_gridkit(cfg: AppConfig, args: argparse.Namespace) -> None:
"""Run a GridKit transient-stability study (``--tool gridkit``)."""
from agentigrid.gridkit_engine.agent_loop import GridkitAgentLoop
from agentigrid.gridkit_engine.settings import GridkitSettings

if args.resume:
raise SystemExit("--resume is not available with --tool gridkit yet")
settings = GridkitSettings.from_config(cfg)
setup_logging(settings.logs_dir, verbose=cfg.output.verbose)
case_file = Path(args.base_case) if args.base_case else None
if case_file is None or args.goal is None:
raise SystemExit("base_case (GridKit .case.json) and goal are required")
if not case_file.exists():
logger.error("Case file does not exist: %s", case_file)
sys.exit(1)
from agentigrid.gridkit_engine.from_exago import (
SteadyStateError, find_latest_steady_state,
)

steady_state = None
if args.from_exago:
try:
steady_state = find_latest_steady_state(exago_output(cfg).output.workdir)
except SteadyStateError as exc:
logger.error("%s", exc)
sys.exit(1)
if args.dry_run:
print(f"GridKit settings: {settings}")
if steady_state:
print(f"Start from: {steady_state.describe()}")
return
quiet = getattr(args, "quiet", False) or False
if not quiet:
print("=" * 60)
print(" AgentiGrid — transient stability with GridKit")
print("=" * 60)
print(f" Backend: {cfg.llm.backend} ({cfg.llm.model})")
print(f" Case: {case_file}")
print(f" Goal: {args.goal}")
print(f" Max iterations: {settings.max_iterations}")
print("=" * 60)
from agentigrid.gridkit_parsers.case_parser import CaseFileError

try:
session = GridkitAgentLoop(cfg, settings=settings, quiet=quiet).run(
case_file, args.goal, steady_state=steady_state)
except (FileNotFoundError, CaseFileError, SteadyStateError) as exc:
logger.error("%s", exc)
sys.exit(1)
if session.termination_reason in ("llm_error", "base_case_failed", "parse_failures"):
sys.exit(1)


def run_regenerate_report(argv: list[str] | None = None) -> None:
"""Offline `regenerate-report` subcommand: rebuild analysis+PDF from a journal.

Expand All @@ -401,7 +482,7 @@ def run_regenerate_report(argv: list[str] | None = None) -> None:
renders from deterministic journal data only.
"""
from agentigrid.backends import create_backend
from agentigrid.engine.regenerate_report import regenerate_from_journal
from agentigrid.exago_engine.regenerate_report import regenerate_from_journal

sub = argparse.ArgumentParser(
prog="agentigrid regenerate-report",
Expand All @@ -424,7 +505,7 @@ def run_regenerate_report(argv: list[str] | None = None) -> None:
overrides["llm.backend"] = args.backend
if args.model:
overrides["llm.model"] = args.model
cfg = load_config(Path(args.config), cli_overrides=overrides)
cfg = exago_output(load_config(Path(args.config), cli_overrides=overrides))
setup_logging(cfg.output.logs_dir, verbose=cfg.output.verbose)

generate_analysis = not args.no_analysis
Expand Down Expand Up @@ -459,6 +540,11 @@ def main(argv: list[str] | None = None) -> None:
overrides = _cli_overrides(args)
cfg = load_config(Path(args.config), cli_overrides=overrides)

if args.tool == "gridkit":
run_gridkit(cfg, args)
return
cfg = exago_output(cfg)

# Set up logging
verbose = cfg.output.verbose
setup_logging(cfg.output.logs_dir, verbose=verbose)
Expand Down Expand Up @@ -488,7 +574,7 @@ def main(argv: list[str] | None = None) -> None:
sys.exit(1)
if not quiet:
print(f"Resuming session from: {resume_dir}")
from agentigrid.engine.agent_loop import AgentLoopController
from agentigrid.exago_engine.agent_loop import AgentLoopController
controller = AgentLoopController(cfg, quiet=quiet)
if sys.stdin.isatty():
_start_stdin_listener(controller)
Expand Down
Loading
Loading