From eda8b7987400077135f1402346dd97621fd68d0c Mon Sep 17 00:00:00 2001 From: tsybinae Date: Wed, 7 Oct 2026 14:31:04 -0700 Subject: [PATCH 1/2] AgentiGrid: substation-based N-1/N-2 contingency screen, verdict, fast-only relief (#134) - Study area from substation tiers (transformers join buses, lines are hops, default depth 3) instead of k nearest buses; five affected-element lists; new set_shunt command; mutation-driven contingency sweep. - Verdict line and table of every failed contingency; per-element failure counts for N-2; studies over 500 contingencies are reported as not completable instead of being shrunk. - Agent's full findings kept in the journal, CSV and saved session. - Relief: tap change, line switching out or in, tiered load shedding capped at 10% per bus; added load never shed; generator_redispatch removed; relief only when the goal asks; relief in the sweep cache key. - Objective extraction no longer invents objectives or limits. - Prompt templates moved to prompts/exago/; restored identical-sweep rule. - Live opflow test no longer overwrites tests/fixtures/sample_opflow_output.txt; test_solve_time matches the committed sample. Co-authored-by: tsybinae <186317713+tsybinae@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- agentigrid/agentigrid/config.py | 4 + agentigrid/agentigrid/engine/agent_loop.py | 352 ++++++--- agentigrid/agentigrid/engine/commands.py | 16 +- agentigrid/agentigrid/engine/contingency.py | 455 +++++++++--- agentigrid/agentigrid/engine/explore.py | 7 + agentigrid/agentigrid/engine/journal.py | 70 +- agentigrid/agentigrid/engine/modifier.py | 13 + .../agentigrid/engine/objective_parser.py | 16 +- agentigrid/agentigrid/engine/relief.py | 171 +++-- agentigrid/agentigrid/engine/session_io.py | 1 + agentigrid/agentigrid/engine/topology.py | 220 +++--- agentigrid/agentigrid/engine/validation.py | 6 + agentigrid/agentigrid/prompts/__init__.py | 7 +- .../agentigrid/prompts/exago/__init__.py | 6 + .../prompts/{ => exago}/system_prompt.py | 673 +++++++++++------- .../prompts/{ => exago}/user_prompt.py | 0 agentigrid/agentigrid_architecture.md | 4 +- agentigrid/launcher/app.py | 23 +- agentigrid/launcher/report_generator.py | 35 +- agentigrid/tests/agent_loop/test_steering.py | 2 +- agentigrid/tests/analyses/test_contingency.py | 661 +++++++++++++---- agentigrid/tests/analyses/test_explore.py | 20 +- agentigrid/tests/analyses/test_relief.py | 284 ++++++-- agentigrid/tests/analyses/test_reserve.py | 7 +- agentigrid/tests/analyses/test_reserve_min.py | 2 +- .../tests/analyses/test_sweep_corrections.py | 2 +- agentigrid/tests/analyses/test_topology.py | 237 ++---- agentigrid/tests/exago/test_pflow.py | 6 +- agentigrid/tests/exago/test_sopflow.py | 2 +- agentigrid/tests/exago/test_tcopflow.py | 2 +- agentigrid/tests/journal/test_journal.py | 41 +- .../tests/journal/test_regenerate_report.py | 11 +- agentigrid/tests/journal/test_session_io.py | 12 + .../tests/results/test_opflow_parser.py | 2 +- agentigrid/tests/runner/test_executor.py | 8 +- 35 files changed, 2277 insertions(+), 1101 deletions(-) create mode 100644 agentigrid/agentigrid/prompts/exago/__init__.py rename agentigrid/agentigrid/prompts/{ => exago}/system_prompt.py (66%) rename agentigrid/agentigrid/prompts/{ => exago}/user_prompt.py (100%) diff --git a/agentigrid/agentigrid/config.py b/agentigrid/agentigrid/config.py index bca2bade..ce52e93c 100644 --- a/agentigrid/agentigrid/config.py +++ b/agentigrid/agentigrid/config.py @@ -80,6 +80,8 @@ "load_factor": None, "relief_tap_steps": [0.90, 0.95, 1.00, 1.05, 1.10], "relief_curtail_tol_mw": 1.0, + "relief_shed_max_fraction": 0.10, + "relief_shed_max_depth": 3, "relief_max_solves": 2000, "reserve_max_solves": 4000, }, @@ -182,6 +184,8 @@ class SearchConfig: # Contingency relief (C.7) — bounds for the per-failure relief-measure search relief_tap_steps: list = field(default_factory=lambda: [0.90, 0.95, 1.00, 1.05, 1.10]) # transformer tap ratios tried relief_curtail_tol_mw: float = 1.0 # Load-curtailment bisection stops below this MW gap + relief_shed_max_fraction: float = 0.10 # Max share of each bus's load that relief may shed + relief_shed_max_depth: int = 3 # Load shedding grows tier by tier around the outage up to this depth relief_max_solves: int = 2000 # Total relief solves budget across all failed contingencies # Reserve minimization (C.8-revised Path A) — solve budget for the greedy de-commitment search reserve_max_solves: int = 4000 # Total OPF solves budget across the greedy reserve-minimization pass diff --git a/agentigrid/agentigrid/engine/agent_loop.py b/agentigrid/agentigrid/engine/agent_loop.py index 23dae310..72763d59 100644 --- a/agentigrid/agentigrid/engine/agent_loop.py +++ b/agentigrid/agentigrid/engine/agent_loop.py @@ -61,7 +61,7 @@ def _count_scenario_rows(scenario_path: Path) -> int: return max(len(lines) - 1, 1) except (OSError, UnicodeDecodeError): return 1 -from agentigrid.prompts import build_system_prompt, build_user_prompt +from agentigrid.prompts.exago import build_system_prompt, build_user_prompt from agentigrid.engine.goal_classifier import build_classification_prompts, parse_goal_classification logger = logging.getLogger("agentigrid.engine.agent_loop") @@ -783,6 +783,8 @@ def __init__( # Sweep dedup (Fix 4): cache results by sweep signature within this session # so an identical re-requested sweep is served from cache, not re-solved. self._sweep_signature_cache: dict[str, dict] = {} + # Goal text of the running session (set each iteration); the relief guard reads it. + self._current_goal: str = "" # Pause/resume self._pause_event = threading.Event() @@ -1019,7 +1021,7 @@ def run(self, base_case: Path, goal: str) -> SearchSession: ): try: from agentigrid.engine.benchmark import _run_opflow_on_base_case - from agentigrid.prompts.system_prompt import format_benchmark_for_prompt + from agentigrid.prompts.exago.system_prompt import format_benchmark_for_prompt self._print("[Session] Running OPFLOW baseline for benchmark reference...") opflow_baseline = _run_opflow_on_base_case(base_case, self._config) if opflow_baseline is not None: @@ -1075,7 +1077,7 @@ def run(self, base_case: Path, goal: str) -> SearchSession: self._journal.load_factor = self._session_load_factor # Build system prompt once (static per session) - from agentigrid.prompts.system_prompt import format_benchmark_for_prompt as _fmt_bench + from agentigrid.prompts.exago.system_prompt import format_benchmark_for_prompt as _fmt_bench self._system_prompt = build_system_prompt( command_schema=command_schema_text(), network_summary=net_summary_text, @@ -1276,6 +1278,7 @@ def _iteration( goal: str, ) -> tuple[str, bool]: """Execute one iteration. Returns (action_type, should_continue).""" + self._current_goal = goal # Drain the steering queue at the iteration boundary new_directives: list[dict] = [] while True: @@ -2111,11 +2114,39 @@ def _augment_generator_mutation( "mode", "entity", "power_factor", "mutation", "candidate_set", "feasibility", "metric", "feasibility_predicate", "entity_dispatchable", "entity_cost_coeffs", # C.5 contingency screen — an identical study is deterministic and served from cache. - "target_bus", "neighbor_count", "contingency_order", "components", + "substation_depth", "contingency_order", "components", + # C.7 relief — a screen with relief is a different study from one without. + "relief_measures", # C.8 Path A reserve minimization — the minimize flag changes the study. "minimize", ) + def _relief_requested(self) -> bool: + """True if the goal or an active steering directive asks for relief measures.""" + texts = [self._current_goal] + [ + d.get("directive", "") for d in self._active_steering_directives + ] + return any("relief" in (t or "").lower() for t in texts) + + def _strip_unrequested_relief(self, iteration: int, data: dict) -> str: + """Drop ``relief_measures`` from a request when the user did not ask for relief. + + A contingency test only reports; relief runs only when the goal (or an + operator steering directive) asks for relief measures. Returns a note for + the agent when relief was dropped, else "". + """ + if not data.get("relief_measures") or self._relief_requested(): + return "" + data.pop("relief_measures") + self._print( + f"[Iter {iteration}] relief_measures ignored: the goal does not ask for relief" + ) + return ( + "\n\n[relief_measures ignored: the goal does not ask for relief measures. " + "The screen was run as a test only. Report its result with complete; " + "do not request relief.]" + ) + def _sweep_cache_key(self, data: dict) -> str: """Stable signature for a sweep request (Fix 4 dedup).""" payload = {k: data.get(k) for k in self._SWEEP_SIGNATURE_KEYS} @@ -2175,21 +2206,32 @@ def _handle_sweep( ) return "error", True + # Relief guard: relief is run only when the user asked for it. Checked before + # the cache so a stripped request matches the plain screen that already ran. + relief_note = self._strip_unrequested_relief(iteration, data) + # Fix 4: a sweep is deterministic — if an identical signature already ran # this session, serve the cached result instead of re-solving. Covers both # the feasibility/metric path and the boundary path (checked before dispatch). cache_key = self._sweep_cache_key(data) if cache_key in self._sweep_signature_cache: - return self._serve_cached_sweep(iteration, data, cache_key) + result = self._serve_cached_sweep(iteration, data, cache_key) + if relief_note: + self._latest_results_text = (self._latest_results_text or "") + relief_note + return result # Boundary (hosting-capacity) mode: per-candidate bisection on injection magnitude. if data.get("mode") == "boundary": return self._handle_boundary_sweep(iteration, data) - # Contingency (N-1/N-2) mode: enumerate outages on the target's neighbors, + # Contingency (N-1/N-2) mode: apply the mutation, enumerate outages within the + # substations around the changed element, # re-solve each on top of the current operating point, tabulate pass/fail. if data.get("mode") == "contingency": - return self._handle_contingency_sweep(iteration, data) + result = self._handle_contingency_sweep(iteration, data) + if relief_note and result[0] != "error": + self._latest_results_text = (self._latest_results_text or "") + relief_note + return result # Hot-reserve / minimum N-1 generator security (C.8): system-wide gen screen. if data.get("mode") == "reserve": @@ -2931,7 +2973,7 @@ def _ctg_progress(done: int, total: int) -> None: "label": ctg.label(), "order": ctg.order, "kinds": [e.kind for e in ctg.elements], - "neighbor_buses": [e.neighbor_bus for e in ctg.elements], + "tiers": [e.tier for e in ctg.elements], "elements": [e.to_command() for e in ctg.elements], "passed": False, "voltage_min": 0.0, @@ -2960,7 +3002,7 @@ def _ctg_progress(done: int, total: int) -> None: "label": ctg.label(), "order": ctg.order, "kinds": [e.kind for e in ctg.elements], - "neighbor_buses": [e.neighbor_bus for e in ctg.elements], + "tiers": [e.tier for e in ctg.elements], "elements": [e.to_command() for e in ctg.elements], "passed": passed, "voltage_min": opflow.voltage_min if opflow else 0.0, @@ -2989,10 +3031,14 @@ def _handle_contingency_sweep( ) -> tuple[str, bool]: """Handle a contingency (N-1/N-2) screen — one LLM action, whole study in Python. - Enumerates outages drawn from the k nearest neighbor buses of a target, - applies each outage set ON TOP OF THE CURRENT OPERATING POINT (so a prior - `modify` that connected the load is included), re-solves OPFLOW, and judges - pass/fail. + Applies the ``mutation`` (one command, e.g. add_load_at_bus) to a copy of the + CURRENT OPERATING POINT, then enumerates outages of every element (branch, + generator, load, shunt) in the substations within ``substation_depth`` line + hops of the element the mutation changed, excluding that element. Buses + joined by a transformer form one substation (0 hops). Each outage set is + applied on top of the mutated operating point, OPFLOW is re-solved, and + pass/fail is judged. The mutation is applied for this study only; the + session's current network is unchanged. Feasibility semantics: under OPFLOW the V-band (via set_all_bus_vlimits) and Rate A are in-solve hard constraints, so a contingency PASSES iff the @@ -3016,23 +3062,29 @@ def _handle_contingency_sweep( reasoning = data.get("reasoning", "") # --- validate parameters --- - raw_target = data.get("target_bus") - if raw_target is None: - self._error_feedback = "contingency sweep requires a 'target_bus' (integer bus number)." + # Operating point: the CURRENT network with the mutation applied (study only). + if "mutation" not in data: + self._error_feedback = ( + 'contingency sweep requires "mutation", the one command the goal describes, ' + f"with action one of {list(contingency.MUTATION_ACTIONS)}, e.g. " + '{"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}.' + ) return "error", True + before = self._current_network or self._base_network try: - target_bus = int(raw_target) - except (TypeError, ValueError): - self._error_feedback = f"contingency 'target_bus' must be an integer, got {raw_target!r}." + net, changed = contingency.apply_mutation(before, data["mutation"]) + except ValueError as exc: + self._error_feedback = f"contingency 'mutation' error: {exc}" return "error", True + raw_depth = data.get("substation_depth", contingency.DEFAULT_SUBSTATION_DEPTH) try: - neighbor_count = int(data.get("neighbor_count", 3)) + depth = int(raw_depth) except (TypeError, ValueError): - self._error_feedback = f"contingency 'neighbor_count' must be an integer, got {data.get('neighbor_count')!r}." + self._error_feedback = f"contingency 'substation_depth' must be an integer, got {raw_depth!r}." return "error", True - if neighbor_count < 1: - self._error_feedback = "contingency 'neighbor_count' must be >= 1." + if depth < 0: + self._error_feedback = "contingency 'substation_depth' must be >= 0." return "error", True try: @@ -3044,14 +3096,14 @@ def _handle_contingency_sweep( self._error_feedback = "contingency 'contingency_order' must be 1 (N-1) or 2 (N-2)." return "error", True - components = data.get("components", ["branch", "gen", "load"]) + components = data.get("components", list(contingency.OUTAGE_KINDS)) if not isinstance(components, list) or not components: self._error_feedback = ( "contingency 'components' must be a non-empty list drawn from " - "['branch', 'gen', 'load']." + f"{list(contingency.OUTAGE_KINDS)}." ) return "error", True - _valid_kinds = {"branch", "gen", "load"} + _valid_kinds = set(contingency.OUTAGE_KINDS) if any(c not in _valid_kinds for c in components): self._error_feedback = ( f"contingency 'components' may only contain {sorted(_valid_kinds)}; " @@ -3081,40 +3133,64 @@ def _handle_contingency_sweep( ) return "error", True - # Operating point: apply the screen on top of the CURRENT network (post-modify). - net = self._current_network or self._base_network - # --- enumerate --- try: - contingencies = contingency.enumerate_contingencies( - net, target_bus, neighbor_count, order, tuple(components), - ) + affected = contingency.find_affected_elements(net, changed, depth) + pool = contingency.outages_from(affected, tuple(components)) + contingencies = contingency.enumerate_contingencies(pool, order) except ValueError as exc: self._error_feedback = f"contingency enumeration error: {exc}" return "error", True if not contingencies: self._error_feedback = ( - f"No contingencies enumerated for target bus {target_bus} " - f"(neighbors={neighbor_count}, components={components}). The neighbor " - "buses may have no outage-eligible elements of the requested kinds." + f"No contingencies enumerated around {changed.label()} " + f"(substation_depth={depth}, components={components}). The substations in " + "scope may have no outage-eligible elements of the requested kinds." ) return "error", True + # Too large: stop without solving and say so. The study is not shrunk (lower + # depth, order or components) — that would answer a different question. max_count = self._config.search.contingency_max_count if len(contingencies) > max_count: - self._error_feedback = ( - f"Contingency screen would enumerate {len(contingencies)} contingencies, " - f"exceeding the guard of {max_count}. Lower neighbor_count, reduce " - f"contingency_order (2→1), or narrow components to shrink the study." + verdict = ( + f"The number of contingencies ({len(contingencies)}) exceeds {max_count}, " + "the study cannot be completed with existing resources." ) - return "error", True + self._print(f"[Iter {iteration}] {verdict}") + self._latest_results_text = "\n".join([ + f"Contingency screen (N-{order}) around changed element {changed.label()}: " + f"{len(contingencies)} contingencies over {len(affected.substations)} " + f"substations / {len(affected.buses)} buses within substation depth {depth}.", + "", + f"VERDICT: {verdict}", + "", + "No contingency was solved. Answer with complete; summary = the VERDICT " + "line. Do not shrink the study (depth, order or components) to make it fit.", + ]) + self._journal.add_contingency( + iteration=iteration, + description=f"[contingency N-{order}] {description} (not run: too many contingencies)", + mutation=data["mutation"], + changed_element=changed.to_dict(), + substation_depth=depth, + affected=affected.to_dict(), + order=order, + contingency_summaries=[], + passed_count=0, + failed_count=0, + passes=False, + verdict=f"VERDICT: {verdict}", + llm_reasoning=reasoning, + ) + return "sweep", True - neighbors = topology.k_nearest_by_hops(net, target_bus, neighbor_count) self._print( f'[Iter {iteration}] LLM action: contingency sweep — "{description}" ' - f"(target bus {target_bus}, N-{order}, {len(contingencies)} contingencies, " - f"neighbors={[nb for nb, _ in neighbors]})" + f"(changed {changed.label()}, N-{order}, {len(contingencies)} contingencies, " + f"{len(affected.substations)} substations / {len(affected.buses)} buses within " + f"depth {depth})" ) if self._on_phase: @@ -3139,6 +3215,9 @@ def _handle_contingency_sweep( passed_count = sum(1 for s in contingency_summaries if s["passed"]) failed_count = len(contingency_summaries) - passed_count + passes, verdict = contingency.screen_verdict( + changed, order, len(contingency_summaries), failed_count, ref_passed, ref_reason, + ) self._latest_opflow = reference["opflow"] or reference["first_opflow"] # --- relief phase (C.7): search relief measures for each FAILED contingency --- @@ -3146,21 +3225,24 @@ def _handle_contingency_sweep( self._run_relief_phase( iteration, net, contingencies, contingency_summaries, relief_measures, vmin, vmax, bus_limits, + protected_load=contingency.added_load(before, net, changed), ) # --- LLM-facing view (token-bounded) --- self._latest_results_text = self._build_contingency_llm_view( - target_bus=target_bus, - neighbors=neighbors, + changed=changed, + affected=affected, order=order, components=components, vmin=vmin, vmax=vmax, + contingencies=contingencies, contingency_summaries=contingency_summaries, passed_count=passed_count, failed_count=failed_count, ref_passed=ref_passed, ref_reason=ref_reason, + verdict=verdict, threshold=self._config.search.sweep_full_table_threshold, top_n=self._config.search.sweep_llm_top_n, relief_measures=relief_measures, @@ -3176,18 +3258,23 @@ def _handle_contingency_sweep( "contingency", len(contingencies), _representative_sim, "Executed once per enumerated contingency. Each outage set is written into " "the per-contingency netfile (via set_branch_status / set_gen_status / " - "set_load), not passed as an ExaGO argument; only the -netfile path differs. " + "set_load / set_shunt), not passed as an ExaGO argument; only the -netfile " + "path differs. " "The representative shown is the pre-contingency reference solve.", ) self._journal.add_contingency( iteration=iteration, description=f"[contingency N-{order}] {description} (on current operating point)", - target_bus=target_bus, - neighbors=neighbors, + mutation=data["mutation"], + changed_element=changed.to_dict(), + substation_depth=depth, + affected=affected.to_dict(), order=order, contingency_summaries=contingency_summaries, passed_count=passed_count, failed_count=failed_count, + passes=passes, + verdict=verdict, llm_reasoning=reasoning, steering_directive=active_directive, exago_command=_ctg_command, @@ -3200,8 +3287,9 @@ def _handle_contingency_sweep( self._print( f"[Iter {iteration}] Contingency screen complete: " f"{passed_count} passed / {failed_count} failed of {len(contingencies)} " - f"(N-{order}, target bus {target_bus})" + f"(N-{order}, changed {changed.label()})" ) + self._print(f"[Iter {iteration}] {verdict}") return "sweep", True def _handle_reserve_screen( @@ -3590,9 +3678,13 @@ def _run_relief_phase( vmin: float, vmax: float, bus_limits: dict, + protected_load: dict[int, tuple[float, float]] | None = None, ) -> None: """Search relief measures for each FAILED contingency (C.7); attach 'relief' payloads. + ``protected_load`` ({bus: (Pd, Qd)}) is the load the mutation added; load + shedding never touches it. + Uses the same executor solve as C.5 via an injected ``solve_fn``. A shared solve counter enforces ``relief_max_solves``: once the budget is spent, remaining failures are marked 'relief budget exhausted' rather than searched. @@ -3638,7 +3730,7 @@ def _solve_fn(mnet: MATNetwork): continue result = relief_search.find_relief( net, contingencies[i], relief_measures, vmin, vmax, - _solve_fn, self._config.search, + _solve_fn, self._config.search, protected_load=protected_load, ) summary["relief"] = { "resolved": result.resolved, @@ -3658,30 +3750,38 @@ def _solve_fn(mnet: MATNetwork): def _build_contingency_llm_view( self, - target_bus: int, - neighbors: list[tuple[int, int]], + changed: "contingency.ChangedElement", + affected: "contingency.AffectedElements", order: int, components: list[str], vmin: float, vmax: float, + contingencies: list["contingency.Contingency"], contingency_summaries: list[dict], passed_count: int, failed_count: int, ref_passed: bool, ref_reason: str, + verdict: str, threshold: int, top_n: int, relief_measures: list[str] | None = None, ) -> str: """Token-bounded LLM view of a contingency screen, mirroring the sweep view gating. - At or below ``threshold`` contingencies, a full pass/fail table is shown. - Above it, the FAILED set is listed in full (grouped by reason) with a capped - sample of passers plus aggregate counts and a journal/PDF pointer — the LLM - needs the complete failed set to answer, so failures are never truncated. + Always starts with the verdict (the change passes N-k only if every + contingency passes) and a table of every FAILED contingency — failures are + never truncated. For N-2 a second table counts, per element, the failed + pairs it appears in. At or below ``threshold`` contingencies the full + pass/fail table follows; above it, a capped sample of passers and a journal + pointer. """ n_total = len(contingency_summaries) - nbr_str = ", ".join(f"{nb}(h{hop})" for nb, hop in neighbors) + tier_parts = [] + for d, tier in enumerate(affected.tiers): + shown = ", ".join(str(s) for s in tier[:30]) + more = f", ... +{len(tier) - 30} more" if len(tier) > 30 else "" + tier_parts.append(f"tier {d}: [{shown}{more}]") comp_str = "/".join(components) ref_line = ( "Pre-contingency reference: FEASIBLE under the band." @@ -3690,15 +3790,55 @@ def _build_contingency_llm_view( "— base operating point does not hold under the band; interpret results with care." ) header = [ - f"Contingency screen (N-{order}) on target bus {target_bus}: " - f"{n_total} contingencies over nearest neighbors [{nbr_str}].", + f"Contingency screen (N-{order}) around changed element {changed.label()} " + f"(excluded from outages): {n_total} contingencies over " + f"{len(affected.substations)} substations / {len(affected.buses)} buses within " + f"substation depth {affected.depth}.", + "Substations by tier (id = lowest bus number; transformers add no hop): " + + "; ".join(tier_parts) + ".", + f"Affected elements: {len(affected.branches)} branches, {len(affected.gens)} " + f"generators, {len(affected.loads)} loads, {len(affected.shunts)} shunts.", f"Components: {comp_str}. Feasibility band: Vmin={vmin}, Vmax={vmax} " f"(OPF-redispatch model — PASS = post-outage OPFLOW converges feasibly).", f"PASSED: {passed_count} / {n_total} FAILED: {failed_count} / {n_total}", ref_line, "", + verdict, + "", ] + # Every failed contingency, always in full. + failed = [s for s in contingency_summaries if not s["passed"]] + header.append(f"FAILED contingencies ({failed_count} of {n_total}):") + if failed: + header.append(f"{'contingency':<36} | {'kinds':<14} | {'tier':<5} | reason") + for s in failed: + kinds_str = "+".join(s["kinds"]) + tiers_str = ",".join(str(t) for t in s.get("tiers") or []) + header.append( + f"{s['label']:<36} | {kinds_str:<14} | {tiers_str:<5} | " + f"{s.get('reason') or 'did not converge'}" + ) + else: + header.append(" (none)") + header.append("") + + # N-2: which elements the failures come from. + if order > 1 and failed: + counts = contingency.failed_element_counts( + contingencies, [s["passed"] for s in contingency_summaries], + ) + header.append( + "Elements in failed contingencies (failed pairs containing the element, " + "of all pairs containing it):" + ) + header.append(f"{'element':<24} | {'tier':>4} | {'failed':>6} | {'of':>4}") + for elem, n_failed, n_with in counts: + header.append( + f"{elem.label():<24} | {elem.tier:>4} | {n_failed:>6} | {n_with:>4}" + ) + header.append("") + # Relief section (C.7) — appended to whichever table branch is used below. relief_lines = self._relief_section_lines( contingency_summaries, failed_count, relief_measures, @@ -3707,6 +3847,7 @@ def _build_contingency_llm_view( # --- full-table branch --- if threshold > 0 and n_total <= threshold: lines = header + [ + "All contingencies:", f"{'contingency':<28} | {'kinds':<14} | {'pass':>4} | " f"{'Vmin':>5} | {'Vmax':>5} | {'maxLoad%':>8} | reason", ] @@ -3721,23 +3862,9 @@ def _build_contingency_llm_view( lines += relief_lines return "\n".join(lines) - # --- summarized branch (FAILED set never truncated) --- + # --- summarized branch (FAILED set above is never truncated) --- lines = list(header) - failed = [s for s in contingency_summaries if not s["passed"]] passed = [s for s in contingency_summaries if s["passed"]] - - failed_by_reason: dict[str, list[str]] = defaultdict(list) - for s in failed: - failed_by_reason[s.get("reason") or "did not converge"].append(s["label"]) - lines.append(f"FAILED contingencies grouped by reason ({failed_count} total):") - if failed_by_reason: - for reason in sorted(failed_by_reason): - labels = failed_by_reason[reason] - lines.append(f" {reason} ({len(labels)}): {labels}") - else: - lines.append(" (none)") - lines.append("") - sample = passed[:top_n] lines.append( f"Passed contingencies ({passed_count} total; showing {len(sample)}):" @@ -3937,6 +4064,7 @@ def _handle_complete( self._journal.add_complete( iteration=iteration, summary=summary_text, + findings=findings or None, ) self._final_findings = findings @@ -3972,8 +4100,14 @@ def _handle_analyze( def _handle_topology_analyze( self, iteration: int, data: dict, query_type: str ) -> tuple[str, bool]: - """Handle a structured topology query — deterministic, no LLM/backend call.""" - _VALID = ("nearest_neighbors", "incident_branches") + """Handle a structured topology query — deterministic, no LLM/backend call. + + affected_elements: the contingency neighbor search (five lists with tiers) + around the element a mutation changes — exactly what a contingency sweep + with the same mutation would test. + incident_branches: the branch circuits touching one bus. + """ + _VALID = ("affected_elements", "incident_branches") if query_type not in _VALID: self._error_feedback = ( f"Unknown query_type '{query_type}'. " @@ -3986,35 +4120,43 @@ def _handle_topology_analyze( self._error_feedback = "No network loaded; cannot execute topology query." return "error", True - raw_bus = data.get("bus") - if raw_bus is None: - self._error_feedback = "Topology query requires a 'bus' field (integer bus number)." - return "error", True - try: - bus = int(raw_bus) - except (TypeError, ValueError): - self._error_feedback = f"'bus' must be an integer, got {raw_bus!r}." - return "error", True - - if query_type == "nearest_neighbors": - raw_k = data.get("k", 3) + if query_type == "affected_elements": + if "mutation" not in data: + self._error_feedback = ( + 'affected_elements requires "mutation", e.g. ' + '{"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}.' + ) + return "error", True + raw_depth = data.get("substation_depth", contingency.DEFAULT_SUBSTATION_DEPTH) try: - k = int(raw_k) + depth = int(raw_depth) + if depth < 0: + raise ValueError except (TypeError, ValueError): - self._error_feedback = f"'k' must be a positive integer, got {raw_k!r}." + self._error_feedback = ( + f"'substation_depth' must be an integer >= 0, got {raw_depth!r}." + ) return "error", True - try: - neighbors = topology.k_nearest_by_hops(net, bus, k) - total = topology.count_reachable(net, bus) + mutated, changed = contingency.apply_mutation(net, data["mutation"]) + affected = contingency.find_affected_elements(mutated, changed, depth) except ValueError as exc: self._error_feedback = f"Topology query error: {exc}" return "error", True - result_text = topology.format_nearest_neighbors_view(bus, k, neighbors, total) - query_desc = f"nearest_neighbors bus={bus} k={k}" + result_text = contingency.format_affected_elements_view(affected) + query_desc = f"affected_elements {changed.label()} depth={depth}" else: # incident_branches + raw_bus = data.get("bus") + if raw_bus is None: + self._error_feedback = "incident_branches requires a 'bus' field (integer bus number)." + return "error", True + try: + bus = int(raw_bus) + except (TypeError, ValueError): + self._error_feedback = f"'bus' must be an integer, got {raw_bus!r}." + return "error", True try: branches = topology.incident_branches(net, bus) except ValueError as exc: @@ -4140,7 +4282,7 @@ def _handle_set_load_factor( f"{old_factor} → {self._session_load_factor}" ) # Rebuild system prompt to reflect the new load factor note - from agentigrid.prompts.system_prompt import format_benchmark_for_prompt as _fmt_bench + from agentigrid.prompts.exago.system_prompt import format_benchmark_for_prompt as _fmt_bench net_summary_text = network_summary( self._base_network, max_generators=self._config.report.network_summary_max_generators, @@ -4526,23 +4668,11 @@ def _extract_initial_objectives(self, goal: str) -> None: f"{', '.join(o['name'] for o in parsed)}" ) else: - self._journal.objective_registry.register(ObjectiveEntry( - name="generation_cost", - direction="minimize", - priority="primary", - introduced_at=0, - source="initial", - )) - self._print("[Objectives] Defaulted to generation_cost (minimize)") + # Nothing to optimize in the goal (e.g. a test or report): track + # nothing rather than invent an objective the user did not ask for. + self._print("[Objectives] None stated in the goal; none tracked") except Exception as exc: logger.warning("Failed to extract initial objectives: %s", exc) - self._journal.objective_registry.register(ObjectiveEntry( - name="generation_cost", - direction="minimize", - priority="primary", - introduced_at=0, - source="initial", - )) def _extract_objectives_from_steering(self, directive: str, iteration: int) -> None: """Extract any new objectives from a steering directive.""" diff --git a/agentigrid/agentigrid/engine/commands.py b/agentigrid/agentigrid/engine/commands.py index bf596d96..96e36932 100644 --- a/agentigrid/agentigrid/engine/commands.py +++ b/agentigrid/agentigrid/engine/commands.py @@ -161,6 +161,19 @@ class SetShuntSusceptance: Bs: float +@dataclass +class SetShunt: + """Set shunt conductance and/or susceptance at a bus. + + Modifies the Gs and Bs fields of the specified bus. Gs=0, Bs=0 takes the + bus shunt out of service (used by the contingency screen). + """ + + bus: int + Gs: Optional[float] = None + Bs: Optional[float] = None + + @dataclass class SetPhaseShiftAngle: """Set phase shifter angle for a branch. @@ -210,7 +223,7 @@ class AddGeneratorAtBus: SetLoad, ScaleLoad, ScaleAllLoads, SetGenStatus, SetGenDispatch, SetGenVoltage, SetBranchStatus, SetBranchRate, SetCostCoeffs, SetBusVLimits, SetAllBusVLimits, ScaleLoadProfile, ScaleWindScenario, - SetTapRatio, SetShuntSusceptance, SetPhaseShiftAngle, + SetTapRatio, SetShuntSusceptance, SetShunt, SetPhaseShiftAngle, AddLoadAtBus, AddGeneratorAtBus, ] @@ -231,6 +244,7 @@ class AddGeneratorAtBus: "scale_wind_scenario": (ScaleWindScenario, {"factor"}), "set_tap_ratio": (SetTapRatio, {"fbus", "tbus", "ratio"}), "set_shunt_susceptance": (SetShuntSusceptance, {"bus", "Bs"}), + "set_shunt": (SetShunt, {"bus"}), "set_phase_shift_angle": (SetPhaseShiftAngle, {"fbus", "tbus", "angle"}), "add_load_at_bus": (AddLoadAtBus, {"bus"}), "add_generator_at_bus": (AddGeneratorAtBus, {"bus", "capacity_mw"}), diff --git a/agentigrid/agentigrid/engine/contingency.py b/agentigrid/agentigrid/engine/contingency.py index 6f6beb2e..8c42b9a0 100644 --- a/agentigrid/agentigrid/engine/contingency.py +++ b/agentigrid/agentigrid/engine/contingency.py @@ -1,8 +1,17 @@ """Pure N-1 / N-2 contingency enumeration (C.5). -Enumerates single outages (N-1) and unordered outage pairs (N-2) drawn from the -k nearest neighbor buses of a target bus. Deterministic: the outage pool has a -fixed total order, so contingency lists and N-2 pairings are reproducible. +Two steps, kept separate so each can be checked on its own: + +1. ``find_affected_elements`` — the neighbor search. From the element a mutation changed it + walks the substations within a given number of line hops (buses joined by a + transformer form one substation, 0 hops; each line between two substations is + 1 hop) and returns five lists: affected buses, branches, generators, loads and + shunts, each entry tagged with its tier. The changed element is left out. +2. ``outages_from`` + ``enumerate_contingencies`` — join the lists into outage + elements and build single outages (N-1) or unordered pairs (N-2). + +Deterministic: every list and the outage pool have a fixed order, so contingency +lists and N-2 pairings are reproducible. Feasibility semantics (judged by the caller, not here): under OPFLOW the V-band and Rate A are in-solve hard constraints, so a contingency PASSES iff the @@ -22,26 +31,27 @@ from agentigrid.parsers.matpower_model import Branch, MATNetwork # Stable kind ordering for the pool total order. -_KIND_RANK = {"branch": 0, "gen": 1, "load": 2} +_KIND_RANK = {"branch": 0, "gen": 1, "load": 2, "shunt": 3} +OUTAGE_KINDS = tuple(_KIND_RANK) +# Line hops between substations searched when a request gives no substation_depth. +DEFAULT_SUBSTATION_DEPTH = 3 @dataclass(frozen=True) class OutageElement: """A single element that can be taken out of service. - ``neighbor_bus`` / ``hop`` attribute the element to the neighbor it was drawn - from (for a branch touching two neighbors, the first in neighbor order), - giving deterministic labeling. + ``tier`` is the element's line-hop distance from the changed element's substation + (a branch takes the lower tier of its two ends). """ - kind: str # "branch" | "gen" | "load" - neighbor_bus: int # the neighbor this element is drawn from - hop: int # hop distance of neighbor_bus from the target + kind: str # "branch" | "gen" | "load" | "shunt" + tier: int # branch fields: fbus: int | None = None tbus: int | None = None ckt: int | None = None # circuit index (position in the net-wide endpoint group) - # gen/load fields: + # gen/load/shunt fields: bus: int | None = None gen_id: int | None = None @@ -69,17 +79,25 @@ def to_command(self) -> dict: "Pd": 0, "Qd": 0, } + if self.kind == "shunt": + return { + "action": "set_shunt", + "bus": self.bus, + "Gs": 0, + "Bs": 0, + } raise ValueError(f"Unknown outage kind: {self.kind!r}") def label(self) -> str: - """Short human-readable label, e.g. 'branch 69-77', 'gen@76', 'load@75'.""" + """Short human-readable label, e.g. 'branch 69-77', 'gen@76', 'load@75', 'shunt@74'.""" if self.kind == "branch": ckt_suffix = f" (ckt {self.ckt})" if self.ckt else "" return f"branch {self.fbus}-{self.tbus}{ckt_suffix}" if self.kind == "gen": - return f"gen@{self.bus}" - if self.kind == "load": - return f"load@{self.bus}" + id_suffix = f"#{self.gen_id}" if self.gen_id else "" + return f"gen@{self.bus}{id_suffix}" + if self.kind in ("load", "shunt"): + return f"{self.kind}@{self.bus}" raise ValueError(f"Unknown outage kind: {self.kind!r}") def _sort_key(self) -> tuple: @@ -91,7 +109,7 @@ def _sort_key(self) -> tuple: return (rank, lo, hi, self.ckt or 0) if self.kind == "gen": return (rank, self.bus, self.gen_id or 0) - # load + # load / shunt return (rank, self.bus, 0) @@ -112,6 +130,120 @@ def label(self) -> str: return " + ".join(e.label() for e in self.elements) + " out" +# --------------------------------------------------------------------------- +# Mutation and the changed element +# --------------------------------------------------------------------------- + +# Commands a contingency study may be centred on (the "mutation"). +MUTATION_ACTIONS = ( + "add_load_at_bus", "add_generator_at_bus", "set_load", "set_gen_status", "set_branch_status", +) + + +@dataclass(frozen=True) +class ChangedElement: + """The element the mutation adds or changes; it is never itself an outage candidate. + + load: the (aggregate) load at ``bus`` + gen: generator ``gen_id`` at ``bus`` (per-bus index, net.generators order) + branch: circuit ``ckt`` between ``fbus`` and ``tbus`` + """ + + kind: str + bus: int | None = None + gen_id: int | None = None + fbus: int | None = None + tbus: int | None = None + ckt: int = 0 + + @property + def start_buses(self) -> tuple[int, ...]: + """Buses whose substations form tier 0 (both ends for a branch).""" + if self.kind == "branch": + return (self.fbus, self.tbus) + return (self.bus,) + + def matches_branch(self, entry: dict) -> bool: + """True if ``entry`` ({"fbus", "tbus", "ckt"}) is this changed branch.""" + if self.kind != "branch": + return False + return ( + (min(entry["fbus"], entry["tbus"]), max(entry["fbus"], entry["tbus"]), entry["ckt"]) + == (min(self.fbus, self.tbus), max(self.fbus, self.tbus), self.ckt) + ) + + def label(self) -> str: + if self.kind == "branch": + ckt_suffix = f" (ckt {self.ckt})" if self.ckt else "" + return f"branch {self.fbus}-{self.tbus}{ckt_suffix}" + if self.kind == "gen": + return f"gen@{self.bus}#{self.gen_id}" + return f"load@{self.bus}" + + def to_dict(self) -> dict: + if self.kind == "branch": + return {"type": "branch", "fbus": self.fbus, "tbus": self.tbus, "ckt": self.ckt} + if self.kind == "gen": + return {"type": "gen", "bus": self.bus, "gen_id": self.gen_id} + return {"type": "load", "bus": self.bus} + + +def apply_mutation(net: MATNetwork, raw: object) -> tuple[MATNetwork, ChangedElement]: + """Apply a mutation command to a copy of ``net`` and name the element it changed. + + ``raw`` is one command from the command list, restricted to MUTATION_ACTIONS: + add_load_at_bus / set_load → the load at ``bus`` + add_generator_at_bus → the new generator (last at ``bus``) + set_gen_status → generator ``gen_id`` (default 0) at ``bus`` + set_branch_status → circuit ``ckt`` (default 0) of ``fbus``-``tbus`` + + Returns ``(mutated_net, changed)``; ``net`` itself is not modified. Raises + ValueError with an actionable message if the command is not allowed, cannot + be parsed, or is rejected by validation. + """ + # Imported here: modifier/commands are only needed for this step. + from agentigrid.engine.commands import ( + AddGeneratorAtBus, AddLoadAtBus, SetBranchStatus, SetGenStatus, SetLoad, parse_command, + ) + from agentigrid.engine.modifier import apply_modifications + + if not isinstance(raw, dict) or raw.get("action") not in MUTATION_ACTIONS: + got = raw.get("action") if isinstance(raw, dict) else raw + raise ValueError( + f"'mutation' must be one command whose action is one of {list(MUTATION_ACTIONS)}, " + f'e.g. {{"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}}; got {got!r}.' + ) + cmd = parse_command(raw) + mutated, report = apply_modifications(net, [cmd], application="opflow") + if report.skipped: + reasons = "; ".join(r for _, rs in report.skipped for r in rs) + raise ValueError(f"mutation could not be applied: {reasons}") + + if isinstance(cmd, (AddLoadAtBus, SetLoad)): + return mutated, ChangedElement(kind="load", bus=cmd.bus) + if isinstance(cmd, AddGeneratorAtBus): + n_at_bus = sum(1 for g in mutated.generators if g.bus == cmd.bus) + return mutated, ChangedElement(kind="gen", bus=cmd.bus, gen_id=n_at_bus - 1) + if isinstance(cmd, SetGenStatus): + return mutated, ChangedElement(kind="gen", bus=cmd.bus, gen_id=cmd.gen_id or 0) + assert isinstance(cmd, SetBranchStatus) + return mutated, ChangedElement(kind="branch", fbus=cmd.fbus, tbus=cmd.tbus, ckt=cmd.ckt or 0) + + +def added_load(before: MATNetwork, after: MATNetwork, changed: ChangedElement) -> dict[int, tuple[float, float]]: + """The load the mutation added, as {bus: (Pd, Qd)}; empty unless a load was changed. + + This is the new development under study, so relief must never shed it. Only the + increase is returned: load already at the bus before the mutation stays sheddable. + """ + if changed.kind != "load": + return {} + pd0, qd0 = next((b.Pd, b.Qd) for b in before.buses if b.bus_i == changed.bus) + pd1, qd1 = next((b.Pd, b.Qd) for b in after.buses if b.bus_i == changed.bus) + dp, dq = max(pd1 - pd0, 0.0), max(qd1 - qd0, 0.0) + return {changed.bus: (dp, dq)} if (dp or dq) else {} + + # --------------------------------------------------------------------------- # Enumeration # --------------------------------------------------------------------------- @@ -133,96 +265,249 @@ def _branch_ckt(net: MATNetwork, br: Branch) -> int: return pos -def build_component_pool( - net: MATNetwork, - target_bus: int, - neighbor_count: int, - components: tuple[str, ...] = ("branch", "gen", "load"), -) -> list[OutageElement]: - """Deterministic, deduped single-outage pool from the k nearest neighbors. - - - branch: union over neighbors of ``topology.incident_branches``, deduped by - (min(fbus,tbus), max(fbus,tbus), ckt). A branch between two neighbors - appears once; it is attributed to the FIRST neighbor (in neighbor order) - that touches it. - - gen: one element per generator physically at each neighbor bus - (gen_id = 0..count-1, in net.generators order at that bus). - - load: one element per neighbor bus with a non-zero (Pd or Qd). - - Restricted to the kinds present in ``components``. The returned list is in a - fixed total order (kind rank, then branch by (min,max,ckt), gen by - (bus,gen_id), load by (bus,)) so N-2 pairing is reproducible. +@dataclass +class AffectedElements: + """Result of the neighbor search: five lists, each entry tagged with its tier. + + buses: {"bus", "substation", "tier"} for every bus in scope (the changed + bus included — buses are not outaged themselves) + branches: {"fbus", "tbus", "ckt", "tier"} in-service, both ends in scope + gens: {"bus", "gen_id", "tier"} in-service + loads: {"bus", "tier"} Pd or Qd != 0 + shunts: {"bus", "tier"} Gs or Bs != 0 + + The changed element is excluded from branches/gens/loads. """ - if neighbor_count < 1: - raise ValueError(f"neighbor_count must be >= 1, got {neighbor_count}.") - neighbors = topology.k_nearest_by_hops(net, target_bus, neighbor_count) - hop_of = {nb: hop for nb, hop in neighbors} - neighbor_order = [nb for nb, _ in neighbors] + changed: ChangedElement + depth: int + buses: list[dict] + branches: list[dict] + gens: list[dict] + loads: list[dict] + shunts: list[dict] - pool: list[OutageElement] = [] + @property + def substations(self) -> list[tuple[int, int]]: + """(substation_id, tier) pairs, by tier then id.""" + pairs = {(b["substation"], b["tier"]) for b in self.buses} + return sorted(pairs, key=lambda p: (p[1], p[0])) - if "branch" in components: - seen_branches: dict[tuple[int, int, int], OutageElement] = {} - for nb in neighbor_order: - for br in topology.incident_branches(net, nb): - ckt = _branch_ckt(net, br) - key = (min(br.fbus, br.tbus), max(br.fbus, br.tbus), ckt) - if key in seen_branches: - continue # attributed to the first neighbor that touched it - seen_branches[key] = OutageElement( - kind="branch", neighbor_bus=nb, hop=hop_of[nb], - fbus=br.fbus, tbus=br.tbus, ckt=ckt, - ) - pool.extend(seen_branches.values()) + @property + def tiers(self) -> list[list[int]]: + """Substation ids grouped by tier.""" + out: list[list[int]] = [] + for sub, tier in self.substations: + while len(out) <= tier: + out.append([]) + out[tier].append(sub) + return out + + def to_dict(self) -> dict: + return { + "buses": self.buses, + "branches": self.branches, + "gens": self.gens, + "loads": self.loads, + "shunts": self.shunts, + } + + +def find_affected_elements( + net: MATNetwork, + changed: ChangedElement, + depth: int, +) -> AffectedElements: + """Neighbor search: all elements in the substations within ``depth`` of ``changed``. + + Scope: ``topology.substations_within`` from the changed element's bus (both + end buses for a branch), so tier 0 is the changed substation(s). Branches + leaving the last tier are excluded; parallel circuits are separate entries. + Every list is in a fixed order (file order for branches and generators, bus + order otherwise). + """ + scope = topology.substations_within(net, changed.start_buses, depth) + tier_of = scope.tier_of + + buses = [ + {"bus": b, "substation": scope.substation_of[b], "tier": tier_of[b]} + for b in scope.buses + ] + + branches = [] + for br in net.branches: + if br.status == 0 or br.fbus == br.tbus: + continue + if br.fbus not in tier_of or br.tbus not in tier_of: + continue + entry = { + "fbus": br.fbus, "tbus": br.tbus, "ckt": _branch_ckt(net, br), + "tier": min(tier_of[br.fbus], tier_of[br.tbus]), + } + if not changed.matches_branch(entry): + branches.append(entry) + + gens = [] + per_bus_index: dict[int, int] = {} + for g in net.generators: + gen_id = per_bus_index.get(g.bus, 0) + per_bus_index[g.bus] = gen_id + 1 + if g.bus not in tier_of or g.status != 1: + continue + if changed.kind == "gen" and (g.bus, gen_id) == (changed.bus, changed.gen_id): + continue + gens.append({"bus": g.bus, "gen_id": gen_id, "tier": tier_of[g.bus]}) + + loads, shunts = [], [] + for b in sorted(net.buses, key=lambda b: b.bus_i): + if b.bus_i not in tier_of: + continue + tier = tier_of[b.bus_i] + if (b.Pd != 0 or b.Qd != 0) and not ( + changed.kind == "load" and b.bus_i == changed.bus + ): + loads.append({"bus": b.bus_i, "tier": tier}) + if b.Gs != 0 or b.Bs != 0: + shunts.append({"bus": b.bus_i, "tier": tier}) + + return AffectedElements( + changed=changed, depth=depth, + buses=buses, branches=branches, gens=gens, loads=loads, shunts=shunts, + ) + + +def format_affected_elements_view(affected: AffectedElements, max_per_list: int = 40) -> str: + """Compact, token-bounded text view of the neighbor search for the LLM. + + Shows the substations by tier and each of the five lists, capped at + ``max_per_list`` entries per list with a count of the rest. + """ + def _cap(items: list[str]) -> str: + shown = ", ".join(items[:max_per_list]) + more = f", ... +{len(items) - max_per_list} more" if len(items) > max_per_list else "" + return f"[{shown}{more}]" if items else "[]" + + def _branch(e: dict) -> str: + ckt = f" ckt{e['ckt']}" if e["ckt"] else "" + return f"{e['fbus']}-{e['tbus']}{ckt} (t{e['tier']})" + + lines = [ + f"Affected elements around changed element {affected.changed.label()} within substation " + f"depth {affected.depth} (transformers add no hop; tN = tier N; the changed " + "element is not listed):", + "Substations by tier (id = lowest bus number): " + "; ".join( + f"tier {d}: {_cap([str(s) for s in tier])}" for d, tier in enumerate(affected.tiers) + ), + f"buses ({len(affected.buses)}): " + + _cap([f"{e['bus']} (t{e['tier']})" for e in affected.buses]), + f"branches ({len(affected.branches)}): " + _cap([_branch(e) for e in affected.branches]), + f"gens ({len(affected.gens)}): " + + _cap([f"{e['bus']}#{e['gen_id']} (t{e['tier']})" for e in affected.gens]), + f"loads ({len(affected.loads)}): " + + _cap([f"{e['bus']} (t{e['tier']})" for e in affected.loads]), + f"shunts ({len(affected.shunts)}): " + + _cap([f"{e['bus']} (t{e['tier']})" for e in affected.shunts]), + "[A contingency sweep with the same mutation and substation_depth tests " + "exactly these elements; it does not need this lookup first.]", + ] + return "\n".join(lines) + + +def outages_from( + affected: AffectedElements, + components: tuple[str, ...] = OUTAGE_KINDS, +) -> list[OutageElement]: + """Join the affected-element lists into one ordered outage pool. + Restricted to the kinds in ``components``. Fixed total order (kind rank, then + branch by (min,max,ckt), gen by (bus,gen_id), load/shunt by bus) so N-2 + pairing is reproducible. + """ + pool: list[OutageElement] = [] + if "branch" in components: + pool += [ + OutageElement(kind="branch", tier=e["tier"], fbus=e["fbus"], tbus=e["tbus"], ckt=e["ckt"]) + for e in affected.branches + ] if "gen" in components: - for nb in neighbor_order: - gens_at_bus = [g for g in net.generators if g.bus == nb] - for gen_id in range(len(gens_at_bus)): - pool.append(OutageElement( - kind="gen", neighbor_bus=nb, hop=hop_of[nb], - bus=nb, gen_id=gen_id, - )) - + pool += [ + OutageElement(kind="gen", tier=e["tier"], bus=e["bus"], gen_id=e["gen_id"]) + for e in affected.gens + ] if "load" in components: - for nb in neighbor_order: - bus_obj = next((b for b in net.buses if b.bus_i == nb), None) - if bus_obj is not None and (bus_obj.Pd != 0 or bus_obj.Qd != 0): - pool.append(OutageElement( - kind="load", neighbor_bus=nb, hop=hop_of[nb], bus=nb, - )) - + pool += [OutageElement(kind="load", tier=e["tier"], bus=e["bus"]) for e in affected.loads] + if "shunt" in components: + pool += [OutageElement(kind="shunt", tier=e["tier"], bus=e["bus"]) for e in affected.shunts] pool.sort(key=lambda e: e._sort_key()) return pool -def enumerate_contingencies( - net: MATNetwork, - target_bus: int, - neighbor_count: int, - order: int, - components: tuple[str, ...] = ("branch", "gen", "load"), -) -> list[Contingency]: - """Enumerate contingencies of the given order over the neighbor outage pool. +def enumerate_contingencies(pool: list[OutageElement], order: int) -> list[Contingency]: + """Enumerate contingencies of the given order over an outage pool. order == 1 → one Contingency per pool element. order == 2 → all unordered pairs (i < j) over the ordered pool (C(m,2)). - Raises ValueError for ``order`` not in (1, 2) or ``neighbor_count`` < 1. - Deterministic (follows the pool total order). + Raises ValueError for ``order`` not in (1, 2). Deterministic (follows the + pool order). """ if order not in (1, 2): raise ValueError(f"contingency order must be 1 or 2, got {order}.") - pool = build_component_pool(net, target_bus, neighbor_count, components) - if order == 1: return [Contingency(elements=(e,)) for e in pool] return [Contingency(elements=(a, b)) for a, b in combinations(pool, 2)] +def screen_verdict( + changed: ChangedElement, + order: int, + n_total: int, + failed_count: int, + ref_passed: bool, + ref_reason: str = "", +) -> tuple[bool, str]: + """Pass/fail verdict for the whole screen: the change passes N-k only if every contingency passes. + + Returns ``(passes, line)``. A single failure, or an infeasible pre-contingency + reference (the change alone already breaks the band), means it does not pass. + """ + if not ref_passed: + return False, ( + f"VERDICT: {changed.label()} does NOT pass N-{order}: the operating point with the change is " + f"already infeasible before any outage ({ref_reason or 'did not converge'})." + ) + if failed_count == 0: + return True, f"VERDICT: {changed.label()} passes N-{order}: all {n_total} contingencies passed." + return False, ( + f"VERDICT: {changed.label()} does NOT pass N-{order}: " + f"{failed_count} of {n_total} contingencies failed." + ) + + +def failed_element_counts( + contingencies: list[Contingency], + passed: list[bool], +) -> list[tuple[OutageElement, int, int]]: + """For each outage element, how many failed contingencies it appears in. + + ``passed`` is aligned with ``contingencies``. Returns ``(element, failed_in, + appears_in)`` for elements in at least one failure, most failures first, then + pool order. Used to summarise large N-2 screens by element. + """ + failed_in: dict[OutageElement, int] = {} + appears_in: dict[OutageElement, int] = {} + for ctg, ok in zip(contingencies, passed): + for e in ctg.elements: + appears_in[e] = appears_in.get(e, 0) + 1 + if not ok: + failed_in[e] = failed_in.get(e, 0) + 1 + rows = [(e, n, appears_in[e]) for e, n in failed_in.items()] + rows.sort(key=lambda r: (-r[1], r[0]._sort_key())) + return rows + + def all_generator_contingencies(net: MATNetwork) -> list[Contingency]: - """One N-1 contingency per in-service generator, system-wide (not neighbor-scoped). + """One N-1 contingency per in-service generator, system-wide (not scoped). Used by the hot-reserve / N-1 generator security screen (C.8): each committed unit is tripped in turn and the OPF re-solved. Only in-service units @@ -230,7 +515,7 @@ def all_generator_contingencies(net: MATNetwork) -> list[Contingency]: Deterministic: generators are grouped by bus and, within a bus, indexed in ``net.generators`` order (``gen_id`` matches the modifier's per-bus generator - indexing, as in ``build_component_pool``). The returned list is sorted by + indexing, as in ``find_affected_elements``). The returned list is sorted by ``(bus, gen_id)``. """ contingencies: list[Contingency] = [] @@ -240,8 +525,6 @@ def all_generator_contingencies(net: MATNetwork) -> list[Contingency]: for gen_id, gen in enumerate(gens_at_bus): if gen.status != 1: continue - elem = OutageElement( - kind="gen", neighbor_bus=bus, hop=0, bus=bus, gen_id=gen_id, - ) + elem = OutageElement(kind="gen", tier=0, bus=bus, gen_id=gen_id) contingencies.append(Contingency(elements=(elem,))) return contingencies diff --git a/agentigrid/agentigrid/engine/explore.py b/agentigrid/agentigrid/engine/explore.py index 121bc1db..842ddaf9 100644 --- a/agentigrid/agentigrid/engine/explore.py +++ b/agentigrid/agentigrid/engine/explore.py @@ -63,6 +63,13 @@ def _describe_command(cmd: ModCommand) -> str: return f"tap br{cmd.fbus}-{cmd.tbus}={cmd.ratio:g}" if name == "SetShuntSusceptance": return f"shunt bus{cmd.bus}={cmd.Bs:g}" + if name == "SetShunt": + parts = [] + if cmd.Gs is not None: + parts.append(f"Gs={cmd.Gs:g}") + if cmd.Bs is not None: + parts.append(f"Bs={cmd.Bs:g}") + return f"shunt bus{cmd.bus}({','.join(parts)})" if name == "SetPhaseShiftAngle": return f"phase br{cmd.fbus}-{cmd.tbus}={cmd.angle:g}deg" return name diff --git a/agentigrid/agentigrid/engine/journal.py b/agentigrid/agentigrid/engine/journal.py index 4aa8a97a..c7b04539 100644 --- a/agentigrid/agentigrid/engine/journal.py +++ b/agentigrid/agentigrid/engine/journal.py @@ -52,8 +52,38 @@ class JournalEntry: candidate_count: int = 0 # Sweep: total number of candidates tested feasible_buses: Optional[list[int]] = None # Sweep: list of feasible bus ids exago_command: Optional[dict] = None # Reproducible ExaGO invocation record (JSON journal only; see add_* methods) - contingency_meta: Optional[dict] = None # C.5: target bus, neighbors, order, pass/fail counts + contingency_meta: Optional[dict] = None # C.5: mutation, changed element, affected elements, order, pass/fail counts reserve_meta: Optional[dict] = None # C.8: hot-reserve / N-1 generator security accounting + findings: Optional[dict] = None # complete: the LLM's full findings (summary, details, ...) + + +def describe_contingency_scope(meta: dict) -> str: + """One-line description of a contingency screen's scope, for UI and reports. + + Reads the affected-element meta written by ``add_contingency``. Sessions saved + before the substation neighbor search have no such record. + """ + if "affected" in meta: + p = meta.get("changed_element") or {} + if p.get("type") == "branch": + ckt = f" (ckt {p['ckt']})" if p.get("ckt") else "" + what = f"branch {p.get('fbus')}-{p.get('tbus')}{ckt}" + elif p.get("type") == "gen": + what = f"generator {p.get('gen_id', 0)} at bus {p.get('bus')}" + else: + what = f"load at bus {p.get('bus', '?')}" + aff = meta.get("affected") or {} + buses = aff.get("buses") or [] + n_subs = len({b["substation"] for b in buses}) + n_tiers = max((b["tier"] for b in buses), default=-1) + 1 + return ( + f"around {what}; {n_subs} substations in {n_tiers} tier(s) within " + f"substation depth {meta.get('substation_depth', '?')} " + f"({len(buses)} buses, {len(aff.get('branches') or [])} branches, " + f"{len(aff.get('gens') or [])} generators, {len(aff.get('loads') or [])} loads, " + f"{len(aff.get('shunts') or [])} shunts)" + ) + return "study scope not recorded (session saved by an earlier AgentiGrid version)" def is_solve_iteration(entry: JournalEntry) -> bool: @@ -435,12 +465,16 @@ def add_contingency( self, iteration: int, description: str, - target_bus: int, - neighbors: list[tuple[int, int]], + mutation: dict, + changed_element: dict, + substation_depth: int, + affected: dict, order: int, contingency_summaries: list[dict], passed_count: int, failed_count: int, + passes: Optional[bool] = None, + verdict: str = "", llm_reasoning: str = "", steering_directive: Optional[str] = None, exago_command: Optional[dict] = None, @@ -449,9 +483,14 @@ def add_contingency( Modeled on ``add_sweep``: a lightweight entry whose ``explored_variants`` holds the full per-contingency pass/fail summaries and whose - ``contingency_meta`` captures the reproducible study parameters (target - bus, resolved neighbors, order, and pass/fail counts) so the JSON journal - is a complete, reproducible record of the N-1/N-2 study. + ``contingency_meta`` captures the reproducible study parameters (the mutation + command and the element it changed, substation depth, the five affected-element lists from the + neighbor search, order, and pass/fail counts) so the JSON journal is a + complete, reproducible record of the N-1/N-2 study. + + ``passes`` / ``verdict`` record whether the change passes N-k (every + contingency passed and the pre-contingency reference held); ``feasible`` + follows ``passes`` when it is given. When a C.7 relief phase ran, each FAILED contingency summary carries a ``relief`` payload ({resolved, measure, detail, attempts}); these ride @@ -462,7 +501,7 @@ def add_contingency( description=description, commands=[], objective_value=None, - feasible=passed_count > 0, + feasible=passes if passes is not None else passed_count > 0, convergence_status="CONTINGENCY", violations_count=0, voltage_min=0.0, @@ -480,11 +519,15 @@ def add_contingency( feasible_buses=[], exago_command=exago_command, contingency_meta={ - "target_bus": target_bus, - "neighbors": [[nb, hop] for nb, hop in neighbors], + "mutation": mutation, + "changed_element": changed_element, + "substation_depth": substation_depth, + "affected": affected, "order": order, "passed_count": passed_count, "failed_count": failed_count, + "passes": passes, + "verdict": verdict, }, ) self._entries.append(entry) @@ -578,11 +621,14 @@ def add_complete( self, iteration: int, summary: str, + findings: Optional[dict] = None, ) -> JournalEntry: """Record a 'complete' action in the journal. Creates a lightweight entry so the LLM's decision to end - the search is visible in the search history. + the search is visible in the search history. ``findings`` keeps the + LLM's full answer (e.g. the failed-contingency table in ``details``), + not just the one-line summary. """ entry = JournalEntry( iteration=iteration, @@ -601,6 +647,7 @@ def add_complete( mode="complete", elapsed_seconds=0.0, feasibility_detail="", + findings=findings, ) self._entries.append(entry) return entry @@ -1092,7 +1139,7 @@ def export_csv(self, path: Path) -> None: "mode", "elapsed_seconds", "timestamp", "steering_directive", "tracked_metrics", "feasibility_detail", "solver", "num_steps", "num_scenarios", "explored_variants", "candidate_count", "feasible_buses", "exago_command", - "contingency_meta", "reserve_meta", + "contingency_meta", "reserve_meta", "findings", ] with open(path, "w", newline="", encoding="utf-8") as f: @@ -1107,6 +1154,7 @@ def export_csv(self, path: Path) -> None: row["exago_command"] = json.dumps(row.get("exago_command") or None) row["contingency_meta"] = json.dumps(row.get("contingency_meta") or None) row["reserve_meta"] = json.dumps(row.get("reserve_meta") or None) + row["findings"] = json.dumps(row.get("findings") or None) writer.writerow(row) logger.info("Journal CSV exported to %s (%d entries)", path, len(self._entries)) diff --git a/agentigrid/agentigrid/engine/modifier.py b/agentigrid/agentigrid/engine/modifier.py index 87995bc0..f1c0822d 100644 --- a/agentigrid/agentigrid/engine/modifier.py +++ b/agentigrid/agentigrid/engine/modifier.py @@ -26,6 +26,7 @@ SetGenVoltage, SetLoad, SetPhaseShiftAngle, + SetShunt, SetShuntSusceptance, SetTapRatio, ) @@ -346,6 +347,18 @@ def _apply_one(cmd: ModCommand, ctx: _WorkCtx, application: str | None = None) - bus.Bs = cmd.Bs return f"Set shunt susceptance at bus {cmd.bus}: {old_bs} -> {cmd.Bs}" + if isinstance(cmd, SetShunt): + idx = ctx.maps.bus_by_id[cmd.bus] + bus = _writable_bus(ctx, idx) + parts = [] + if cmd.Gs is not None: + bus.Gs = cmd.Gs + parts.append(f"Gs={cmd.Gs}") + if cmd.Bs is not None: + bus.Bs = cmd.Bs + parts.append(f"Bs={cmd.Bs}") + return f"Set shunt at bus {cmd.bus}: {', '.join(parts)}" + if isinstance(cmd, SetPhaseShiftAngle): bi = _branch_index_in_network(net, cmd.fbus, cmd.tbus, cmd.ckt, ctx.maps) br = _writable_branch(ctx, bi) diff --git a/agentigrid/agentigrid/engine/objective_parser.py b/agentigrid/agentigrid/engine/objective_parser.py index 78f5d5db..0d9df403 100644 --- a/agentigrid/agentigrid/engine/objective_parser.py +++ b/agentigrid/agentigrid/engine/objective_parser.py @@ -31,9 +31,9 @@ def build_objective_extraction_prompt( Tuple of (system_prompt, user_prompt). """ system = ( - "You are analyzing a power grid optimization request to identify " - "what objectives should be tracked. Extract structured objectives " - "from the text. Respond ONLY with a JSON object, no other text." + "You are reading a power grid study request to find what it explicitly " + "asks to optimize or keep within a limit. Extract only those, as " + "structured objectives. Respond ONLY with a JSON object, no other text." ) user = ( @@ -53,12 +53,14 @@ def build_objective_extraction_prompt( f' ]\n' f'}}\n\n' f"Rules:\n" - f"- Use metric names from the available list when they match the intent.\n" - f"- Every goal has at least one primary objective.\n" - f"- Constraints mentioned (like 'keep voltages above 0.95') become constraint-type objectives.\n" + f"- Use metric names from the available list when they match the text.\n" + f"- Return only objectives the text states. Do not infer or add any.\n" + f"- A limit the text states (like 'keep voltages above 0.95') becomes a constraint-type objective.\n" + f"- Numbers that describe the change or test being studied (like 'connect a 50 MW load' or " + f"'a 100 MW generator') are not objectives or limits.\n" + f"- If the text only asks for a test, screen, list or report, return {{\"objectives\": []}}.\n" f"- If the text mentions monitoring something without optimizing it, use priority='watch'.\n" f"- For simple single-objective goals (e.g. 'minimize cost'), return just one objective.\n" - f"- Only return objectives that are clearly stated or strongly implied.\n" ) return system, user diff --git a/agentigrid/agentigrid/engine/relief.py b/agentigrid/agentigrid/engine/relief.py index 9d8dcfa7..ddc732cb 100644 --- a/agentigrid/agentigrid/engine/relief.py +++ b/agentigrid/agentigrid/engine/relief.py @@ -4,16 +4,20 @@ with a bounded parameter search — re-solving OPFLOW on top of the outage, and report the first measure+setting that restores feasibility. -Option-A modeling assumptions (confirm with the domain expert before relying on -results — see the C.7 task header): +Relief uses only fast measures on existing, already-committed equipment: tap +changes, switching lines out or in, and load shedding. Unit commitment is never +changed and no equipment is added. + +Option-A modeling assumptions: 1. OPF-redispatch feasibility: "feasible" = post-outage OPFLOW converges. - 2. Generator redispatch is INHERENT to the OPF (Pg is a decision variable), so - ``set_gen_dispatch`` is initial-guess-only and cannot rescue a contingency - that already failed with optimal redispatch. ``generator_redispatch`` is - therefore a reported pass-through (no command applied, never resolves). + 2. Generator redispatch is INHERENT to the OPF (Pg is a decision variable): every + solve already redispatches all committed units, so it is not a separate + measure. 3. Transformer tap ratios are FIXED in ExaGO ACOPF, so a tap change is a genuine lever here. If taps become optimization variables, treat tap change like redispatch (subsumed) → an Option-B redesign. + 4. Load shedding grows in substation tiers around the outage, each load bus shed + by at most ``relief_shed_max_fraction`` (see ``_try_load_curtailment``). This module is pure w.r.t. the agent loop: it imports only the command parser, the modifier, sweep_metrics (the feasibility predicate), topology, and the MATPOWER @@ -35,7 +39,6 @@ # Priority order (highest first) and the canonical measure names. MEASURE_ORDER = ( "transformer_ratio", - "generator_redispatch", "line_switching", "load_curtailment", ) @@ -99,11 +102,9 @@ def _trial( # --------------------------------------------------------------------------- def _focus_buses(contingency) -> set[int]: - """Buses local to the contingency (neighbor buses + outaged branch endpoints).""" + """Buses local to the contingency (outaged branch endpoints, element buses).""" buses: set[int] = set() for e in contingency.elements: - if e.neighbor_bus is not None: - buses.add(e.neighbor_bus) if e.kind == "branch": buses.add(e.fbus) buses.add(e.tbus) @@ -121,8 +122,10 @@ def _outaged_branch_keys(contingency) -> set[tuple[int, int, int]]: return keys -def _incident_branches_sorted(net: MATNetwork, buses: set[int]): - """In-service branches incident to any focus bus, deduped, in a fixed order. +def _incident_branches_sorted(net: MATNetwork, buses: set[int], include_out_of_service: bool = False): + """Branches incident to any focus bus, deduped, in a fixed order. + + In-service only unless ``include_out_of_service``. Yields (branch, key) where key = (lo, hi, ckt) — ckt being the branch's position among net branches sharing its unordered endpoints (matching @@ -130,7 +133,7 @@ def _incident_branches_sorted(net: MATNetwork, buses: set[int]): """ seen: dict[tuple[int, int, int], object] = {} for bus in sorted(buses): - for br in topology.incident_branches(net, bus): + for br in topology.incident_branches(net, bus, include_out_of_service): lo, hi = min(br.fbus, br.tbus), max(br.fbus, br.tbus) ckt = 0 for other in net.branches: @@ -170,91 +173,113 @@ def _try_transformer_ratio(net, contingency, vmin, vmax, solve_fn, cfg): return None -def _try_generator_redispatch(net, contingency, vmin, vmax, solve_fn, cfg): - """Pass-through under Option A: OPF redispatch is inherent; no command applied. +def _try_line_switching(net, contingency, vmin, vmax, solve_fn, cfg): + """Switch one branch local to the contingency: in-service ones out, open ones in. - Never resolves (the bare outaged case is exactly the already-failed solve), so - this is a logged no-op and the search proceeds to the next measure. + Candidates are the branches at the contingency's buses, in a fixed order; the + branches the contingency itself takes out are never switched back in. """ - return None - - -def _try_line_switching(net, contingency, vmin, vmax, solve_fn, cfg): - """Switch OUT one in-service branch local to the contingency (excluding outaged).""" focus = _focus_buses(contingency) outaged = _outaged_branch_keys(contingency) outage_cmds = tuple(contingency.commands()) - for br, key in _incident_branches_sorted(net, focus): + for br, key in _incident_branches_sorted(net, focus, include_out_of_service=True): if key in outaged: continue + switch_in = br.status == 0 cmd = { "action": "set_branch_status", - "fbus": br.fbus, "tbus": br.tbus, "ckt": key[2], "status": 0, + "fbus": br.fbus, "tbus": br.tbus, "ckt": key[2], "status": 1 if switch_in else 0, } if _trial(net, outage_cmds, [cmd], vmin, vmax, solve_fn): return ReliefAction( measure="line_switching", commands=(cmd,), - detail=f"switch out branch {br.fbus}-{br.tbus}", + detail=f"switch {'in' if switch_in else 'out'} branch {br.fbus}-{br.tbus}", ) return None -def _try_load_curtailment(net, contingency, vmin, vmax, solve_fn, cfg): - """Backstop: bisect a uniform curtailment fraction on local load buses. +def _try_load_curtailment(net, contingency, vmin, vmax, solve_fn, cfg, protected_load=None): + """Shed load in growing rings around the contingency, at most a fixed share per bus. + + Tier 0 is the substation(s) of the contingency's buses; each further tier is + one line farther out (transformers add no hop), up to ``relief_shed_max_depth``. + Every load bus sheds at most ``relief_shed_max_fraction`` of its Pd and Qd + (power factor kept). Starting at tier 0, if shedding the full share there does + not restore feasibility, those buses stay at the full share and the next tier + is added. In the first tier where it works, the smallest share that works is + bisected to ``relief_curtail_tol_mw``. Unresolved if the full share at every + tier up to the maximum depth is not enough. - Curtails load at the contingency's focus buses via - ``set_load(Pd*(1-f), Qd*(1-f))`` and bisects the minimum feasible fraction to - ``relief_curtail_tol_mw``. Reports the minimum MW curtailed. Resolves at f=1 - (all local load removed) unless even that is infeasible. + ``protected_load`` ({bus: (Pd, Qd)}) is never shed: the load the mutation added + is the development under study. The share applies to the rest of the bus load. """ focus = _focus_buses(contingency) outage_cmds = tuple(contingency.commands()) + cap = float(getattr(cfg, "relief_shed_max_fraction", 0.10)) + max_depth = int(getattr(cfg, "relief_shed_max_depth", 3)) tol_mw = float(getattr(cfg, "relief_curtail_tol_mw", 1.0)) - # Load-bearing focus buses (deterministic order) and their base Pd/Qd. - load_buses = [] - total_pd = 0.0 - for b in net.buses: - if b.bus_i in focus and (b.Pd != 0 or b.Qd != 0): - load_buses.append((b.bus_i, b.Pd, b.Qd)) - total_pd += b.Pd - load_buses.sort(key=lambda x: x[0]) - if not load_buses or total_pd <= 0: - return None # nothing to curtail here - - def _curtail_cmds(f: float) -> list[dict]: - return [ - {"action": "set_load", "bus": bi, "Pd": pd * (1.0 - f), "Qd": qd * (1.0 - f)} - for bi, pd, qd in load_buses - ] - - # f=1 (remove all local load) must be feasible for curtailment to work. - if not _trial(net, outage_cmds, _curtail_cmds(1.0), vmin, vmax, solve_fn): - return None - - # Bisect the minimum feasible fraction. lo infeasible (f=0 = bare outage, failed), - # hi feasible. - lo, hi = 0.0, 1.0 - while (hi - lo) * total_pd > tol_mw: - mid = 0.5 * (lo + hi) - if _trial(net, outage_cmds, _curtail_cmds(mid), vmin, vmax, solve_fn): - hi = mid - else: - lo = mid - - mw = hi * total_pd - buses_str = ", ".join(str(bi) for bi, _, _ in load_buses) - return ReliefAction( - measure="load_curtailment", - commands=tuple(_curtail_cmds(hi)), - detail=f"curtail {mw:.1f} MW ({hi * 100:.1f}%) at bus(es) {buses_str}", - ) + scope = topology.substations_within(net, sorted(focus), max_depth) + protected = protected_load or {} + keep_of = {b.bus_i: protected.get(b.bus_i, (0.0, 0.0)) for b in net.buses} + load_of = { # sheddable part of each bus load + b.bus_i: (b.Pd - keep_of[b.bus_i][0], b.Qd - keep_of[b.bus_i][1]) + for b in net.buses if b.Pd - keep_of[b.bus_i][0] > 0 + } + tiers: list[list[int]] = [[] for _ in range(max_depth + 1)] + for bus in sorted(load_of): + if bus in scope.tier_of: + tiers[scope.tier_of[bus]].append(bus) + + def _cmds(full: list[int], part: list[int], f: float) -> list[dict]: + cmds = [] + for bus, share in [(b, cap) for b in full] + [(b, f) for b in part]: + pd, qd = load_of[bus] + kp, kq = keep_of[bus] + cmds.append({ + "action": "set_load", "bus": bus, + "Pd": kp + pd * (1.0 - share), "Qd": kq + qd * (1.0 - share), + }) + return cmds + + full: list[int] = [] # buses of the inner tiers, shed by the full share + for tier, buses in enumerate(tiers): + if not buses: + continue + if not _trial(net, outage_cmds, _cmds(full, buses, cap), vmin, vmax, solve_fn): + full += buses + continue + + # Bisect this tier's share; lo is known infeasible (0 = inner tiers alone). + tier_pd = sum(load_of[b][0] for b in buses) + lo, hi = 0.0, cap + while (hi - lo) * tier_pd > tol_mw: + mid = 0.5 * (lo + hi) + if _trial(net, outage_cmds, _cmds(full, buses, mid), vmin, vmax, solve_fn): + hi = mid + else: + lo = mid + + parts = [] + for t in range(tier): + if tiers[t]: + mw = sum(load_of[b][0] for b in tiers[t]) * cap + parts.append(f"tier {t} {mw:.1f} MW ({cap * 100:.0f}%) at {', '.join(map(str, tiers[t]))}") + parts.append( + f"tier {tier} {tier_pd * hi:.1f} MW ({hi * 100:.1f}%) at {', '.join(map(str, buses))}" + ) + total_mw = sum(load_of[b][0] for b in full) * cap + tier_pd * hi + return ReliefAction( + measure="load_curtailment", + commands=tuple(_cmds(full, buses, hi)), + detail=f"shed {total_mw:.1f} MW: " + "; ".join(parts), + ) + return None _MEASURE_FNS = { "transformer_ratio": _try_transformer_ratio, - "generator_redispatch": _try_generator_redispatch, "line_switching": _try_line_switching, "load_curtailment": _try_load_curtailment, } @@ -272,6 +297,7 @@ def find_relief( vmax: float, solve_fn: SolveFn, cfg, + protected_load: Optional[dict] = None, ) -> ReliefResult: """Greedily search relief measures in the given priority order. @@ -279,6 +305,8 @@ def find_relief( ``MEASURE_ORDER``), run its bounded search; on the first measure that restores feasibility, return ``ReliefResult(resolved=True, action=..., attempts=...)`` where ``attempts`` records every measure tried and whether it resolved. + ``protected_load`` ({bus: (Pd, Qd)}) is passed to load shedding, which never + sheds it. """ attempts: list = [] for measure in relief_measures: @@ -286,7 +314,8 @@ def find_relief( if fn is None: attempts.append((measure, False)) continue - action = fn(net, contingency, vmin, vmax, solve_fn, cfg) + extra = {"protected_load": protected_load} if measure == "load_curtailment" else {} + action = fn(net, contingency, vmin, vmax, solve_fn, cfg, **extra) resolved = action is not None attempts.append((measure, resolved)) if resolved: diff --git a/agentigrid/agentigrid/engine/session_io.py b/agentigrid/agentigrid/engine/session_io.py index a5b3fe51..985a6326 100644 --- a/agentigrid/agentigrid/engine/session_io.py +++ b/agentigrid/agentigrid/engine/session_io.py @@ -184,6 +184,7 @@ def load_session(save_dir: Path) -> dict[str, Any]: exago_command=entry_data.get("exago_command"), contingency_meta=entry_data.get("contingency_meta"), reserve_meta=entry_data.get("reserve_meta"), + findings=entry_data.get("findings"), ) journal_entries.append(entry) diff --git a/agentigrid/agentigrid/engine/topology.py b/agentigrid/agentigrid/engine/topology.py index 282a11e9..d62bc625 100644 --- a/agentigrid/agentigrid/engine/topology.py +++ b/agentigrid/agentigrid/engine/topology.py @@ -1,12 +1,16 @@ """Deterministic graph-topology layer for power-grid networks. +Substations (buses joined by transformers) and the substation tiers around a +start bus, used by the contingency neighbor search; plus the branches incident +to a bus. + Pure module — no imports from agent_loop (avoids circular dependency). All bus identifiers are external bus numbers (Bus.bus_i == Branch.fbus/tbus). """ from __future__ import annotations -from collections import deque +from dataclasses import dataclass from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -23,87 +27,134 @@ def _build_bus_set(net: MATNetwork) -> set[int]: return {b.bus_i for b in net.buses} -def _bfs_distances(adj: dict[int, set[int]], src: int) -> dict[int, int]: - """BFS from src. Returns {bus: hop_distance} for all reachable buses, excluding src.""" - visited: dict[int, int] = {src: 0} - q: deque[int] = deque([src]) - while q: - node = q.popleft() - for nb in sorted(adj[node]): - if nb not in visited: - visited[nb] = visited[node] + 1 - q.append(nb) - del visited[src] - return visited - - # --------------------------------------------------------------------------- # Graph functions # --------------------------------------------------------------------------- -def build_adjacency( +def is_transformer(br: Branch) -> bool: + """True for a transformer branch (MATPOWER TAP column != 0), False for a line. + + MATPOWER convention: ``ratio == 0`` marks a line; any other value (including + 1.0) marks a transformer. Phase shift and base-kV differences are not used. + """ + return br.ratio != 0 + + +def substation_map( net: MATNetwork, include_out_of_service: bool = False, -) -> dict[int, set[int]]: - """Undirected adjacency keyed by external bus number (bus_i). +) -> dict[int, int]: + """Group buses into substations: buses joined by transformers share one substation. - Every bus in net.buses appears as a key (isolated buses map to empty set). - Branches with status == 0 are excluded unless include_out_of_service=True. - Parallel branches collapse to a single edge (set semantics). - Self-loops (fbus == tbus) are skipped. + Returns ``{bus_i: substation_id}`` for every bus, where the substation id is + the smallest bus number in the group. A case with no transformers maps each + bus to itself. Branches with status == 0 are ignored unless + include_out_of_service=True. """ - adj: dict[int, set[int]] = {b.bus_i: set() for b in net.buses} + parent: dict[int, int] = {b.bus_i: b.bus_i for b in net.buses} + + def find(x: int) -> int: + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + for br in net.branches: if not include_out_of_service and br.status == 0: continue - if br.fbus == br.tbus: + if not is_transformer(br) or br.fbus == br.tbus: continue - adj.setdefault(br.fbus, set()).add(br.tbus) - adj.setdefault(br.tbus, set()).add(br.fbus) - return adj + if br.fbus not in parent or br.tbus not in parent: + continue + ra, rb = find(br.fbus), find(br.tbus) + if ra != rb: + # Keep the smaller bus number as the root → stable substation ids. + lo, hi = min(ra, rb), max(ra, rb) + parent[hi] = lo + return {bus: find(bus) for bus in parent} -def k_nearest_by_hops( - net: MATNetwork, - bus: int, - k: int = 3, - include_out_of_service: bool = False, -) -> list[tuple[int, int]]: - """BFS from bus. Return up to k (neighbor_bus, hop_distance) pairs. - Ordered by (hop_distance ASC, neighbor_bus ASC). - Source bus is excluded. Raises ValueError for unknown bus or k < 1. +@dataclass +class SubstationScope: + """Substations within a given number of line hops of the start substation(s). + + ``tiers[0]`` holds the start substation(s); ``tiers[d]`` the substations first + reached across ``d`` lines (transformers add no hop). Substation ids are the + smallest bus number in each substation. """ - bus_set = _build_bus_set(net) - if bus not in bus_set: - raise ValueError(f"Bus {bus} is not in the network.") - if k < 1: - raise ValueError(f"k must be >= 1, got {k}.") - adj = build_adjacency(net, include_out_of_service) - distances = _bfs_distances(adj, bus) - result = sorted(distances.items(), key=lambda x: (x[1], x[0])) - return result[:k] + start_buses: tuple[int, ...] + depth: int + tiers: list[list[int]] + substation_of: dict[int, int] # bus -> substation id, buses in scope only + tier_of: dict[int, int] # bus -> tier, buses in scope only -def hop_distance( + @property + def buses(self) -> list[int]: + return sorted(self.substation_of) + + +def substations_within( net: MATNetwork, - src: int, - dst: int, + start_buses: list[int] | tuple[int, ...], + depth: int, include_out_of_service: bool = False, -) -> int | None: - """Minimum hop distance src -> dst (0 if src == dst). None if dst unreachable. +) -> SubstationScope: + """All substations within ``depth`` line hops of the substation(s) of ``start_buses``. + + Buses joined by a transformer belong to the same substation (0 hops); each + line between two substations is 1 hop. For a changed branch pass both end + buses, so the tiers grow outward on both sides. There is no cap on the number + of substations found. - Raises ValueError if src or dst is not in the network. + Raises ValueError for an unknown bus, an empty start list, or depth < 0. """ + if not start_buses: + raise ValueError("start_buses must contain at least one bus.") + if depth < 0: + raise ValueError(f"depth must be >= 0, got {depth}.") bus_set = _build_bus_set(net) - if src not in bus_set: - raise ValueError(f"Bus {src} is not in the network.") - if dst not in bus_set: - raise ValueError(f"Bus {dst} is not in the network.") - if src == dst: - return 0 - adj = build_adjacency(net, include_out_of_service) - distances = _bfs_distances(adj, src) - return distances.get(dst) + for b in start_buses: + if b not in bus_set: + raise ValueError(f"Bus {b} is not in the network.") + + sub_of = substation_map(net, include_out_of_service) + + # Substation graph: one edge per pair of substations joined by at least one line. + sub_adj: dict[int, set[int]] = {s: set() for s in set(sub_of.values())} + for br in net.branches: + if not include_out_of_service and br.status == 0: + continue + if is_transformer(br) or br.fbus == br.tbus: + continue + if br.fbus not in sub_of or br.tbus not in sub_of: + continue + sa, sb = sub_of[br.fbus], sub_of[br.tbus] + if sa != sb: + sub_adj[sa].add(sb) + sub_adj[sb].add(sa) + + tier0 = sorted({sub_of[b] for b in start_buses}) + tiers: list[list[int]] = [tier0] + seen: set[int] = set(tier0) + for _ in range(depth): + nxt = sorted({nb for s in tiers[-1] for nb in sub_adj[s]} - seen) + if not nxt: + break + tiers.append(nxt) + seen.update(nxt) + + tier_of_sub = {s: d for d, tier in enumerate(tiers) for s in tier} + substation_of = {bus: s for bus, s in sub_of.items() if s in tier_of_sub} + tier_of = {bus: tier_of_sub[s] for bus, s in substation_of.items()} + + return SubstationScope( + start_buses=tuple(start_buses), + depth=depth, + tiers=tiers, + substation_of=substation_of, + tier_of=tier_of, + ) def incident_branches( @@ -128,59 +179,10 @@ def incident_branches( return result -def count_reachable( - net: MATNetwork, - bus: int, - include_out_of_service: bool = False, -) -> int: - """Total number of buses reachable from bus (excluding bus itself).""" - bus_set = _build_bus_set(net) - if bus not in bus_set: - raise ValueError(f"Bus {bus} is not in the network.") - adj = build_adjacency(net, include_out_of_service) - return len(_bfs_distances(adj, bus)) - - # --------------------------------------------------------------------------- # Pure view formatters (token-bounded, LLM-facing) # --------------------------------------------------------------------------- -def format_nearest_neighbors_view( - bus: int, - k: int, - neighbors: list[tuple[int, int]], - total_reachable: int, -) -> str: - """Compact, token-bounded text view of k-nearest neighbors for the LLM.""" - svc = "in-service branches" - header = ( - f"Topology: {len(neighbors)} nearest neighbor bus(es) of bus {bus} " - f"by hop count ({svc}).\n" - ) - col_bus_w = max(3, max((len(str(nb)) for nb, _ in neighbors), default=3)) - col_hop_w = max(4, max((len(str(h)) for _, h in neighbors), default=4)) - rank_w = max(4, len(str(len(neighbors)))) - - header_row = f"{'rank':>{rank_w}} | {'bus':>{col_bus_w}} | {'hops':>{col_hop_w}}" - rows = [header_row] - for i, (nb, hops) in enumerate(neighbors, 1): - rows.append(f"{i:>{rank_w}} | {nb:>{col_bus_w}} | {hops:>{col_hop_w}}") - - note_lines = [ - f"[Equal-hop buses ordered by ascending bus number; source bus {bus} excluded.", - f" {bus} can reach {total_reachable} buses in total. Adjacency uses {svc} only.", - ] - if len(neighbors) < k: - note_lines.append( - f" Only {len(neighbors)} neighbor(s) reachable (requested k={k})." - ) - note_lines[-1] = note_lines[-1] + "]" - # Close the bracket: remove from last line and add to end - note = "\n".join(note_lines) - - return header + "\n".join(rows) + "\n" + note - - def format_incident_branches_view(bus: int, branches: list[Branch]) -> str: """Compact, token-bounded text view of incident branches for the LLM.""" header = f"Topology: branches incident to bus {bus} (in-service circuits).\n" diff --git a/agentigrid/agentigrid/engine/validation.py b/agentigrid/agentigrid/engine/validation.py index 859f0ab4..5be75b29 100644 --- a/agentigrid/agentigrid/engine/validation.py +++ b/agentigrid/agentigrid/engine/validation.py @@ -21,6 +21,7 @@ SetGenVoltage, SetLoad, SetPhaseShiftAngle, + SetShunt, SetShuntSusceptance, SetTapRatio, ) @@ -228,6 +229,11 @@ def validate_command(cmd: ModCommand, net: MATNetwork, index_maps=None) -> Valid elif isinstance(cmd, SetShuntSusceptance): _validate_bus_exists(net, cmd.bus, errors, index_maps) + elif isinstance(cmd, SetShunt): + _validate_bus_exists(net, cmd.bus, errors, index_maps) + if cmd.Gs is None and cmd.Bs is None: + warnings.append("set_shunt has no effect: neither Gs nor Bs was provided") + elif isinstance(cmd, SetPhaseShiftAngle): branch = _validate_branch(net, cmd.fbus, cmd.tbus, cmd.ckt, errors, index_maps) if branch is not None: diff --git a/agentigrid/agentigrid/prompts/__init__.py b/agentigrid/agentigrid/prompts/__init__.py index f105409e..c8d34e07 100644 --- a/agentigrid/agentigrid/prompts/__init__.py +++ b/agentigrid/agentigrid/prompts/__init__.py @@ -1,6 +1 @@ -"""LLM prompt templates.""" - -from agentigrid.prompts.system_prompt import build_system_prompt -from agentigrid.prompts.user_prompt import build_user_prompt - -__all__ = ["build_system_prompt", "build_user_prompt"] +"""LLM prompt templates (ExaGO templates in ``exago/``).""" diff --git a/agentigrid/agentigrid/prompts/exago/__init__.py b/agentigrid/agentigrid/prompts/exago/__init__.py new file mode 100644 index 00000000..a79ee21c --- /dev/null +++ b/agentigrid/agentigrid/prompts/exago/__init__.py @@ -0,0 +1,6 @@ +"""LLM prompt templates.""" + +from agentigrid.prompts.exago.system_prompt import build_system_prompt +from agentigrid.prompts.exago.user_prompt import build_user_prompt + +__all__ = ["build_system_prompt", "build_user_prompt"] diff --git a/agentigrid/agentigrid/prompts/system_prompt.py b/agentigrid/agentigrid/prompts/exago/system_prompt.py similarity index 66% rename from agentigrid/agentigrid/prompts/system_prompt.py rename to agentigrid/agentigrid/prompts/exago/system_prompt.py index ba083aa2..55f8fde2 100644 --- a/agentigrid/agentigrid/prompts/system_prompt.py +++ b/agentigrid/agentigrid/prompts/exago/system_prompt.py @@ -2,6 +2,62 @@ from __future__ import annotations +# Enforced bus voltage band. Every example in this prompt uses these values. +VMIN = 0.9 +VMAX = 1.1 + +# Contingency study radius: tiers (line hops between substations) around the element +# the mutation changes (transformers add no hop). Used in the contingency sweep text. +SUBSTATION_DEPTH = 3 + +# The sweep "feasibility" field, built from the enforced band above. +_FEASIBILITY = f'"feasibility": {{"Vmin": {VMIN}, "Vmax": {VMAX}}}' + + +def _strip_leading_header(text: str) -> str: + """Drop a leading ``=== ... ===`` line so a block is not announced twice. + + network_summary() and network_metadata() each emit their own headline. This + prompt already emits a Section headline for them, so the inner one is noise. + """ + lines = text.lstrip("\n").split("\n") + if lines and lines[0].startswith("===") and lines[0].rstrip().endswith("==="): + lines = lines[1:] + while lines and not lines[0].strip(): + lines = lines[1:] + return "\n".join(lines) + + +def _retarget_vlimits(text: str) -> str: + """Retarget Section A's voltage figures to the enforced band. + + Section A is supplied by command_schema_text() in ExaGO, which illustrates + set_bus_vlimits / set_all_bus_vlimits with 0.95/1.05 and documents the + set_gen_voltage setpoint range as 0.8-1.2 pu -- wider than any AVR holds in + service, and misleading under PFLOW where Vg is the primary voltage control. + Retargeting here keeps every figure equal to the enforced band without + editing the ExaGO source. + """ + return ( + text.replace('"Vmin": 0.95, "Vmax": 1.05', f'"Vmin": {VMIN}, "Vmax": {VMAX}') + .replace('"Vmin": 0.98, "Vmax": 1.02', f'"Vmin": {VMIN}, "Vmax": {VMAX}') + .replace("Vg (float, pu \u2014 reasonable range 0.8\u20131.2)", + f"Vg (float, pu \u2014 reasonable range {VMIN}\u2013{VMAX})") + ) + + +def _drop_command_envelope(text: str) -> str: + """Remove Section A's trailing "return {"commands": [...]}" claim. + + command_schema_text() ends by stating the reply is a top-level object with a + "commands" key. The Response Format section defines the real envelope + ({"action": "modify", ...}), so this trailing claim is both duplicated and + wrong. Kept out rather than reworded. + """ + marker = 'Return your commands as a JSON object with a "commands" key' + i = text.find(marker) + return text[:i].rstrip() + "\n" if i != -1 else text + def format_benchmark_for_prompt(benchmark_result: dict | None) -> str: """Format a BenchmarkResult dict into a compact prompt section (<25 lines). @@ -143,15 +199,15 @@ def build_system_prompt( _AC_OPF_VOLTAGE_SECTION = ( "=== OPF Voltage Control ===\n\n" - "In OPFLOW (Optimal Power Flow), bus voltages are **optimization variables** \u2014 " - "the solver picks the voltage at each bus to minimise cost within the bounds set " - "by bus Vmin/Vmax. This means:\n" - '- set_gen_voltage sets only an initial guess; OPFLOW will ignore it and solve ' - "for the optimal voltage.\n" + "In OPFLOW the solver chooses the voltage at every bus. It picks whatever " + "minimises cost within each bus's Vmin/Vmax. Two consequences:\n" + '- set_gen_voltage is only a starting guess. The solver overwrites it, so it ' + "cannot be used to control voltage here.\n" + "- Voltage is controlled by changing the LIMITS, not the setpoint.\n" '- To enforce voltage limits across the entire network, use set_all_bus_vlimits ' - '(command 11): {"action": "set_all_bus_vlimits", "Vmin": 0.95, "Vmax": 1.05}\n' + '(command 11): {"action": "set_all_bus_vlimits", "Vmin": 0.9, "Vmax": 1.1}\n' '- To enforce voltage limits on a specific bus only, use set_bus_vlimits ' - '(command 10): {"action": "set_bus_vlimits", "bus": 10, "Vmin": 0.98, "Vmax": 1.02}\n' + '(command 10): {"action": "set_bus_vlimits", "bus": 10, "Vmin": 0.9, "Vmax": 1.1}\n' "- Use scale_all_loads / set_gen_dispatch to shift the operating point when " "limits alone are insufficient.\n\n" "=== Feasibility Classification ===\n\n" @@ -192,17 +248,6 @@ def build_system_prompt( "The loadability limit will appear significantly higher with EMPAR than IPOPT.\n\n" "When using EMPAR, results marked 'feasible' with CONVERGED status may still be " "N-1-INSECURE. For accurate N-1 security analysis, use IPOPT.\n\n" - "=== Feasibility Classification ===\n\n" - "Each iteration is classified as one of:\n" - "- feasible: Simulation converged with no constraint violations. " - "The solution is physically valid and can be used for decision-making.\n" - "- infeasible: Either the solver did not converge, or the solution has " - "generation < load (negative losses) meaning the dispatch cannot serve the demand. " - "This iteration should be treated as a boundary marker, not a valid solution.\n" - "- marginal: The solver did not fully converge (e.g., maximum iterations exceeded) " - "but the solution data shows no constraint violations. The solution MAY be usable " - "but should be treated with caution. It can serve as a boundary marker in " - "feasibility searches." ) _TCOPFLOW_SECTION = ( @@ -268,8 +313,8 @@ def build_system_prompt( "measures' or 'cannot, even with remediation.'\n" "- 'Which buses/components are most affected by wind variability?' → issue an " "analyze action with query_type scenario_voltage_spread (per-bus voltage " - "spread across scenarios). Do NOT use nearest_neighbors for this — that is a " - "topology helper, not a variability measure.\n" + "spread across scenarios). Do NOT use a topology query (affected_elements, " + "incident_branches) for this — those describe the grid layout, not variability.\n" "- 'How much wind can the network absorb / where does curtailment start?' → " "use the optional absorption procedure below.\n\n" "=== Wind Is a Zero-Cost, Curtailable Upper Bound ===\n\n" @@ -317,7 +362,7 @@ def build_system_prompt( "depend on the load level, so state the load condition you used.\n\n" "=== Buses Most Affected by Wind Variability ===\n\n" "To answer 'which buses are most affected by wind variability', do NOT reach " - "for nearest_neighbors or free-text analyze. After a SOPFLOW solve, issue a " + "for a topology query or free-text analyze. After a SOPFLOW solve, issue a " "STRUCTURED analyze query:\n" '{"action": "analyze", "query_type": "scenario_voltage_spread", "k": 10}\n' "This reads the per-scenario second-stage solutions (sopflowout/scen_*.m) for " @@ -346,13 +391,13 @@ def build_system_prompt( "=== CRITICAL: Voltage Limits Must Be Set Explicitly ===\n\n" "PFLOW does NOT enforce bus voltage limits — it only solves the power flow equations. " "Violation checking uses the Vmin/Vmax values in the network file. If the default limits " - "are wide (e.g., 0.9/1.1), voltages outside the typical engineering range of 0.95–1.05 pu " - "will NOT be flagged as violations.\n\n" + "are wider than the range you intend to enforce, voltages outside your intended " + "range will NOT be flagged as violations.\n\n" "**IMPORTANT: As your FIRST action in any PFLOW search, issue " "set_all_bus_vlimits (command 11) to set the voltage limits you want to enforce.** " "For typical feasibility searches, use:\n" - '{"action": "set_all_bus_vlimits", "Vmin": 0.95, "Vmax": 1.05}\n\n' - "This ensures that voltages outside 0.95–1.05 pu are correctly reported as violations. " + '{"action": "set_all_bus_vlimits", "Vmin": 0.9, "Vmax": 1.1}\n\n' + "This ensures that voltages outside 0.9–1.1 pu are correctly reported as violations. " "Without this command, the search may treat infeasible operating points as feasible, " "leading to incorrect boundary estimates.\n\n" "=== Key Differences from OPFLOW ===\n\n" @@ -514,8 +559,8 @@ def build_system_prompt( "- Combined variations: each variant is an independent set of modifications\n\n" "**CRITICAL: Every variant MUST include all commands that are required by the goal.** " "For example, if the goal says 'scale loads to 1.23', every variant must include " - "scale_all_loads(factor=1.23). If the goal says 'enforce voltage limits 0.95-1.05', " - "every variant must include set_all_bus_vlimits(Vmin=0.95, Vmax=1.05). " + "scale_all_loads(factor=1.23). If the goal says 'enforce voltage limits 0.9-1.1', " + "every variant must include set_all_bus_vlimits(Vmin=0.9, Vmax=1.1). " "Omitting these fixed commands in some variants makes the results incomparable.\n\n" "Example: For a feasibility boundary search, instead of testing one factor per " "iteration with 'modify', propose 5 factors spanning the search range. The system " @@ -604,262 +649,334 @@ def _build_standard_prompt( ) -> str: if concurrent_pflow and application == "pflow": _action_header = "You MUST respond with a single JSON object. Choose one of five actions:" - _action_section = """ -1. EXPLORE the neighborhood — evaluate multiple configurations concurrently (PREFERRED for search): -{{ + _action_shapes = """ +1. EXPLORE the neighborhood: +{ "action": "explore", "reasoning": "Why these variants are worth testing.", "mode": "fresh" or "accumulative", "description": "Short description of the exploration", "variants": [ - {{"label": "A", "commands": [{{"action": "set_gen_voltage", "bus": 1, "Vg": 1.02}}]}}, - {{"label": "B", "commands": [{{"action": "set_gen_voltage", "bus": 1, "Vg": 1.04}}]}}, - {{"label": "C", "commands": [{{"action": "set_gen_voltage", "bus": 1, "Vg": 1.06}}]}} + {"label": "A", "commands": [{"action": "set_gen_voltage", "bus": 1, "Vg": 1.02}]}, + {"label": "B", "commands": [{"action": "set_gen_voltage", "bus": 1, "Vg": 1.04}]}, + {"label": "C", "commands": [{"action": "set_gen_voltage", "bus": 1, "Vg": 1.06}]} ] -}} +} -2. SELECT a variant — after explore, adopt one of the evaluated points as the new current point: -{{ +2. SELECT a variant: +{ "action": "select", "choice": "A", "reasoning": "Why this variant is the best choice for the next iteration." -}} +} -3. MODIFY the network — apply a single change and run a simulation (use ONLY when you are certain of the outcome): -{{ +3. MODIFY the network: +{ "action": "modify", "reasoning": "Explanation of why these changes should help achieve the goal.", "mode": "fresh" or "accumulative", "description": "Short one-line description for the search journal", - "commands": [{{"action": "...", ...}}] -}} + "commands": [{"action": "...", ...}] +} -4. COMPLETE the search — when the goal is achieved or determined infeasible: -{{ +4. COMPLETE the search: +{ "action": "complete", "reasoning": "Explanation of why the search is done.", - "findings": {{ + "findings": { "summary": "Concise answer to the goal.", "details": "Supporting data and observations." - }} -}} + } +} -5. ANALYZE results — request specific data before deciding: -{{ +5. ANALYZE results: +{ "action": "analyze", "reasoning": "What information is needed and why.", "query": "e.g. buses with voltage below 0.95" -}} +} + {"action": "analyze", "query_type": "affected_elements", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, "substation_depth": """ + str(SUBSTATION_DEPTH) + """} + {"action": "analyze", "query_type": "incident_branches", "bus": 77} +""" + _action_guide = """ +1. EXPLORE the neighborhood + evaluate multiple configurations concurrently (PREFERRED for search): + +2. SELECT a variant + after explore, adopt one of the evaluated points as the new current point: + +3. MODIFY the network + apply a single change, make sure the change reflects all user requested quantities, + and run a simulation (use ONLY when you are certain of the outcome): + +4. COMPLETE the search + when the goal is achieved or determined infeasible: + +5. ANALYZE results + request specific data before deciding: For network topology, prefer a STRUCTURED query_type over free text (deterministic, exact, and scales to large networks): - {{"action": "analyze", "query_type": "nearest_neighbors", "bus": 77, "k": 3}} - {{"action": "analyze", "query_type": "incident_branches", "bus": 77}} - nearest_neighbors returns the k nearest buses by hop count (BFS over in-service - branches, ties broken by ascending bus number). incident_branches lists the branch - circuits touching a bus. Use these for any "nearest neighbor", "buses connected to X", - or contingency-neighbor goal — do NOT ask for branch/adjacency data in free text. + affected_elements lists the substations within substation_depth tiers of the element + the mutation changes (one tier = one line; buses joined by a transformer are one + substation) and every + bus, branch, generator, load and shunt in them, each with its tier. incident_branches + lists the branch circuits touching a bus. Do NOT ask for branch/adjacency data in + free text. """ else: - _action_header = "You MUST respond with a single JSON object. Choose one of four actions:" - _action_section = """ -1. MODIFY the network — apply changes and run a simulation: -{{ + _action_shapes = """ +Reply with exactly one JSON object and nothing else: no markdown fences, no text +before or after it. The "action" field must be one of: modify, complete, analyze, sweep. + +1. modify - change the network, then solve it. +{ "action": "modify", - "reasoning": "Explanation of why these changes should help achieve the goal.", + "reasoning": "Why these changes should help reach the goal.", "mode": "fresh" or "accumulative", - "description": "Short one-line description for the search journal", - "commands": [{{"action": "...", ...}}] -}} + "description": "One short line for the search journal", + "commands": [{"action": "", ...}] +} -2. COMPLETE the search — when the goal is achieved or determined infeasible: -{{ +2. complete - the goal is answered, or shown to be impossible. +{ "action": "complete", - "reasoning": "Explanation of why the search is done.", - "findings": {{ - "summary": "Concise answer to the goal.", - "details": "Supporting data and observations." - }} -}} - -3. ANALYZE results — request specific data before deciding: -{{ + "reasoning": "Why the search is finished.", + "findings": { + "summary": "Direct answer to the goal.", + "details": "Numbers and observations that support it." + } +} + +3. analyze - ask for data before deciding. Costs one iteration and solves nothing. +{ "action": "analyze", - "reasoning": "What information is needed and why.", - "query": "e.g. buses with voltage below 0.95" -}} - For network topology, prefer a STRUCTURED query_type over free text (deterministic, - exact, and scales to large networks): - {{"action": "analyze", "query_type": "nearest_neighbors", "bus": 77, "k": 3}} - {{"action": "analyze", "query_type": "incident_branches", "bus": 77}} - nearest_neighbors returns the k nearest buses by hop count (BFS over in-service - branches, ties broken by ascending bus number). incident_branches lists the branch - circuits touching a bus. Use these for any "nearest neighbor", "buses connected to X", - or contingency-neighbor goal — do NOT ask for branch/adjacency data in free text. - -4. SWEEP over a candidate set — test the SAME mutation at every bus (or a subset) in ONE action. - Use this for any goal of the form "find all buses that can ..." or "for each bus ...". - The system loops over the candidates in code (in parallel), solves each, enforces the - feasibility voltage band you specify, and returns a table of per-bus feasibility. You then - read the table and report the answer with a "complete" action. -{{ + "reasoning": "What you need and why.", + "query": "buses with voltage below 0.95" +} +For topology, use a structured query instead of free text: +{"action": "analyze", "query_type": "affected_elements", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, "substation_depth": """ + str(SUBSTATION_DEPTH) + """} +{"action": "analyze", "query_type": "incident_branches", "bus": 77} + +4. sweep - apply the same change at many buses in ONE action, solved in parallel. + A sweep has six forms. The "mode" field selects the first four. + +4a. plain sweep - test one fixed change at every candidate bus. +{ "action": "sweep", "reasoning": "Why this sweep answers the goal.", - "description": "Short one-line description for the journal", - "candidate_set": {{"type": "all_buses"}}, - "mutation": {{"action": "add_generator_at_bus", "capacity_mw": 100.0}}, - "feasibility": {{"Vmin": 0.9, "Vmax": 1.1}} -}} - -5. BOUNDARY SWEEP (max hosting capacity) — for "how much load/generation can each bus take?" - or "what is the maximum MW at bus X" goals. Instead of testing ONE fixed injection, the - system runs a per-bus bisection on the injection magnitude (in code, in parallel) and - returns the maximum feasible MW per bus plus the binding constraint. This is ONE action, - not many iterations. Choose the entity: - - "load": adds active load and scales reactive load along a constant-power-factor - ray. "power_factor" is "system_average" (default, the ΣQd/ΣPd of the base - case), "unity", or a number 0..1. Pd and Qd always move together. - - "generator": adds a generator in fixed-injection mode (Pmin=Pmax=ΔP); reactive output - is left free within a default band. (A dispatchable unit would be zeroed - by the OPF, making the hosting test vacuous — fixed injection is required.) - The boundary located is the OPFLOW convergence boundary (V-band and Rate A are in-solve - hard constraints). Read the returned per-bus capacities and answer with "complete". -{{ + "description": "One short line for the journal", + "candidate_set": {"type": "all_buses"}, + "mutation": {"action": "add_generator_at_bus", "capacity_mw": 100.0}, + """ + _FEASIBILITY + """ +} + +4b. mode "boundary" - find the LARGEST change each bus can take. + Set "entity" to "load" or "generator". +{ "action": "sweep", "mode": "boundary", "entity": "load", "power_factor": "system_average", "reasoning": "Find the maximum load each bus can host.", - "description": "Short one-line description for the journal", - "candidate_set": {{"type": "all_buses"}}, - "feasibility": {{"Vmin": 0.9, "Vmax": 1.1}} -}} - Contingency screening (single action, runs the whole N-1/N-2 study internally): -{{ + "description": "One short line for the journal", + "candidate_set": {"type": "all_buses"}, + """ + _FEASIBILITY + """ +} + +4c. mode "contingency" - apply one change, then test every outage around it. +{ "action": "sweep", "mode": "contingency", - "target_bus": 77, - "neighbor_count": 3, + "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": """ + str(SUBSTATION_DEPTH) + """, "contingency_order": 1, - "components": ["branch", "gen", "load"], - "feasibility": {{"Vmin": 0.9, "Vmax": 1.1}} -}} - Python resolves the nearest neighbor buses by hop count, enumerates every - contingency (order 1 = single outages, order 2 = pairs), applies each outage set - on top of the CURRENT operating point, re-solves OPFLOW, and returns a pass/fail - table. FIRST connect any required load with a `modify` action, THEN issue this. - Do not enumerate contingencies yourself and do not loop analyze/modify to test them. - To also suggest RELIEF measures for the failures, add an ordered "relief_measures" - list (applied only to FAILED contingencies; the first measure that restores - feasibility is reported): - {{"action": "sweep", "mode": "contingency", "target_bus": 35, "contingency_order": 2, - "relief_measures": ["transformer_ratio", "generator_redispatch", "line_switching", "load_curtailment"]}} - Priority order matters (highest-priority first). Note: under ExaGO OPFLOW generator - redispatch is INHERENT to the solve, so "generator_redispatch" is logged as an - inherent no-op (it cannot rescue a case that already failed with optimal redispatch); - the resolving measure will be a tap change, a line switch, or load curtailment. - - Hot reserve / N-1 generator security assessment (single action): -{{ + """ + _FEASIBILITY + """ +} + Only when the goal's words ask for relief measures, add "relief_measures", highest + priority first. Otherwise never add it, even if contingencies fail: +{ + "action": "sweep", + "mode": "contingency", + "mutation": {"action": "add_load_at_bus", "bus": 35, "Pd": 50.0}, + "substation_depth": """ + str(SUBSTATION_DEPTH) + """, + "contingency_order": 2, + "relief_measures": ["transformer_ratio", "line_switching", "load_curtailment"] +} + +4d. mode "reserve" - hot reserve and N-1 generator security. +{ "action": "sweep", "mode": "reserve", - "feasibility": {{"Vmin": 0.9, "Vmax": 1.1}} -}} - Python computes the system hot reserve (Σ Pmax−Pg over in-service units at the - solved base dispatch), runs a system-wide N-1 generator-outage screen (each - committed unit tripped, OPF re-solved), and reports the reserve available, the - minimum required for N-1 (the largest committed unit's output — the worst single - loss), the margin, and whether every unit loss is feasible. - - Minimum feasible hot reserve (single action): -{{ + """ + _FEASIBILITY + """ +} + Add "minimize": true to find the SMALLEST reserve that is still N-1 secure: +{ "action": "sweep", "mode": "reserve", "minimize": true, - "feasibility": {{"Vmin": 0.9, "Vmax": 1.1}} -}} - Python greedily de-commits generators (largest capacity first), re-solving the OPF - and the full N-1 generator screen after each, to find the minimum hot reserve - (Σ Pmax−Pg over on-units) that remains N-1 secure. Returns the minimum reserve - (a greedy UPPER BOUND, bracketed below by the largest remaining committed unit), - which units were de-committed, and the N-1-secure confirmation. Do NOT enumerate - generator outages yourself and do NOT attempt manual per-unit de-commitment loops. - -6. ECONOMIC (DISPATCHABLE) GENERATOR SITING — for "where is the minimum-cost location for a - generator under economic dispatch?" The OPF must CHOOSE the unit's output, so add a - dispatchable unit (Pmin=0, Pmax=cap) with a realistic cost curve and rank locations by the - resulting total system cost. Set entity_dispatchable=true; the cost curve defaults to the - case median (mid-merit) unless you pass entity_cost_coeffs [c2, c1, c0]. The report records - each location's dispatched Pg (a unit dispatching ~0 MW is not helping there). -{{ + """ + _FEASIBILITY + """ +} + +4e. dispatchable siting - set "entity_dispatchable": true, no "mode". + Ranks locations by total system cost when the solver chooses the unit's output. +{ "action": "sweep", - "reasoning": "Find the min-cost location for a dispatchable generator.", - "description": "Short one-line description for the journal", - "candidate_set": {{"type": "all_buses"}}, - "mutation": {{"action": "add_generator_at_bus", "capacity_mw": 200.0}}, + "reasoning": "Find the lowest-cost location for a dispatchable generator.", + "description": "One short line for the journal", + "candidate_set": {"type": "all_buses"}, + "mutation": {"action": "add_generator_at_bus", "capacity_mw": 200.0}, "entity_dispatchable": true, - "feasibility": {{"Vmin": 0.9, "Vmax": 1.1}} -}} + """ + _FEASIBILITY + """ +} -7. CUSTOM METRIC / PREDICATE SWEEP — select a NAMED, verified primitive (never invent logic): - - metric "max_delta_v": ranks buses by the worst system-wide voltage step |ΔV| caused by - switching in a load block at that bus (power-quality flag). Use for "largest voltage step - on energizing / switching" goals. Pair with mutation add_load_at_bus. - - feasibility_predicate "reactive_adequacy": tests whether a feasible OPF exists with a unit - forced to (P=Pmax, Q=Qmax) at the bus (reactive headroom). Use for "which buses have - reactive adequacy / can supply Qmax at Pmax" goals. Pair with mutation add_generator_at_bus - and set Qmax to the target; the system pins Q=Qmax. -{{ +4f. named metric or predicate - set "metric" or "feasibility_predicate", no "mode". +{ "action": "sweep", - "reasoning": "Rank buses by the voltage step when switching in a 100 MW block.", - "description": "Short one-line description for the journal", - "candidate_set": {{"type": "all_buses"}}, - "mutation": {{"action": "add_load_at_bus", "Pd": 100.0}}, + "reasoning": "Rank buses by the voltage step when a 100 MW block is switched in.", + "description": "One short line for the journal", + "candidate_set": {"type": "all_buses"}, + "mutation": {"action": "add_load_at_bus", "Pd": 100.0}, "metric": "max_delta_v", - "feasibility": {{"Vmin": 0.9, "Vmax": 1.1}} -}} + """ + _FEASIBILITY + """ +} +""" + _action_guide = """ +Match the goal's wording to an action. Prefer the action that answers the whole +goal in ONE step over a sequence of smaller steps. + + "find all buses that can host X" -> 4a, mutation add_load_at_bus or add_generator_at_bus + "how much can each bus host", "maximum MW" -> 4b (boundary) + "test all N-1 / N-2 contingencies" -> 4c (contingency) + "and suggest relief measures" -> 4c plus relief_measures + "hot reserve", "N-1 generator security" -> 4d (reserve) + "minimum / minimise hot reserve" -> 4d plus minimize true + "lowest-cost location under economic dispatch" -> 4e (entity_dispatchable) + "largest voltage step on switching" -> 4f, metric max_delta_v + "reactive adequacy", "Qmax at Pmax" -> 4f, feasibility_predicate reactive_adequacy + "which elements are near bus X" -> analyze, query_type affected_elements + "which branches touch bus X" -> analyze, query_type incident_branches + +Never answer a per-bus question with scale_all_loads. That scales the whole network +uniformly and tells you nothing about individual buses. + +Never build a sweep by hand. Do not loop modify/analyze over buses, outages or +injection sizes: one sweep does the whole study in a single iteration. + +Feasibility criteria, for every solve and sweep: every bus voltage within +""" + str(VMIN) + """-""" + str(VMAX) + """ pu (unless the goal states another band), and every line within +Rate A. Rate A is always enforced inside the OPFLOW solve; you never set it. Put the +voltage band in "feasibility". + +What each sweep form does: + +4a Applies your mutation at every candidate, solves each one, applies the voltage + band you give, and returns a per-bus feasible/infeasible table. Read the table, + then answer with complete. + +4b Runs a search on the injection size at each bus and returns the maximum feasible + MW per bus together with the constraint that stopped it. Voltage band and Rate A + are enforced inside the solve. + entity "load" - adds Pd and scales Qd with it at constant power factor. + "power_factor" is "system_average" (default), "unity", or + a number between 0 and 1. Pd and Qd always move together. + entity "generator" - adds a unit with fixed output (Pmin = Pmax). Reactive + output stays free. A dispatchable unit would be dispatched + to zero by the solver, which would make the test meaningless. + +4c Applies your mutation (one command from Section 2: add_load_at_bus, + add_generator_at_bus, set_load, set_gen_status or set_branch_status) to the current + operating point. No separate modify is needed. It then studies every substation + within substation_depth tiers of the element you changed: one tier is one line, and + buses joined by a transformer are one substation. Each branch, generator, load and + shunt there, except the element you changed, is taken out alone (order 1) or in + pairs (order 2), and OPFLOW is re-solved each time. The sweep finds these elements + itself; do not run affected_elements first. + - Keep substation_depth at """ + str(SUBSTATION_DEPTH) + """ unless the goal names a number of tiers. + "All contingencies" means all contingencies in this area. + - Leave out "components" unless the goal limits the outage types. + - Pass: OPFLOW, with generators free to redispatch, finds a solution that meets + the feasibility criteria above. Fail: it finds none. If the pre-contingency + reference fails, report that first; the other results then mean little. + - A contingency test only reports. If a contingency fails, report it. Do not try + to resolve it: no switching on extra generators, no load shedding, no other + fixes. Fixes are proposed only when the goal asks for relief measures. + - The change passes N-1 (or N-2) only if EVERY contingency passes; one failure + means it does not pass. The results start with a VERDICT line and a table of + every failed contingency. + - Then answer with complete: "summary" is the VERDICT line exactly as given, for + example "VERDICT: load@77 does NOT pass N-1: 2 of 126 contingencies failed."; + "details" lists every failed contingency with its reason, copied from the FAILED + table. For N-2 with many failures, give the "Elements in failed contingencies" + table and the count instead. If none failed, say so. Use only numbers from the + results. The full pass/fail table is added to the report automatically. + - If the VERDICT says the number of contingencies exceeds the limit, the study was + not run: answer with complete, summary = that VERDICT line. Do not lower + substation_depth, contingency_order or components to make it fit. + With relief_measures, each measure is tried on the FAILED cases only and the first + one that restores feasibility is reported. Relief uses only fast measures on existing, + committed equipment: a transformer tap change, switching one nearby line out or in, + or load shedding (grows tier by tier around the outage, at most 10% of each bus's + load; the load the mutation adds is never shed). Never turn on a generator or add + equipment. Generator redispatch is not a + measure: every OPFLOW solve already redispatches all committed units. + +4d Computes hot reserve as the sum of (Pmax - Pg) over in-service units at the solved + dispatch, trips each committed unit in turn, and reports the reserve available, the + minimum needed for N-1 (the output of the largest committed unit), the margin, and + whether every single loss stays feasible. + With minimize true, it de-commits units largest-first, re-running the N-1 screen + each time, and reports the smallest reserve that is still N-1 secure. That figure + is an upper bound produced by a greedy search, not a proven minimum. + +4e Adds a unit the solver can dispatch between 0 and capacity, with a cost curve, and + ranks locations by resulting total system cost. The cost curve defaults to the + median unit in the case unless you pass "entity_cost_coeffs": [c2, c1, c0]. A + location where the unit dispatches near 0 MW is not a useful location. + +4f Use only the named metrics and predicates listed below. Do not invent your own. + metric "max_delta_v" - ranks buses by the largest voltage change anywhere in the + system when a load block is switched in at that bus. Pair with add_load_at_bus. + feasibility_predicate "reactive_adequacy" - tests whether a feasible solution + exists with a unit held at P = Pmax and Q = Qmax. Pair with add_generator_at_bus + and set Qmax to the target value. + +Generator mode: fixed output or dispatchable. Read this from the goal's wording and +never choose silently. State which mode you used in your answer. + "hosting capacity", "how much can connect", "fixed output" -> fixed output + (Pmin = Pmax = capacity). This is a headroom test. + "economic dispatch", "minimise cost", "let the unit choose" -> dispatchable + (Pmin = 0, Pmax = capacity) with a cost curve, "entity_dispatchable": true. + Rank locations by total system cost. +These answer different questions, and the wrong one silently gives the wrong number. """ metadata_section = "" if network_metadata: - metadata_section = f"\n=== Section G: Network Metadata ===\n\n{network_metadata}\n" + metadata_section = f"\n=== Section 7: Network Metadata ===\n\n{_strip_leading_header(network_metadata)}\n" benchmark_section = "" if benchmark_text: - benchmark_section = f"\n=== Section H: Benchmark Reference (OPFLOW vs PFLOW) ===\n\n{benchmark_text}\n" + benchmark_section = f"\n=== Section 8: Benchmark Reference (OPFLOW vs PFLOW) ===\n\n{benchmark_text}\n" return f"""\ You are a power systems analysis agent. You iteratively modify a power grid \ network and run {application.upper()} simulations to achieve a user-specified goal. -=== Section A: Available Commands === +=== Section 1: Response Format === -{command_schema} +{_action_shapes} -=== Section B: Network Information === +=== Section 2: Available Commands === -{network_summary} -{metadata_section}{benchmark_section} -=== Response Format === +{_drop_command_envelope(_retarget_vlimits(command_schema))} -{_action_header} -{_action_section} -=== Rules === +=== Section 3: Choosing an Action === -- Be systematic: start with small changes, observe the effect, then adjust. -- Explain your reasoning in every response. -- Respect physical bounds: generator Pmin/Pmax, voltage limits, thermal ratings. -- Use "fresh" mode to apply commands to the original base case network. -- Use "accumulative" mode to build on top of the previous iteration's network. -- Fresh mode is best for binary-search or parameter-sweep approaches. -- Accumulative mode is best for incremental refinement. +Every action is defined in Section 1. This section says when to use which one. +{_action_guide} - For "find all buses that can host a load/generator" goals, use the `sweep` action with \ `add_load_at_bus` or `add_generator_at_bus` as the mutation — do NOT use `scale_all_loads`, \ which changes the whole network uniformly and does not answer a per-bus question. -- For "what is the MAXIMUM load/generation each bus can host" or "how much can bus X take" \ -goals, use the `sweep` action with `"mode": "boundary"` (set `entity` to "load" or \ -"generator"). The system bisects the injection magnitude per bus in ONE action and returns \ -the maximum feasible MW with the binding constraint — do NOT run a manual binary search across \ -many iterations. - GENERATOR MODE — fixed-injection vs dispatchable (choose from the prompt wording, never \ default silently, and name the chosen mode back in your answer): - "hosting capacity" / "how much can connect" / "forced injection" / "fixed output" → \ @@ -870,52 +987,82 @@ def _build_standard_prompt( The OPF chooses the output; rank locations by total system cost. These are different questions (e.g. a forced 160/40 split vs an optimized 200/200 dispatch) — \ picking the wrong mode silently flips the answer. -- SWEEP METRIC / PREDICATE — when a goal needs something other than cost or standard \ -feasibility, select a NAMED primitive from the verified registry; never invent the logic. \ -Available metrics: `max_delta_v` (worst system-wide voltage step on switching). Available \ -predicates: `reactive_adequacy` (feasible OPF at forced P=Pmax, Q=Qmax). Omit both for the \ -default cost metric and standard V-band/loading feasibility. - DO NOT RE-RUN AN IDENTICAL SWEEP. A sweep is deterministic: once it returns results for \ the requested parameters (same mutation/entity/mode/candidate set), trust them and proceed to \ the answer. Re-running the same sweep yields byte-identical results, gives no new information, \ and wastes 2-3x the compute (which scales badly to thousands of buses). Only run another sweep \ -if you genuinely change a parameter (a different mutation size, entity, metric, or candidate set). -- For "nearest neighbor by hop count" or "buses connected to bus X" goals, issue ONE \ -analyze action with query_type "nearest_neighbors" (do not loop free-text analyze \ -queries asking for branch data). -- For "test all N-1/N-2 contingencies on the nearest neighbors" goals, FIRST connect any \ -required load with a `modify` action, THEN issue ONE `sweep` with `mode:"contingency"` \ -(set target_bus, neighbor_count, contingency_order 1 or 2). Never hand-enumerate outages \ -or loop analyze/modify to test them one at a time. -- For "N-1 generator security" or "how much hot reserve is available / required" goals, issue \ -ONE `sweep` with `mode:"reserve"`. Python computes the available hot reserve and the minimum \ -required for N-1 (largest committed unit) and screens every single-generator outage in one \ -action. Do NOT enumerate generator outages yourself. -- For "minimum / minimize hot reserve for N-1" goals, issue ONE `sweep` with \ -`mode:"reserve", minimize:true`. Python greedily de-commits generators and re-screens N-1 to \ -find the minimum N-1-secure hot reserve. When it returns, the goal is fully answered — issue \ -`complete` with the reported minimum. Do NOT attempt manual per-unit de-commitment loops and \ -do NOT invent a reserve number that no screen produced. -- Declare "complete" when you have a clear answer, when further iterations \ -cannot improve the result, or when the goal is provably infeasible. -- When performing a binary search (e.g., finding a maximum scaling factor), \ -declare "complete" as soon as the feasible/infeasible gap is below 1%. The \ -last feasible value is your answer — further refinement wastes iterations \ -without meaningful improvement. -- If the last 2-3 iterations all classify as "marginal" or oscillate between \ -"feasible" and "infeasible" with a tiny gap, you are at the boundary — \ -declare "complete" immediately. -- Do NOT repeat the same modification if it already failed. -- If a simulation diverges, try a smaller or different change. -- When multiple objectives are being tracked, explain tradeoffs between them \ -in your reasoning. If you notice a tension between objectives (e.g., cost \ -decreasing but voltage stability degrading), flag it explicitly. -- You may propose tracking additional metrics by including a "propose_objectives" \ -field in your JSON response (optional): \ -"propose_objectives": [{{"name": "", "direction": "minimize", "priority": "secondary"}}] -- The operator can accept or reject proposed objectives via steering. - -{_app_section(application, concurrent_pflow, session_load_factor)}""" +if you genuinely change a parameter (a different mutation size, entity, metric, or candidate set). \ +This includes contingency and reserve sweeps: answer from the results you already have. + +=== Section 4: Check Your Command Before Sending === + +Read the goal once more and confirm your command carries every quantity it names. +A goal naming two quantities, such as "100 MW, 10 MVar", must set both. Dropping one +produces a confident answer to a different question, which is worse than failing. + +Confirm all four: +- Every number in the goal appears in your command: MW, MVar, voltage limits, counts, + bus numbers. A load with a reactive part needs BOTH Pd and Qd. +- The voltage band in "feasibility" equals the band the goal states. +- Your action answers the question asked, not a similar one. +- Every assumption the goal states (power factor, study depth, priority order) + appears in the command or in your reasoning. + +If you notice afterwards that a value was missing or wrong, send the corrected command. +A corrected sweep is not a repeat: one parameter differs, and the earlier result answered +a different question. Say exactly what you changed. + +If the corrected sweep returns results identical to the flawed ones, the correction did +not take effect. Report that. Do not present the old numbers as the answer. + +Do not re-send a sweep whose parameters are all unchanged. Sweeps are deterministic: +the same inputs give the same numbers, add nothing, and waste compute that grows quickly +with system size. + +=== Section 5: Search Rules === + +Working method: +- Explain your reasoning in every response. +- Change one thing at a time, observe the effect, then adjust. +- Stay inside physical limits: generator Pmin/Pmax, voltage limits, thermal ratings. +- Do not repeat a change that already failed. If a solve diverges, make a smaller change. + +Choosing the mode field: +- "fresh" applies your commands to the original base case. Use it for binary searches + and parameter sweeps, where each trial must be independent. +- "accumulative" applies them on top of the previous iteration. Use it when you are + refining a state you want to keep. + +When to stop: +- Answer with complete once you have a clear answer, further iterations cannot improve + it, or the goal is shown to be impossible. +- In a binary search, stop when the gap between the last feasible and last infeasible + value is under 1%. The last feasible value is your answer. +- If the last two or three iterations are all marginal, or alternate between feasible + and infeasible across a small gap, you have found the boundary. Stop there. + +Objectives: +- When several objectives are tracked, describe the trade-offs in your reasoning. Say so + explicitly when they conflict, for example cost falling while voltages degrade. +- You may suggest tracking another metric by adding an optional field: + "propose_objectives": [{{"name": "", "direction": "minimize", "priority": "secondary"}}] + The operator decides whether to accept it. + +Success criterion: +- A study succeeds when every solve returned a convergence flag of 1 or 0: 1 = converged, + 0 = did not converge (ExaGO's "Convergence status" CONVERGED / DID NOT CONVERGE, the + same as mpc.converged in its output file). What matters for each solve is only whether + it converged or not. +- A solve that returned no convergence flag crashed. A crash is not a converged or + not-converged result: report it as a crash and do not draw conclusions from it. + +{_app_section(application, concurrent_pflow, session_load_factor)} + +=== Section 6: Network Information === + +{_strip_leading_header(network_summary)} +{metadata_section}{benchmark_section} +""" def _build_stress_test_prompt( @@ -926,7 +1073,7 @@ def _build_stress_test_prompt( ) -> str: metadata_section = "" if network_metadata: - metadata_section = f"\n=== Section G: Network Metadata ===\n\n{network_metadata}\n" + metadata_section = f"\n=== Section 7: Network Metadata ===\n\n{_strip_leading_header(network_metadata)}\n" return f"""\ You are a power systems security analyst performing adversarial stress testing \ @@ -934,14 +1081,6 @@ def _build_stress_test_prompt( contingencies — component outages that cause the most severe impact on \ system operation. -=== Section A: Available Commands === - -{command_schema} - -=== Section B: Network Information === - -{network_summary} -{metadata_section} === Response Format === You MUST respond with a single JSON object. Choose one of three actions: @@ -980,6 +1119,10 @@ def _build_stress_test_prompt( "query": "most loaded lines" }} +=== Section 2: Available Commands === + +{_drop_command_envelope(_retarget_vlimits(command_schema))} + === Stress Testing Strategy === - ALWAYS use "fresh" mode — each contingency must be tested independently from the base case. @@ -994,4 +1137,10 @@ def _build_stress_test_prompt( and can characterize the system's vulnerability profile. - Do NOT test contingencies on lines with very low loading (<20%) — they are unlikely to be critical. -{_app_section(application)}""" +{_app_section(application)} + +=== Section 6: Network Information === + +{_strip_leading_header(network_summary)} +{metadata_section} +""" diff --git a/agentigrid/agentigrid/prompts/user_prompt.py b/agentigrid/agentigrid/prompts/exago/user_prompt.py similarity index 100% rename from agentigrid/agentigrid/prompts/user_prompt.py rename to agentigrid/agentigrid/prompts/exago/user_prompt.py diff --git a/agentigrid/agentigrid_architecture.md b/agentigrid/agentigrid_architecture.md index 17af9f60..3d8ee808 100644 --- a/agentigrid/agentigrid_architecture.md +++ b/agentigrid/agentigrid_architecture.md @@ -298,7 +298,7 @@ Session best (feasible): $29,924.90 [iter 5, variant A] ``` When no feasible variant has been found yet (first explore), this line is omitted. The session_best includes non-selected variants, so the LLM can detect regression even when selected iterations show higher costs. -**PFLOW "Reading Variant Results" paragraph:** Added to the PFLOW system prompt (both concurrent and sequential) via `_PFLOW_VARIANT_READING_GUIDANCE`. Contains three bullets: (1) identical cost → skipped command diagnosis, (2) regression detection via Session best, (3) session best as primary cost reference not journal selected-iteration costs. Located at `agentigrid/prompts/system_prompt.py` lines 354–367. +**PFLOW "Reading Variant Results" paragraph:** Added to the PFLOW system prompt (both concurrent and sequential) via `_PFLOW_VARIANT_READING_GUIDANCE`. Contains three bullets: (1) identical cost → skipped command diagnosis, (2) regression detection via Session best, (3) session best as primary cost reference not journal selected-iteration costs. Located at `agentigrid/prompts/exago/system_prompt.py` lines 354–367. **Section G — Network Metadata (static, computed once):** Structural facts about the base case that constrain which actions can have an effect. Computed once at session start by `network_metadata(MATNetwork)` and injected into the system prompt: @@ -495,7 +495,7 @@ Each explore+select cycle costs **one iteration** against `max_iterations`, but | `_handle_explore()` | `agentigrid/engine/agent_loop.py` | Parses variant command lists, applies each to a deep copy of the base/current network, runs all simulations concurrently via `run_parallel()`, computes Pareto front, stores results in `_explore_cache`. | | `_handle_select()` | `agentigrid/engine/agent_loop.py` | Validates selection against cache, updates `_current_network` to selected variant's network, creates journal entry with `explored_variants` metadata, clears cache. | | `SimulationExecutor.run_parallel()` | `agentigrid/engine/executor.py` | Thin ThreadPoolExecutor wrapper that runs N simulations concurrently. Each simulation is an independent subprocess. | -| System prompt | `agentigrid/prompts/system_prompt.py` | Dynamic action schema (5 actions for concurrent PFLOW vs 3 for standard). `explore` is action #1 (primary) when concurrent mode is on. Search heuristics are rewritten to recommend parallel search patterns. | +| System prompt | `agentigrid/prompts/exago/system_prompt.py` | Dynamic action schema (5 actions for concurrent PFLOW vs 3 for standard). `explore` is action #1 (primary) when concurrent mode is on. Search heuristics are rewritten to recommend parallel search patterns. | #### Search Strategy diff --git a/agentigrid/launcher/app.py b/agentigrid/launcher/app.py index 76339485..9a49e379 100644 --- a/agentigrid/launcher/app.py +++ b/agentigrid/launcher/app.py @@ -36,6 +36,7 @@ reserve_trajectory_chart, ) +from agentigrid.engine.journal import describe_contingency_scope from agentigrid.parsers import parse_matpower, network_summary # ── Page Configuration ─────────────────────────────────────────────────────── @@ -1189,8 +1190,7 @@ def _certified_reason(v: dict) -> str: st.subheader("Contingency Screening") order = meta.get("order", "?") - target_bus = meta.get("target_bus", "?") - neighbors = meta.get("neighbors", []) + scope_str = describe_contingency_scope(meta) kinds = meta.get("kinds", []) vmin = meta.get("vmin", 0.9) vmax = meta.get("vmax", 1.1) @@ -1198,15 +1198,11 @@ def _certified_reason(v: dict) -> str: failed_count = meta.get("failed_count", 0) total_count = passed_count + failed_count - neighbor_str = ( - ", ".join(f"bus {n[0]} (hop {n[1]})" for n in neighbors[:5]) - if neighbors else "—" - ) kind_str = ", ".join(sorted(set(kinds))) if kinds else "all" st.caption( - f"N-{order} contingency screen | target bus {target_bus} | " - f"neighbors: {neighbor_str} | components: {kind_str} | " + f"N-{order} contingency screen | {scope_str} | " + f"components: {kind_str} | " f"V-band [{vmin:.2f}, {vmax:.2f}] p.u. | " f"{passed_count}/{total_count} contingencies feasible." ) @@ -1260,14 +1256,15 @@ def _certified_reason(v: dict) -> str: else: # tolerate a dict shape defensively measure = a.get("measure") or a.get("action", "?") resolved = a.get("resolved", False) - note = " [inherent to OPF]" if measure == "generator_redispatch" else "" - parts.append(f"{measure} {'✓' if resolved else '✗'}{note}") + parts.append(f"{measure} {'✓' if resolved else '✗'}") trail = ", ".join(parts) st.caption(f"{v.get('label', '?')}: {trail}") st.caption( - "Relief scope: local modifications only (transformer ratio, generator " - "redispatch at neighboring buses, line switching, load curtailment). " - "Load curtailment is applied uniformly across all loads at the affected bus." + "Relief scope: fast measures on existing, committed equipment only " + "(transformer ratio, switching a line out or in, load shedding). Load " + "shedding grows tier by tier around the outage, at most " + "relief_shed_max_fraction of each bus's load; the load the mutation " + "adds is never shed." ) if passed_variants: diff --git a/agentigrid/launcher/report_generator.py b/agentigrid/launcher/report_generator.py index 22076181..af99e1b4 100644 --- a/agentigrid/launcher/report_generator.py +++ b/agentigrid/launcher/report_generator.py @@ -23,7 +23,7 @@ from reportlab.pdfbase.ttfonts import TTFont from agentigrid.engine.agent_loop import SearchSession -from agentigrid.engine.journal import is_solve_iteration +from agentigrid.engine.journal import describe_contingency_scope, is_solve_iteration from agentigrid.parsers.opflow_results import OPFLOWResult try: @@ -616,19 +616,11 @@ def _build_executive_summary( if contingency_entry is not None: meta = contingency_entry.contingency_meta or {} order = meta.get("order", "?") - target = meta.get("target_bus", "?") - neighbors = meta.get("neighbors") or [] passed = meta.get("passed_count", 0) failed = meta.get("failed_count", 0) total = passed + failed - nb_parts = [ - f"bus {nb} ({hop} hop{'s' if hop != 1 else ''})" - for nb, hop in neighbors - ] - nb_str = ", ".join(nb_parts) if nb_parts else "—" headline = ( - f"N-{order} contingency screen on the {len(neighbors)} nearest " - f"neighbors of bus {target} ({nb_str}): " + f"N-{order} contingency screen ({describe_contingency_scope(meta)}): " f"{passed}/{total} contingencies feasible, {failed} failed." ) cont_lines = [headline] @@ -1304,23 +1296,15 @@ def _build_contingency_results_section(self, session, entry) -> list: else: meta = entry.contingency_meta or {} order = meta.get("order", "?") - target_bus = meta.get("target_bus", "?") - neighbors = meta.get("neighbors") or [] passed_count = meta.get("passed_count", 0) failed_count = meta.get("failed_count", 0) total = passed_count + failed_count # Infer component kinds from variant data kinds = sorted({k for v in variants for k in (v.get("kinds") or [])}) - nb_parts = [ - f"bus {nb} ({hop} hop{'s' if hop != 1 else ''})" - for nb, hop in neighbors - ] - nb_str = ", ".join(nb_parts) if nb_parts else "—" intro = ( - f"N-{order} contingency screen: {len(neighbors)} nearest neighbors of " - f"bus {target_bus} ({nb_str}); " + f"N-{order} contingency screen: {describe_contingency_scope(meta)}; " f"components tested: {', '.join(kinds) if kinds else 'all'}; " f"feasibility band: V ∈ [{v_min:.2f}, {v_max:.2f}] pu, Rate A. " f"Result: {passed_count}/{total} feasible, {failed_count} failed." @@ -1405,11 +1389,10 @@ def _build_contingency_results_section(self, session, entry) -> list: elements.append(Spacer(1, 0.4 * cm)) elements.append(Paragraph("Relief attempts audit", s["heading2"])) elements.append(Paragraph( - "Priority-ordered relief search for each failed contingency. " - "generator_redispatch is an inherent OPF no-op: generator Pg is an " - "optimization variable, so the post-contingency OPF already includes " - "economic redispatch. It is listed for completeness but performs no " - "additional solve and never resolves a failure on its own.", + "Priority-ordered relief search for each failed contingency, using only " + "fast measures on existing, committed equipment. Generator redispatch " + "is not a separate measure: every post-contingency OPF already " + "redispatches all committed units.", s["caption"], )) elements.append(Spacer(1, 0.2 * cm)) @@ -1422,9 +1405,7 @@ def _build_contingency_results_section(self, session, entry) -> list: attempts = rel.get("attempts") or [] parts = [] for m, resolved in attempts: - if m == "generator_redispatch": - parts.append(f"{m} ✗ [inherent to OPF]") - elif resolved: + if resolved: parts.append(f"{m} ✓") else: parts.append(f"{m} ✗") diff --git a/agentigrid/tests/agent_loop/test_steering.py b/agentigrid/tests/agent_loop/test_steering.py index 2467f1d8..283eec60 100644 --- a/agentigrid/tests/agent_loop/test_steering.py +++ b/agentigrid/tests/agent_loop/test_steering.py @@ -10,7 +10,7 @@ import pytest from agentigrid.engine.agent_loop import AgentLoopController -from agentigrid.prompts.user_prompt import build_user_prompt +from agentigrid.prompts.exago.user_prompt import build_user_prompt # ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/agentigrid/tests/analyses/test_contingency.py b/agentigrid/tests/analyses/test_contingency.py index 215c451e..bb754a70 100644 --- a/agentigrid/tests/analyses/test_contingency.py +++ b/agentigrid/tests/analyses/test_contingency.py @@ -1,10 +1,18 @@ """Tests for the N-1/N-2 contingency screening engine (C.5). Covers: -- pure enumeration: component pool, N-1 / N-2 counts, command shapes, determinism, - ValueError guards, component filtering (branch-only) -- handler: _handle_contingency_sweep wiring — 15 contingencies enumerated, pass/fail - derived from the standard predicate, journal recorded, no LLM/backend call inside +- substation scope: transformers merge buses (0 hops), lines are hops, tiers grow + from both ends of a changed branch, out-of-service branches are not walked +- neighbor search: the five affected-element lists (buses, branches, gens, loads, + shunts) with tiers, changed element excluded, branches leaving the last tier + excluded +- outage pool: joining the lists, N-1 / N-2 counts, command shapes, determinism, + ValueError guards +- handler: _handle_contingency_sweep wiring — pass/fail derived from the standard + predicate, journal recorded, no LLM/backend call inside +- verdict: the change passes N-k only if every contingency passes; the LLM view + leads with the verdict and a table of every failed contingency (plus, for N-2, + failures counted per element) """ from __future__ import annotations @@ -18,8 +26,12 @@ AppConfig, ExagoConfig, DataConfig, LLMConfig, SearchConfig, OutputConfig, ) from agentigrid.engine import contingency as C +from agentigrid.engine import topology as T +from agentigrid.engine.commands import parse_command +from agentigrid.engine.modifier import apply_modifications from agentigrid.engine.agent_loop import AgentLoopController from agentigrid.engine.executor import SimulationResult +from agentigrid.parsers.matpower_model import Branch, Bus, Generator, MATNetwork from agentigrid.parsers.matpower_parser import parse_matpower from agentigrid.parsers.opflow_results import OPFLOWResult @@ -28,121 +40,341 @@ _has_118 = IEEE118.exists() +_LOAD_77 = C.ChangedElement(kind="load", bus=77) + + @pytest.fixture(scope="module") def net118(): return parse_matpower(IEEE118) # --------------------------------------------------------------------------- -# Pure enumeration +# Synthetic grid (hand-checkable) # --------------------------------------------------------------------------- +# +# tier 0 tier 1 tier 2 tier 3 tier 4 +# [1 =T= 2] --- [11] (2 circuits) ---- [21] -------- [31] ----- [41] +# \ +# ------- [12 =T= 13] ----------- [22] +# \ +# ...x... 50 (1-50 out of service) +# +# =T= transformer (ratio != 0, same substation, 0 hops); --- line (1 hop). +# Load at 1 and 21; in-service gen at 2 and 31, out-of-service gen at 11; +# shunt at 11 (Bs) and 41 (Gs). + +def _bus(bus_i, Pd=0.0, Qd=0.0, Gs=0.0, Bs=0.0): + return Bus( + bus_i=bus_i, type=1, Pd=Pd, Qd=Qd, Gs=Gs, Bs=Bs, + area=1, Vm=1.0, Va=0.0, baseKV=138.0, zone=1, Vmax=1.1, Vmin=0.9, + ) -@pytest.mark.skipif(not _has_118, reason="ieee_118_bus_v10.m not available") -class TestComponentPool: - - def test_branch_pool_deduped_endpoints(self, net118): - pool = C.build_component_pool(net118, 77, 3, ("branch", "gen", "load")) - branches = [e for e in pool if e.kind == "branch"] - assert len(branches) == 11 - assert all(e.ckt == 0 for e in branches) - endpoint_set = {(min(e.fbus, e.tbus), max(e.fbus, e.tbus)) for e in branches} - assert endpoint_set == { - (47, 69), (49, 69), (68, 69), (69, 70), (69, 75), (69, 77), - (70, 75), (74, 75), (75, 76), (75, 77), (76, 77), - } - - def test_gen_pool(self, net118): - pool = C.build_component_pool(net118, 77, 3, ("branch", "gen", "load")) - gen_buses = {e.bus for e in pool if e.kind == "gen"} - assert gen_buses == {69, 76} # 75 has no generator - - def test_load_pool(self, net118): - pool = C.build_component_pool(net118, 77, 3, ("branch", "gen", "load")) - load_buses = {e.bus for e in pool if e.kind == "load"} - assert load_buses == {75, 76} # 69 has no load - - def test_total_pool_size(self, net118): - pool = C.build_component_pool(net118, 77, 3, ("branch", "gen", "load")) - assert len(pool) == 15 - - def test_branch_only_component_filter(self, net118): - pool = C.build_component_pool(net118, 77, 3, ("branch",)) - assert len(pool) == 11 - assert all(e.kind == "branch" for e in pool) - - def test_determinism_label_sequence(self, net118): - p1 = C.build_component_pool(net118, 77, 3, ("branch", "gen", "load")) - p2 = C.build_component_pool(net118, 77, 3, ("branch", "gen", "load")) - assert [e.label() for e in p1] == [e.label() for e in p2] +def _line(f, t, status=1): + return Branch(f, t, 0.01, 0.1, 0.0, 100.0, 100.0, 100.0, 0.0, 0.0, status, -30.0, 30.0) -@pytest.mark.skipif(not _has_118, reason="ieee_118_bus_v10.m not available") -class TestEnumerate: - def test_n1_count(self, net118): - ctgs = C.enumerate_contingencies(net118, 77, 3, order=1) - assert len(ctgs) == 15 - assert all(c.order == 1 for c in ctgs) +def _xfmr(f, t): + return Branch(f, t, 0.0, 0.05, 0.0, 100.0, 100.0, 100.0, 1.0, 0.0, 1, -30.0, 30.0) - def test_n2_count(self, net118): - ctgs = C.enumerate_contingencies(net118, 77, 3, order=2) - assert len(ctgs) == 105 # C(15, 2) - assert all(c.order == 2 for c in ctgs) - def test_branch_only_counts(self, net118): - assert len(C.enumerate_contingencies(net118, 77, 3, 1, ("branch",))) == 11 - assert len(C.enumerate_contingencies(net118, 77, 3, 2, ("branch",))) == 55 +def _gen(bus, status=1): + return Generator(bus, 50.0, 0.0, 50.0, -50.0, 1.0, 100.0, status, 100.0, 0.0) - def test_order_out_of_range_raises(self, net118): - with pytest.raises(ValueError): - C.enumerate_contingencies(net118, 77, 3, order=3) - def test_neighbor_count_zero_raises(self, net118): +@pytest.fixture +def grid(): + return MATNetwork( + casename="synthetic", version="2", baseMVA=100.0, + buses=[ + _bus(1, Pd=50.0, Qd=10.0), _bus(2), _bus(11, Bs=10.0), _bus(12), _bus(13), + _bus(21, Pd=20.0), _bus(22), _bus(31), _bus(41, Gs=5.0), _bus(50), + ], + generators=[_gen(2), _gen(11, status=0), _gen(31)], + branches=[ + _xfmr(1, 2), _line(1, 11), _line(1, 11), _line(1, 12), _xfmr(12, 13), + _line(11, 21), _line(12, 22), _line(21, 31), _line(31, 41), _line(1, 50, status=0), + ], + gencost=[], header_comments="", + ) + + +LOAD_AT_1 = C.ChangedElement(kind="load", bus=1) + + +def _pool(net, changed, depth, components=C.OUTAGE_KINDS): + return C.outages_from(C.find_affected_elements(net, changed, depth), components) + + +def _labels(pool): + return [e.label() for e in pool] + + +class TestSubstations: + + def test_transformer_is_ratio_nonzero(self, grid): + assert [T.is_transformer(br) for br in grid.branches] == [ + True, False, False, False, True, False, False, False, False, False, + ] + + def test_transformers_merge_buses(self, grid): + sub = T.substation_map(grid) + assert sub[2] == sub[1] == 1 + assert sub[13] == sub[12] == 12 + assert sub[11] == 11 + + def test_no_transformers_each_bus_own_substation(self): + net = MATNetwork( + casename="flat", version="2", baseMVA=100.0, + buses=[_bus(1), _bus(2), _bus(3)], generators=[], + branches=[_line(1, 2), _line(2, 3)], gencost=[], header_comments="", + ) + assert T.substation_map(net) == {1: 1, 2: 2, 3: 3} + + def test_tiers_count_lines_not_transformers(self, grid): + scope = T.substations_within(grid, [1], 2) + assert scope.tiers == [[1], [11, 12], [21, 22]] + # 13 sits behind a transformer at tier-1 substation 12 → tier 1, not 2. + assert scope.tier_of[13] == 1 + assert scope.buses == [1, 2, 11, 12, 13, 21, 22] + + def test_out_of_service_line_not_walked(self, grid): + scope = T.substations_within(grid, [1], 4) + assert 50 not in scope.substation_of + + def test_depth_zero_is_start_substation_only(self, grid): + assert T.substations_within(grid, [2], 0).buses == [1, 2] + + def test_branch_start_grows_both_sides(self, grid): + scope = T.substations_within(grid, [11, 21], 1) + assert scope.tiers == [[11, 21], [1, 31]] + + def test_stops_when_grid_exhausted(self, grid): + scope = T.substations_within(grid, [1], 10) + assert len(scope.tiers) == 5 # tiers 0..4, then nothing new + + def test_errors(self, grid): + with pytest.raises(ValueError): + T.substations_within(grid, [999], 1) with pytest.raises(ValueError): - C.enumerate_contingencies(net118, 77, 0, order=1) + T.substations_within(grid, [1], -1) + with pytest.raises(ValueError): + T.substations_within(grid, [], 1) + + +class TestAffectedElements: + """The neighbor search output, checked list by list against the sketch above.""" + + def test_five_lists_depth2(self, grid): + a = C.find_affected_elements(grid, LOAD_AT_1, 2) + assert a.buses == [ + {"bus": 1, "substation": 1, "tier": 0}, {"bus": 2, "substation": 1, "tier": 0}, + {"bus": 11, "substation": 11, "tier": 1}, {"bus": 12, "substation": 12, "tier": 1}, + {"bus": 13, "substation": 12, "tier": 1}, {"bus": 21, "substation": 21, "tier": 2}, + {"bus": 22, "substation": 22, "tier": 2}, + ] + assert a.branches == [ + {"fbus": 1, "tbus": 2, "ckt": 0, "tier": 0}, + {"fbus": 1, "tbus": 11, "ckt": 0, "tier": 0}, + {"fbus": 1, "tbus": 11, "ckt": 1, "tier": 0}, + {"fbus": 1, "tbus": 12, "ckt": 0, "tier": 0}, + {"fbus": 12, "tbus": 13, "ckt": 0, "tier": 1}, + {"fbus": 11, "tbus": 21, "ckt": 0, "tier": 1}, + {"fbus": 12, "tbus": 22, "ckt": 0, "tier": 1}, + ] + assert a.gens == [{"bus": 2, "gen_id": 0, "tier": 0}] + assert a.loads == [{"bus": 21, "tier": 2}] # load@1 is the changed one + assert a.shunts == [{"bus": 11, "tier": 1}] + + def test_tiers_and_substations(self, grid): + a = C.find_affected_elements(grid, LOAD_AT_1, 2) + assert a.tiers == [[1], [11, 12], [21, 22]] + assert a.substations == [(1, 0), (11, 1), (12, 1), (21, 2), (22, 2)] + + def test_to_dict_has_exactly_five_lists(self, grid): + d = C.find_affected_elements(grid, LOAD_AT_1, 2).to_dict() + assert list(d) == ["buses", "branches", "gens", "loads", "shunts"] + + def test_pool_joins_the_lists(self, grid): + a = C.find_affected_elements(grid, LOAD_AT_1, 2) + pool = C.outages_from(a) + n = len(a.branches) + len(a.gens) + len(a.loads) + len(a.shunts) + assert len(pool) == n == 10 + + +class TestOutagePool: + + def test_pool_depth2_load_changed(self, grid): + pool = _pool(grid, LOAD_AT_1, 2) + assert _labels(pool) == [ + "branch 1-2", "branch 1-11", "branch 1-11 (ckt 1)", "branch 1-12", + "branch 11-21", "branch 12-13", "branch 12-22", + "gen@2", + "load@21", + "shunt@11", + ] + + def test_changed_load_excluded_other_elements_kept(self, grid): + pool = _pool(grid, LOAD_AT_1, 2) + assert "load@1" not in _labels(pool) + # Elements in the changed element's own substation are still candidates. + assert "gen@2" in _labels(pool) and "branch 1-2" in _labels(pool) + + def test_branch_leaving_last_tier_excluded(self, grid): + pool = _pool(grid, LOAD_AT_1, 2) + assert "branch 21-31" not in _labels(pool) + assert "branch 21-31" in _labels(_pool(grid, LOAD_AT_1, 3)) + + def test_out_of_service_gen_excluded(self, grid): + pool = _pool(grid, LOAD_AT_1, 2, ("gen",)) + assert _labels(pool) == ["gen@2"] + + def test_added_gen_excluded_existing_gen_kept(self, grid): + net, changed = C.apply_mutation( + grid, {"action": "add_generator_at_bus", "bus": 2, "capacity_mw": 30.0}) + assert changed == C.ChangedElement(kind="gen", bus=2, gen_id=1) + labels = _labels(_pool(net, changed, 2)) + assert "gen@2#1" not in labels + assert "gen@2" in labels and "load@1" in labels + + def test_disconnected_branch_excluded_and_both_sides(self, grid): + net, changed = C.apply_mutation( + grid, {"action": "set_branch_status", "fbus": 21, "tbus": 11, "status": 0}) + affected = C.find_affected_elements(net, changed, 1) + assert affected.tiers == [[11, 21], [1, 31]] + labels = _labels(C.outages_from(affected)) + assert "branch 11-21" not in labels + assert "branch 21-31" in labels and "branch 1-11" in labels + assert "gen@31" in labels and "gen@2" in labels + + def test_connected_line_is_walked_and_excluded(self, grid): + # 1-50 is out of service in the base grid; connecting it brings 50 into tier 0. + net, changed = C.apply_mutation( + grid, {"action": "set_branch_status", "fbus": 1, "tbus": 50, "status": 1}) + affected = C.find_affected_elements(net, changed, 0) + assert affected.tiers == [[1, 50]] + assert "branch 1-50" not in _labels(C.outages_from(affected)) + + def test_parallel_circuit_only_that_circuit(self, grid): + net, changed = C.apply_mutation( + grid, {"action": "set_branch_status", "fbus": 1, "tbus": 11, "ckt": 1, "status": 0}) + labels = _labels(_pool(net, changed, 1)) + assert "branch 1-11" in labels and "branch 1-11 (ckt 1)" not in labels + + def test_component_filter(self, grid): + pool = _pool(grid, LOAD_AT_1, 4, ("shunt",)) + assert _labels(pool) == ["shunt@11", "shunt@41"] + + def test_element_placement(self, grid): + pool = _pool(grid, LOAD_AT_1, 2) + by_label = {e.label(): e for e in pool} + assert by_label["branch 11-21"].tier == 1 + assert by_label["load@21"].tier == 2 + assert by_label["gen@2"].tier == 0 + + def test_n1_and_n2_counts(self, grid): + pool = _pool(grid, LOAD_AT_1, 2) + assert len(C.enumerate_contingencies(pool, 1)) == 10 + assert len(C.enumerate_contingencies(pool, 2)) == 45 # C(10, 2) + + def test_order_out_of_range_raises(self, grid): + pool = _pool(grid, LOAD_AT_1, 2) + with pytest.raises(ValueError): + C.enumerate_contingencies(pool, 3) - def test_determinism(self, net118): - a = C.enumerate_contingencies(net118, 77, 3, order=2) - b = C.enumerate_contingencies(net118, 77, 3, order=2) + def test_determinism(self, grid): + a = C.enumerate_contingencies(_pool(grid, LOAD_AT_1, 3), 2) + b = C.enumerate_contingencies(_pool(grid, LOAD_AT_1, 3), 2) assert [c.label() for c in a] == [c.label() for c in b] -@pytest.mark.skipif(not _has_118, reason="ieee_118_bus_v10.m not available") +class TestApplyMutation: + + @pytest.mark.parametrize("raw,expected", [ + ({"action": "add_load_at_bus", "bus": 21, "Pd": 50.0}, C.ChangedElement(kind="load", bus=21)), + ({"action": "set_load", "bus": 21, "Pd": 0.0}, C.ChangedElement(kind="load", bus=21)), + ({"action": "set_gen_status", "bus": 2, "status": 0}, C.ChangedElement(kind="gen", bus=2, gen_id=0)), + ({"action": "set_branch_status", "fbus": 12, "tbus": 22, "status": 0}, + C.ChangedElement(kind="branch", fbus=12, tbus=22, ckt=0)), + ]) + def test_changed_element(self, grid, raw, expected): + assert C.apply_mutation(grid, raw)[1] == expected + + def test_mutation_applied_to_copy_only(self, grid): + net, _ = C.apply_mutation(grid, {"action": "add_load_at_bus", "bus": 21, "Pd": 50.0}) + assert {b.bus_i: b.Pd for b in net.buses}[21] == 70.0 + assert {b.bus_i: b.Pd for b in grid.buses}[21] == 20.0 + + def test_added_generator_is_last_at_bus(self, grid): + grid.generators.append(_gen(2)) + _, changed = C.apply_mutation( + grid, {"action": "add_generator_at_bus", "bus": 2, "capacity_mw": 10.0}) + assert changed.gen_id == 2 + + @pytest.mark.parametrize("raw", [ + None, + {"action": "scale_all_loads", "factor": 1.1}, # not an allowed mutation + {"action": "add_load_at_bus"}, # missing bus + {"action": "add_load_at_bus", "bus": 999, "Pd": 1.0}, + {"action": "set_gen_status", "bus": 12, "status": 0}, # no generator there + {"action": "set_gen_status", "bus": 2, "gen_id": 5, "status": 0}, + {"action": "set_branch_status", "fbus": 1, "tbus": 31, "status": 0}, + {"action": "set_branch_status", "fbus": 1, "tbus": 11, "ckt": 2, "status": 0}, + ]) + def test_invalid_mutations_raise(self, grid, raw): + with pytest.raises(ValueError): + C.apply_mutation(grid, raw) + + class TestCommandShapes: - def test_branch_to_command(self, net118): - pool = C.build_component_pool(net118, 77, 3, ("branch",)) - cmd = pool[0].to_command() - assert cmd["action"] == "set_branch_status" - assert cmd["status"] == 0 - assert cmd["ckt"] == 0 - assert "fbus" in cmd and "tbus" in cmd - - def test_gen_to_command(self, net118): - pool = C.build_component_pool(net118, 77, 3, ("gen",)) - cmd = pool[0].to_command() - assert cmd == { - "action": "set_gen_status", "bus": cmd["bus"], - "gen_id": cmd["gen_id"], "status": 0, - } - assert cmd["gen_id"] == 0 - - def test_load_to_command(self, net118): - pool = C.build_component_pool(net118, 77, 3, ("load",)) - cmd = pool[0].to_command() - assert cmd["action"] == "set_load" - assert cmd["Pd"] == 0 and cmd["Qd"] == 0 - - def test_contingency_label(self, net118): - ctgs = C.enumerate_contingencies(net118, 77, 3, order=2, components=("branch",)) - assert ctgs[0].label().endswith(" out") - assert " + " in ctgs[0].label() - - def test_contingency_commands_length_matches_order(self, net118): - n2 = C.enumerate_contingencies(net118, 77, 3, order=2, components=("branch",)) + def test_outage_commands(self, grid): + pool = _pool(grid, LOAD_AT_1, 2) + by_kind = {e.kind: e.to_command() for e in pool} + assert by_kind["branch"]["action"] == "set_branch_status" + assert by_kind["branch"]["status"] == 0 + assert by_kind["gen"] == {"action": "set_gen_status", "bus": 2, "gen_id": 0, "status": 0} + assert by_kind["load"] == {"action": "set_load", "bus": 21, "Pd": 0, "Qd": 0} + assert by_kind["shunt"] == {"action": "set_shunt", "bus": 11, "Gs": 0, "Bs": 0} + + def test_outage_commands_apply(self, grid): + pool = _pool(grid, LOAD_AT_1, 4, ("shunt", "load")) + cmds = [parse_command(e.to_command()) for e in pool] + out, _ = apply_modifications(grid, cmds, application="opflow") + by_bus = {b.bus_i: b for b in out.buses} + assert (by_bus[11].Gs, by_bus[11].Bs) == (0, 0) + assert (by_bus[41].Gs, by_bus[41].Bs) == (0, 0) + assert (by_bus[21].Pd, by_bus[21].Qd) == (0, 0) + assert by_bus[1].Pd == 50.0 # changed load untouched + + def test_contingency_label_and_commands(self, grid): + pool = _pool(grid, LOAD_AT_1, 2, ("branch",)) + n2 = C.enumerate_contingencies(pool, 2) + assert n2[0].label().endswith(" out") and " + " in n2[0].label() assert len(n2[0].commands()) == 2 +# --------------------------------------------------------------------------- +# Real IEEE 118-bus case +# --------------------------------------------------------------------------- + +@pytest.mark.skipif(not _has_118, reason="ieee_118_bus_v10.m not available") +class TestIEEE118: + + def test_scope_contains_tiers_around_77(self, net118): + scope = T.substations_within(net118, [77], 3) + assert scope.tiers[0] == [T.substation_map(net118)[77]] + assert len(scope.tiers) == 4 + + def test_pool_has_no_branch_leaving_scope(self, net118): + affected = C.find_affected_elements(net118, C.ChangedElement(kind="load", bus=77), 3) + pool = C.outages_from(affected) + in_scope = {b["bus"] for b in affected.buses} + for e in pool: + if e.kind == "branch": + assert e.fbus in in_scope and e.tbus in in_scope + assert "load@77" not in _labels(pool) + # --------------------------------------------------------------------------- # Handler # --------------------------------------------------------------------------- @@ -214,70 +446,161 @@ def _controller(self, tmp_path): controller._current_network = net return controller, backend_mock - def test_n1_screen_records_15_all_pass(self, tmp_path): + def _n_expected(self, depth, order): + pool = _pool(parse_matpower(IEEE118), _LOAD_77, depth) + return len(C.enumerate_contingencies(pool, order)) + + def test_n1_screen_all_pass(self, tmp_path): controller, backend_mock = self._controller(tmp_path) + n = self._n_expected(1, 1) calls_before = len(backend_mock.mock_calls) with patch("agentigrid.engine.agent_loop.parse_simulation_result_for_app", return_value=_opflow(feasible=True)): kind, ok = controller._handle_contingency_sweep(1, { - "mode": "contingency", "target_bus": 77, - "neighbor_count": 3, "contingency_order": 1, - "components": ["branch", "gen", "load"], + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": 1, "contingency_order": 1, "feasibility": {"Vmin": 0.9, "Vmax": 1.1}, - "description": "N-1 screen on bus 77 neighbors", + "description": "N-1 screen around bus 77", }) assert (kind, ok) == ("sweep", True) entry = controller._journal.entries[-1] assert entry.mode == "contingency" - assert len(entry.explored_variants) == 15 - assert entry.contingency_meta["passed_count"] == 15 + assert len(entry.explored_variants) == n + assert entry.contingency_meta["passed_count"] == n assert entry.contingency_meta["failed_count"] == 0 - assert entry.contingency_meta["target_bus"] == 77 + assert entry.contingency_meta["mutation"] == {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0} + assert entry.contingency_meta["changed_element"] == {"type": "load", "bus": 77} + assert entry.contingency_meta["substation_depth"] == 1 + aff = entry.contingency_meta["affected"] + assert set(aff) == {"buses", "branches", "gens", "loads", "shunts"} + assert {"bus": 77, "substation": 77, "tier": 0} in aff["buses"] + assert all(ld["bus"] != 77 for ld in aff["loads"]) assert entry.contingency_meta["order"] == 1 + assert all("load@77" not in v["label"] for v in entry.explored_variants) + assert "around changed element load@77" in controller._latest_results_text + assert entry.contingency_meta["passes"] is True + assert entry.feasible is True + assert f"VERDICT: load@77 passes N-1: all {n} contingencies passed." in ( + controller._latest_results_text + ) # No LLM/backend call inside the screen. assert len(backend_mock.mock_calls) == calls_before def test_n1_screen_all_fail_when_infeasible(self, tmp_path): controller, _ = self._controller(tmp_path) + n = self._n_expected(1, 1) with patch("agentigrid.engine.agent_loop.parse_simulation_result_for_app", return_value=_opflow(feasible=False)): kind, ok = controller._handle_contingency_sweep(1, { - "mode": "contingency", "target_bus": 77, - "neighbor_count": 3, "contingency_order": 1, + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": 1, "contingency_order": 1, "feasibility": {"Vmin": 0.9, "Vmax": 1.1}, }) assert (kind, ok) == ("sweep", True) entry = controller._journal.entries[-1] assert entry.contingency_meta["passed_count"] == 0 - assert entry.contingency_meta["failed_count"] == 15 - assert "FAILED: 15 / 15" in controller._latest_results_text - - def test_n2_screen_records_105(self, tmp_path): + assert entry.contingency_meta["failed_count"] == n + assert f"FAILED: {n} / {n}" in controller._latest_results_text + # The reference solve is infeasible too, so the verdict names that first. + assert entry.contingency_meta["passes"] is False + assert entry.feasible is False + assert "does NOT pass N-1: the operating point with the change is already infeasible" in ( + controller._latest_results_text + ) + assert f"FAILED contingencies ({n} of {n}):" in controller._latest_results_text + + def test_n2_screen_count(self, tmp_path): controller, _ = self._controller(tmp_path) + n = self._n_expected(1, 2) with patch("agentigrid.engine.agent_loop.parse_simulation_result_for_app", return_value=_opflow(feasible=True)): kind, ok = controller._handle_contingency_sweep(1, { - "mode": "contingency", "target_bus": 77, - "neighbor_count": 3, "contingency_order": 2, + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": 1, "contingency_order": 2, "feasibility": {"Vmin": 0.9, "Vmax": 1.1}, }) assert (kind, ok) == ("sweep", True) entry = controller._journal.entries[-1] - assert len(entry.explored_variants) == 105 + assert len(entry.explored_variants) == n assert entry.contingency_meta["order"] == 2 + assert "Elements in failed contingencies" not in controller._latest_results_text + + def test_n2_failures_counted_per_element(self, tmp_path): + controller, _ = self._controller(tmp_path) + with patch("agentigrid.engine.agent_loop.parse_simulation_result_for_app", + return_value=_opflow(feasible=False)): + controller._handle_contingency_sweep(1, { + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": 1, "contingency_order": 2, + }) + assert "Elements in failed contingencies" in controller._latest_results_text - def test_missing_target_bus_errors(self, tmp_path): + def test_view_one_failure(self, tmp_path): + """One failed contingency out of three: verdict fails and the table names it.""" + controller, _ = self._controller(tmp_path) + net = parse_matpower(IEEE118) + affected = C.find_affected_elements(net, _LOAD_77, 1) + ctgs = C.enumerate_contingencies(C.outages_from(affected), 1)[:3] + summaries = [ + {"label": c.label(), "kinds": [e.kind for e in c.elements], + "tiers": [e.tier for e in c.elements], "passed": i != 1, + "voltage_min": 0.95, "voltage_max": 1.05, "max_line_loading_pct": 80.0, + "reason": "" if i != 1 else "did not converge"} + for i, c in enumerate(ctgs) + ] + passes, verdict = C.screen_verdict(_LOAD_77, 1, 3, 1, True) + assert passes is False + text = controller._build_contingency_llm_view( + changed=_LOAD_77, affected=affected, order=1, components=list(C.OUTAGE_KINDS), + vmin=0.9, vmax=1.1, contingencies=ctgs, contingency_summaries=summaries, + passed_count=2, failed_count=1, ref_passed=True, ref_reason="", + verdict=verdict, threshold=0, top_n=25, + ) + assert "VERDICT: load@77 does NOT pass N-1: 1 of 3 contingencies failed." in text + failed_block = text.split("FAILED contingencies (1 of 3):")[1].split("\n\n")[0] + assert ctgs[1].label() in failed_block + assert ctgs[0].label() not in failed_block + + def test_default_depth_is_3(self, tmp_path): + controller, _ = self._controller(tmp_path) + with patch("agentigrid.engine.agent_loop.parse_simulation_result_for_app", + return_value=_opflow(feasible=True)): + kind, ok = controller._handle_contingency_sweep(1, { + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + }) + assert (kind, ok) == ("sweep", True) + entry = controller._journal.entries[-1] + assert entry.contingency_meta["substation_depth"] == 3 + assert len(entry.explored_variants) == self._n_expected(3, 1) + + def test_missing_mutation_errors(self, tmp_path): + controller, _ = self._controller(tmp_path) + kind, ok = controller._handle_contingency_sweep(1, {"mode": "contingency"}) + assert kind == "error" + assert "mutation" in (controller._error_feedback or "") + + def test_invalid_mutation_errors(self, tmp_path): + controller, _ = self._controller(tmp_path) + kind, ok = controller._handle_contingency_sweep(1, { + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 99999, "Pd": 50.0}, + }) + assert kind == "error" + assert "99999" in (controller._error_feedback or "") + + def test_bad_depth_errors(self, tmp_path): controller, _ = self._controller(tmp_path) kind, ok = controller._handle_contingency_sweep(1, { - "mode": "contingency", "neighbor_count": 3, + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": -1, }) assert kind == "error" - assert "target_bus" in (controller._error_feedback or "") + assert "substation_depth" in (controller._error_feedback or "") def test_bad_order_errors(self, tmp_path): controller, _ = self._controller(tmp_path) kind, ok = controller._handle_contingency_sweep(1, { - "mode": "contingency", "target_bus": 77, "contingency_order": 3, + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "contingency_order": 3, }) assert kind == "error" assert "contingency_order" in (controller._error_feedback or "") @@ -286,22 +609,37 @@ def test_runaway_guard(self, tmp_path): import dataclasses controller, _ = self._controller(tmp_path) # Force a tiny guard (SearchConfig/AppConfig are frozen → rebuild them) so - # N-2 (105 contingencies) trips it. + # the N-2 study trips it. new_search = dataclasses.replace(controller._config.search, contingency_max_count=50) controller._config = dataclasses.replace(controller._config, search=new_search) - kind, ok = controller._handle_contingency_sweep(1, { - "mode": "contingency", "target_bus": 77, - "neighbor_count": 3, "contingency_order": 2, - }) - assert kind == "error" - assert "exceeding the guard" in (controller._error_feedback or "") + n = self._n_expected(1, 2) + assert n > 50 + with patch.object(controller, "_run_contingency_screen") as mock_screen: + kind, ok = controller._handle_contingency_sweep(1, { + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": 1, "contingency_order": 2, + }) + # Stops without solving and says the study cannot be completed; not an error + # that invites the agent to shrink the study. + assert (kind, ok) == ("sweep", True) + mock_screen.assert_not_called() + verdict = ( + f"VERDICT: The number of contingencies ({n}) exceeds 50, " + "the study cannot be completed with existing resources." + ) + assert verdict in controller._latest_results_text + entry = controller._journal.entries[-1] + assert entry.contingency_meta["verdict"] == verdict + assert entry.contingency_meta["passes"] is False + assert entry.explored_variants == [] def test_dispatch_routes_contingency_mode(self, tmp_path): """_handle_sweep must route mode=contingency to the contingency handler.""" controller, _ = self._controller(tmp_path) with patch.object(controller, "_handle_contingency_sweep", return_value=("sweep", True)) as mock_h: - controller._handle_sweep(1, {"mode": "contingency", "target_bus": 77}) + controller._handle_sweep(1, {"mode": "contingency", + "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}}) mock_h.assert_called_once() def test_non_opflow_rejected(self, tmp_path): @@ -310,6 +648,81 @@ def test_non_opflow_rejected(self, tmp_path): new_search = dataclasses.replace(controller._config.search, application="pflow") controller._config = dataclasses.replace(controller._config, search=new_search) kind, ok = controller._handle_contingency_sweep(1, { - "mode": "contingency", "target_bus": 77, + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, }) assert kind == "error" + + +class TestAddedLoad: + """The load a mutation adds is the development under study; relief protects it.""" + + def test_add_load_returns_increase_only(self): + net = parse_matpower(IEEE118) if _has_118 else None + if net is None: + pytest.skip("ieee_118_bus_v10.m not available") + mutated, changed = C.apply_mutation(net, {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}) + dp, dq = C.added_load(net, mutated, changed)[77] + assert dp == pytest.approx(50.0) + + def test_generator_mutation_protects_nothing(self): + net = parse_matpower(IEEE118) if _has_118 else None + if net is None: + pytest.skip("ieee_118_bus_v10.m not available") + mutated, changed = C.apply_mutation(net, {"action": "add_generator_at_bus", "bus": 77, "capacity_mw": 50.0}) + assert C.added_load(net, mutated, changed) == {} + + +class TestVerdict: + """The change passes N-k only if every contingency passes.""" + + def test_all_pass(self): + assert C.screen_verdict(_LOAD_77, 1, 25, 0, True) == ( + True, "VERDICT: load@77 passes N-1: all 25 contingencies passed.", + ) + + def test_one_failure_fails(self): + passes, line = C.screen_verdict(_LOAD_77, 1, 25, 1, True) + assert passes is False + assert line == "VERDICT: load@77 does NOT pass N-1: 1 of 25 contingencies failed." + + def test_infeasible_reference_fails(self): + passes, line = C.screen_verdict(_LOAD_77, 2, 25, 0, False, "voltage out of band") + assert passes is False + assert "does NOT pass N-2" in line and "voltage out of band" in line + + +class TestFailedElementCounts: + + def test_counts_and_order(self): + a = C.OutageElement(kind="branch", tier=0, fbus=1, tbus=2, ckt=0) + b = C.OutageElement(kind="branch", tier=1, fbus=2, tbus=3, ckt=0) + g = C.OutageElement(kind="gen", tier=1, bus=3, gen_id=0) + ctgs = C.enumerate_contingencies([a, b, g], 2) # a+b, a+g, b+g + rows = C.failed_element_counts(ctgs, [False, False, True]) + assert [(e.label(), n, of) for e, n, of in rows] == [ + ("branch 1-2", 2, 2), ("branch 2-3", 1, 2), ("gen@3", 1, 2), + ] + + def test_no_failures(self): + a = C.OutageElement(kind="load", tier=0, bus=5) + assert C.failed_element_counts(C.enumerate_contingencies([a], 1), [True]) == [] + + +class TestDescribeScope: + """The one-line scope text shown in the launcher and PDF report.""" + + def test_new_meta(self, grid): + from agentigrid.engine.journal import describe_contingency_scope + a = C.find_affected_elements(grid, LOAD_AT_1, 2) + text = describe_contingency_scope({ + "changed_element": LOAD_AT_1.to_dict(), "substation_depth": 2, "affected": a.to_dict(), + }) + assert text == ( + "around load at bus 1; 5 substations in 3 tier(s) within substation depth 2 " + "(7 buses, 7 branches, 1 generators, 1 loads, 1 shunts)" + ) + + def test_meta_without_affected_record(self): + from agentigrid.engine.journal import describe_contingency_scope + text = describe_contingency_scope({"order": 1}) + assert text.startswith("study scope not recorded") diff --git a/agentigrid/tests/analyses/test_explore.py b/agentigrid/tests/analyses/test_explore.py index ca2c6b28..fa9b95f6 100644 --- a/agentigrid/tests/analyses/test_explore.py +++ b/agentigrid/tests/analyses/test_explore.py @@ -545,7 +545,7 @@ def test_cli_override_concurrent_pflow(self): class TestSystemPromptConcurrent: def test_pflow_concurrent_includes_explore(self): - from agentigrid.prompts.system_prompt import build_system_prompt + from agentigrid.prompts.exago.system_prompt import build_system_prompt prompt = build_system_prompt( command_schema="test", network_summary="test", @@ -557,7 +557,7 @@ def test_pflow_concurrent_includes_explore(self): assert "Pareto" in prompt or "pareto" in prompt.lower() def test_pflow_non_concurrent_no_explore(self): - from agentigrid.prompts.system_prompt import build_system_prompt + from agentigrid.prompts.exago.system_prompt import build_system_prompt prompt = build_system_prompt( command_schema="test", network_summary="test", @@ -568,7 +568,7 @@ def test_pflow_non_concurrent_no_explore(self): assert '"select"' not in prompt def test_opflow_concurrent_no_explore(self): - from agentigrid.prompts.system_prompt import build_system_prompt + from agentigrid.prompts.exago.system_prompt import build_system_prompt prompt = build_system_prompt( command_schema="test", network_summary="test", @@ -585,7 +585,7 @@ def test_opflow_concurrent_no_explore(self): class TestUserPromptExploreText: def test_explore_text_replaces_results(self): - from agentigrid.prompts.user_prompt import build_user_prompt + from agentigrid.prompts.exago.user_prompt import build_user_prompt result = build_user_prompt( goal="test goal", journal_text=None, @@ -596,7 +596,7 @@ def test_explore_text_replaces_results(self): assert "Latest Results" not in result def test_no_explore_text_shows_results(self): - from agentigrid.prompts.user_prompt import build_user_prompt + from agentigrid.prompts.exago.user_prompt import build_user_prompt result = build_user_prompt( goal="test goal", journal_text=None, @@ -606,7 +606,7 @@ def test_no_explore_text_shows_results(self): assert "Neighborhood Exploration" not in result def test_explore_text_none_shows_results(self): - from agentigrid.prompts.user_prompt import build_user_prompt + from agentigrid.prompts.exago.user_prompt import build_user_prompt result = build_user_prompt( goal="test goal", journal_text=None, @@ -955,7 +955,7 @@ class TestSystemPromptConcurrentMode: """Extended tests for the system prompt with concurrent_pflow mode.""" def test_pflow_concurrent_has_explore_and_select(self): - from agentigrid.prompts.system_prompt import build_system_prompt + from agentigrid.prompts.exago.system_prompt import build_system_prompt prompt = build_system_prompt( command_schema="test", network_summary="net", application="pflow", concurrent_pflow=True, @@ -966,7 +966,7 @@ def test_pflow_concurrent_has_explore_and_select(self): assert "neighborhood" in prompt.lower() def test_pflow_concurrent_has_pareto_star(self): - from agentigrid.prompts.system_prompt import build_system_prompt + from agentigrid.prompts.exago.system_prompt import build_system_prompt prompt = build_system_prompt( command_schema="test", network_summary="net", application="pflow", concurrent_pflow=True, @@ -974,7 +974,7 @@ def test_pflow_concurrent_has_pareto_star(self): assert "★" in prompt or "Pareto" in prompt def test_pflow_non_concurrent_no_explore(self): - from agentigrid.prompts.system_prompt import build_system_prompt + from agentigrid.prompts.exago.system_prompt import build_system_prompt prompt = build_system_prompt( command_schema="test", network_summary="net", application="pflow", concurrent_pflow=False, @@ -983,7 +983,7 @@ def test_pflow_non_concurrent_no_explore(self): assert '"select"' not in prompt def test_opflow_concurrent_no_explore_section(self): - from agentigrid.prompts.system_prompt import build_system_prompt + from agentigrid.prompts.exago.system_prompt import build_system_prompt prompt = build_system_prompt( command_schema="test", network_summary="net", application="opflow", concurrent_pflow=True, diff --git a/agentigrid/tests/analyses/test_relief.py b/agentigrid/tests/analyses/test_relief.py index 3f7aef1f..6af90492 100644 --- a/agentigrid/tests/analyses/test_relief.py +++ b/agentigrid/tests/analyses/test_relief.py @@ -2,8 +2,13 @@ Covers: - priority order respected (first resolving measure returned; earlier ones logged failed) -- generator_redispatch pass-through (no command applied, never resolves) -- load_curtailment backstop (always resolves, reports minimum MW within tolerance) +- generator_redispatch is not a measure (committed units already redispatch in OPF) +- line_switching switches nearby lines out, and open ones in (never the outaged ones) +- load_curtailment sheds tier by tier around the outage, at most + relief_shed_max_fraction of each bus, bisecting the share in the first tier that works; + the load the mutation added is never shed +- relief guard: relief_measures is dropped unless the goal (or a steering directive) + asks for relief; a dropped request is served from the plain screen's cache - determinism (identical inputs → identical ReliefResult) - handler: an N-2 run with seeded failures attaches per-failure relief entries to the view and journal, with no LLM/backend call inside the screen @@ -22,6 +27,7 @@ ) from agentigrid.engine import contingency as C from agentigrid.engine import relief +from agentigrid.engine import topology as T from agentigrid.engine.agent_loop import AgentLoopController from agentigrid.engine.executor import SimulationResult from agentigrid.parsers.matpower_parser import parse_matpower @@ -34,6 +40,8 @@ _CFG = SimpleNamespace( relief_tap_steps=[0.90, 0.95, 1.00, 1.05, 1.10], relief_curtail_tol_mw=1.0, + relief_shed_max_fraction=0.10, + relief_shed_max_depth=3, relief_max_solves=2000, ) @@ -59,8 +67,11 @@ def net118(): @pytest.fixture def branch_contingency(net118): - # A single branch outage at a neighbor of bus 77. - return C.enumerate_contingencies(net118, 77, 3, order=1, components=("branch",))[0] + # A single branch outage (47-69) in the substations around a load at bus 77. + affected = C.find_affected_elements(net118, C.ChangedElement(kind="load", bus=77), 3) + pool = C.outages_from(affected, ("branch",)) + ctgs = C.enumerate_contingencies(pool, order=1) + return next(c for c in ctgs if c.label() == "branch 47-69 out") # --------------------------------------------------------------------------- @@ -80,63 +91,142 @@ def solve_fn(net): res = relief.find_relief( net118, branch_contingency, - ["transformer_ratio", "generator_redispatch", "line_switching"], + ["transformer_ratio", "line_switching"], 0.9, 1.1, solve_fn, _CFG, ) assert res.resolved is True assert res.action.measure == "line_switching" + assert res.action.detail.startswith("switch out branch") # Earlier measures recorded as attempted-and-failed, in order. - assert res.attempts[0] == ("transformer_ratio", False) - assert res.attempts[1] == ("generator_redispatch", False) - assert res.attempts[-1] == ("line_switching", True) + assert res.attempts == [("transformer_ratio", False), ("line_switching", True)] - def test_generator_redispatch_passthrough_never_resolves(self, net118, branch_contingency): - calls = {"n": 0} + def test_generator_redispatch_is_not_a_measure(self): + assert "generator_redispatch" not in relief.MEASURE_ORDER + + def test_line_switching_switches_open_line_in(self, net118, branch_contingency): + """An open line at the contingency's buses is switched back in.""" + import copy + net = copy.deepcopy(net118) + focus = relief._focus_buses(branch_contingency) + outaged = relief._outaged_branch_keys(branch_contingency) + spare, key = next( + (br, k) for br, k in relief._incident_branches_sorted(net, focus) if k not in outaged + ) + spare.status = 0 # a normally-open line near the outage + + def solve_fn(n): + br = next(b for b in n.branches if (b.fbus, b.tbus) == (spare.fbus, spare.tbus)) + return _opflow(feasible=br.status == 1) + + res = relief.find_relief(net, branch_contingency, ["line_switching"], 0.9, 1.1, solve_fn, _CFG) + assert res.resolved is True + assert res.action.detail == f"switch in branch {spare.fbus}-{spare.tbus}" + assert res.action.commands[0]["status"] == 1 + + def test_line_switching_never_switches_outaged_line_back_in(self, net118, branch_contingency): + outaged = relief._outaged_branch_keys(branch_contingency) + seen = [] + + def solve_fn(n): + for b in n.branches: + key = (min(b.fbus, b.tbus), max(b.fbus, b.tbus), 0) + if key in outaged: + seen.append(b.status) + return _opflow(feasible=False) + + relief.find_relief(net118, branch_contingency, ["line_switching"], 0.9, 1.1, solve_fn, _CFG) + assert seen and all(st == 0 for st in seen) + + def _tiers(self, net, contingency): + """Load buses per tier around the contingency, as the shedding search sees them.""" + scope = T.substations_within(net, sorted(relief._focus_buses(contingency)), 3) + tiers = [[] for _ in range(4)] + for b in sorted(net.buses, key=lambda b: b.bus_i): + if b.Pd > 0 and b.bus_i in scope.tier_of: + tiers[scope.tier_of[b.bus_i]].append(b.bus_i) + return tiers + + @staticmethod + def _shed(base_net, net, buses): + base = {b.bus_i: b.Pd for b in base_net.buses} + return sum(base[b.bus_i] - b.Pd for b in net.buses if b.bus_i in buses) + + def test_load_shedding_resolves_in_tier0(self, net118, branch_contingency): + tiers = self._tiers(net118, branch_contingency) + assert tiers[0], "need load in tier 0 for this test" + tier0_pd = sum(b.Pd for b in net118.buses if b.bus_i in tiers[0]) + thresh = 0.04 * tier0_pd # 4% at tier 0 is enough def solve_fn(net): - calls["n"] += 1 - return _opflow(feasible=True) # would resolve anything that actually solves + return _opflow(feasible=self._shed(net118, net, tiers[0]) >= thresh - 1e-9) - res = relief.find_relief( - net118, branch_contingency, ["generator_redispatch"], - 0.9, 1.1, solve_fn, _CFG, + res = relief.find_relief(net118, branch_contingency, ["load_curtailment"], 0.9, 1.1, solve_fn, _CFG) + assert res.resolved is True + assert res.action.measure == "load_curtailment" + assert {c["bus"] for c in res.action.commands} == set(tiers[0]) + shed = sum( + next(b.Pd for b in net118.buses if b.bus_i == c["bus"]) - c["Pd"] + for c in res.action.commands ) - assert res.resolved is False - assert res.attempts == [("generator_redispatch", False)] - # Pass-through applies no command → performs no solve. - assert calls["n"] == 0 + assert abs(shed - thresh) <= 2.0 + assert "tier 0" in res.action.detail and "tier 1" not in res.action.detail - def test_load_curtailment_backstop_resolves_min_mw(self, net118, branch_contingency): - focus = relief._focus_buses(branch_contingency) - base = sum(b.Pd for b in net118.buses if b.bus_i in focus and (b.Pd or b.Qd)) - assert base > 0, "need load at focus buses for this test" - thresh = 0.4 * base + def test_load_shedding_moves_to_next_tier(self, net118, branch_contingency): + """10% at tier 0 is not enough, so tier 0 stays at 10% and tier 1 is added.""" + tiers = self._tiers(net118, branch_contingency) + assert tiers[0] and tiers[1] + tier0_pd = sum(b.Pd for b in net118.buses if b.bus_i in tiers[0]) + tier1_pd = sum(b.Pd for b in net118.buses if b.bus_i in tiers[1]) + need = 0.10 * tier0_pd + 0.05 * tier1_pd def solve_fn(net): - curtailed = base - sum(b.Pd for b in net.buses if b.bus_i in focus) - return _opflow(feasible=curtailed >= thresh - 1e-9) + return _opflow(feasible=self._shed(net118, net, tiers[0] + tiers[1]) >= need - 1e-9) - res = relief.find_relief( - net118, branch_contingency, ["load_curtailment"], - 0.9, 1.1, solve_fn, _CFG, - ) + res = relief.find_relief(net118, branch_contingency, ["load_curtailment"], 0.9, 1.1, solve_fn, _CFG) assert res.resolved is True - assert res.action.measure == "load_curtailment" - # Minimum feasible curtailment ≈ threshold, within the bisection tolerance. - curtailed_mw = base - sum(c["Pd"] for c in res.action.commands) - assert abs(curtailed_mw - thresh) <= 2.0 + pd = {b.bus_i: b.Pd for b in net118.buses} + by_bus = {c["bus"]: c["Pd"] for c in res.action.commands} + assert set(by_bus) == set(tiers[0] + tiers[1]) + for b in tiers[0]: + assert by_bus[b] == pytest.approx(0.9 * pd[b]) + for b in tiers[1]: + assert 0.9 * pd[b] <= by_bus[b] < pd[b] + assert "tier 1" in res.action.detail + + def test_load_shedding_never_exceeds_cap(self, net118, branch_contingency): + """Unresolved when 10% at every tier is not enough; no bus is ever shed more.""" + pd = {b.bus_i: b.Pd for b in net118.buses} + worst = {"share": 0.0} - def test_load_curtailment_always_resolves_at_full(self, net118, branch_contingency): def solve_fn(net): - # Feasible only when ALL local load removed (f == 1). - total = sum(b.Pd for b in net.buses if b.bus_i in relief._focus_buses(branch_contingency)) - return _opflow(feasible=total <= 1e-6) + for b in net.buses: + if pd[b.bus_i] > 0: + worst["share"] = max(worst["share"], 1 - b.Pd / pd[b.bus_i]) + return _opflow(feasible=False) + + res = relief.find_relief(net118, branch_contingency, ["load_curtailment"], 0.9, 1.1, solve_fn, _CFG) + assert res.resolved is False + assert worst["share"] <= 0.10 + 1e-12 + + def test_load_shedding_never_sheds_added_load(self, net118, branch_contingency): + """Protected (added) load stays; only the bus's other load counts toward the 10%.""" + tiers = self._tiers(net118, branch_contingency) + bus = tiers[0][0] + pd = {b.bus_i: b.Pd for b in net118.buses} + protected = {bus: (0.5 * pd[bus], 0.0)} # half of this bus's load is "new" + lowest = {"pd": pd[bus]} + + def solve_fn(net): + lowest["pd"] = min(lowest["pd"], next(b.Pd for b in net.buses if b.bus_i == bus)) + return _opflow(feasible=False) res = relief.find_relief( - net118, branch_contingency, ["load_curtailment"], - 0.9, 1.1, solve_fn, _CFG, + net118, branch_contingency, ["load_curtailment"], 0.9, 1.1, solve_fn, _CFG, + protected_load=protected, ) - assert res.resolved is True + assert res.resolved is False + # At most 10% of the unprotected half is shed: Pd never drops below 0.95 * Pd. + assert lowest["pd"] == pytest.approx(0.5 * pd[bus] + 0.9 * 0.5 * pd[bus]) def test_unresolved_when_nothing_helps(self, net118, branch_contingency): def solve_fn(net): @@ -160,7 +250,7 @@ def solve_fn(net): n_oos = sum(1 for b in net.branches if b.status == 0) return _opflow(feasible=n_oos > n_outaged) - measures = ["transformer_ratio", "generator_redispatch", "line_switching"] + measures = ["transformer_ratio", "line_switching", "load_curtailment"] r1 = relief.find_relief(net118, branch_contingency, measures, 0.9, 1.1, solve_fn, _CFG) r2 = relief.find_relief(net118, branch_contingency, measures, 0.9, 1.1, solve_fn, _CFG) assert r1 == r2 @@ -226,13 +316,15 @@ def _controller(self, tmp_path): def test_relief_entries_attached_for_failures(self, tmp_path, net118): controller, backend_mock = self._controller(tmp_path) data = { - "mode": "contingency", "target_bus": 77, - "neighbor_count": 1, "contingency_order": 2, "components": ["branch"], + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": 1, "contingency_order": 2, "components": ["branch"], "feasibility": {"Vmin": 0.9, "Vmax": 1.1}, "relief_measures": ["line_switching", "load_curtailment"], "description": "N-2 + relief", } - n = len(C.enumerate_contingencies(net118, 77, 1, 2, ("branch",))) + affected = C.find_affected_elements(net118, C.ChangedElement(kind="load", bus=77), 1) + pool = C.outages_from(affected, ("branch",)) + n = len(C.enumerate_contingencies(pool, 2)) assert n >= 3 # need at least 2 failures + some passers state = {"i": 0} @@ -271,18 +363,110 @@ def test_no_relief_without_relief_measures_matches_c5(self, tmp_path, net118): with patch("agentigrid.engine.agent_loop.parse_simulation_result_for_app", return_value=_opflow(False)): kind, ok = controller._handle_contingency_sweep(1, { - "mode": "contingency", "target_bus": 77, - "neighbor_count": 1, "contingency_order": 1, "components": ["branch"], + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": 1, "contingency_order": 1, "components": ["branch"], }) assert (kind, ok) == ("sweep", True) entry = controller._journal.entries[-1] assert all("relief" not in s for s in entry.explored_variants) assert "Relief for failed contingencies" not in controller._latest_results_text + def test_mutation_load_is_protected_in_handler(self, tmp_path, net118): + """End to end: relief never sheds the 50 MW the mutation adds at bus 77.""" + controller, _ = self._controller(tmp_path) + base_pd77 = next(b.Pd for b in net118.buses if b.bus_i == 77) + state = {"i": 0} + lowest = {"pd": float("inf")} + real_solve = controller._executor.run + + def fake_parse(sim, application="opflow", bus_limits=None): + state["i"] += 1 + return _opflow(state["i"] == 1) # reference passes, everything else fails + + def fake_run(net, *a, **k): + lowest["pd"] = min(lowest["pd"], next(b.Pd for b in net.buses if b.bus_i == 77)) + return real_solve(net, *a, **k) + + controller._executor.run = fake_run + with patch("agentigrid.engine.agent_loop.parse_simulation_result_for_app", + side_effect=fake_parse): + controller._handle_contingency_sweep(1, { + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": 1, "contingency_order": 1, "components": ["branch"], + "relief_measures": ["load_curtailment"], + }) + assert lowest["pd"] != float("inf"), "relief solves ran" + assert lowest["pd"] >= 50.0 + 0.9 * base_pd77 - 1e-9 + + def test_relief_request_not_served_from_plain_screen_cache(self, tmp_path): + """A screen with relief_measures is a different study; it must not reuse the cache.""" + controller, _ = self._controller(tmp_path) + base = { + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": 1, "contingency_order": 1, "components": ["branch"], + } + with_relief = dict(base, relief_measures=["line_switching"]) + assert controller._sweep_cache_key(base) != controller._sweep_cache_key(with_relief) + + _SCREEN = { + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": 1, "contingency_order": 1, "components": ["branch"], + } + + def _sweep(self, controller, data): + with patch("agentigrid.engine.agent_loop.parse_simulation_result_for_app", + return_value=_opflow(False)): + return controller._handle_sweep(1, dict(data)) + + def test_guard_drops_relief_when_goal_does_not_ask(self, tmp_path): + controller, _ = self._controller(tmp_path) + controller._current_goal = "Connect a 50 MW load to bus 77 and test N-1 contingencies." + kind, _ = self._sweep(controller, dict(self._SCREEN, relief_measures=["line_switching"])) + assert kind == "sweep" + entry = controller._journal.entries[-1] + assert all("relief" not in v for v in entry.explored_variants) + assert "relief_measures ignored" in controller._latest_results_text + assert "Relief for failed contingencies" not in controller._latest_results_text + + def test_guard_keeps_relief_when_goal_asks(self, tmp_path): + controller, _ = self._controller(tmp_path) + controller._current_goal = "Connect a 50 MW load to bus 77, test N-1 and suggest relief measures." + self._sweep(controller, dict(self._SCREEN, relief_measures=["line_switching"])) + entry = controller._journal.entries[-1] + failed = [v for v in entry.explored_variants if not v["passed"]] + assert failed and all("relief" in v for v in failed) + assert "relief_measures ignored" not in controller._latest_results_text + + def test_guard_keeps_relief_when_steering_asks(self, tmp_path): + controller, _ = self._controller(tmp_path) + controller._current_goal = "Connect a 50 MW load to bus 77 and test N-1 contingencies." + controller._active_steering_directives = [{"directive": "Also try relief measures."}] + self._sweep(controller, dict(self._SCREEN, relief_measures=["line_switching"])) + assert "relief_measures ignored" not in controller._latest_results_text + + def test_guard_dropped_request_served_from_cache(self, tmp_path): + """Plain screen, then the same screen with unrequested relief: no new solves.""" + controller, _ = self._controller(tmp_path) + controller._current_goal = "Connect a 50 MW load to bus 77 and test N-1 contingencies." + self._sweep(controller, self._SCREEN) + with patch.object(controller, "_handle_contingency_sweep") as screen: + self._sweep(controller, dict(self._SCREEN, relief_measures=["line_switching"])) + screen.assert_not_called() + assert "relief_measures ignored" in controller._latest_results_text + + def test_generator_redispatch_rejected(self, tmp_path): + controller, _ = self._controller(tmp_path) + kind, ok = controller._handle_contingency_sweep(1, { + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "relief_measures": ["generator_redispatch", "load_curtailment"], + }) + assert kind == "error" + assert "generator_redispatch" in (controller._error_feedback or "") + def test_invalid_relief_measure_rejected(self, tmp_path): controller, _ = self._controller(tmp_path) kind, ok = controller._handle_contingency_sweep(1, { - "mode": "contingency", "target_bus": 77, + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, "relief_measures": ["nonsense_measure"], }) assert kind == "error" @@ -297,8 +481,8 @@ def test_relief_budget_exhausted(self, tmp_path, net118): with patch("agentigrid.engine.agent_loop.parse_simulation_result_for_app", return_value=_opflow(False)): kind, ok = controller._handle_contingency_sweep(1, { - "mode": "contingency", "target_bus": 77, - "neighbor_count": 1, "contingency_order": 1, "components": ["branch"], + "mode": "contingency", "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, + "substation_depth": 1, "contingency_order": 1, "components": ["branch"], "relief_measures": ["line_switching"], }) assert (kind, ok) == ("sweep", True) diff --git a/agentigrid/tests/analyses/test_reserve.py b/agentigrid/tests/analyses/test_reserve.py index dbde6aa3..889411c8 100644 --- a/agentigrid/tests/analyses/test_reserve.py +++ b/agentigrid/tests/analyses/test_reserve.py @@ -98,8 +98,7 @@ def test_all_order_one_gen(self, net118): assert ctg.order == 1 (e,) = ctg.elements assert e.kind == "gen" - assert e.bus == e.neighbor_bus - assert e.hop == 0 + assert e.tier == 0 def test_deterministic_sorted_by_bus_genid(self, net118): ctgs = C.all_generator_contingencies(net118) @@ -217,7 +216,7 @@ def test_reserve_meta_and_no_backend_call(self, tmp_path): # Screen exactly these three in-service units. fake_ctgs = [ C.Contingency(elements=(C.OutageElement( - kind="gen", neighbor_bus=b, hop=0, bus=b, gen_id=0),)) + kind="gen", tier=0, bus=b, gen_id=0),)) for b in (10, 20, 30) ] @@ -268,7 +267,7 @@ def test_secure_when_all_units_feasible(self, tmp_path): ] fake_ctgs = [ C.Contingency(elements=(C.OutageElement( - kind="gen", neighbor_bus=b, hop=0, bus=b, gen_id=0),)) + kind="gen", tier=0, bus=b, gen_id=0),)) for b in (10, 20) ] state = {"i": 0} diff --git a/agentigrid/tests/analyses/test_reserve_min.py b/agentigrid/tests/analyses/test_reserve_min.py index b8909962..e62c66e2 100644 --- a/agentigrid/tests/analyses/test_reserve_min.py +++ b/agentigrid/tests/analyses/test_reserve_min.py @@ -267,7 +267,7 @@ def test_handler_minimize_maps_result_and_journals(tmp_path): base_gens = [_gr(10, 100.0, 300.0), _gr(20, 250.0, 400.0), _gr(30, 50.0, 200.0)] fake_ctgs = [ C.Contingency(elements=(C.OutageElement( - kind="gen", neighbor_bus=b, hop=0, bus=b, gen_id=0),)) + kind="gen", tier=0, bus=b, gen_id=0),)) for b in (10, 20, 30) ] diff --git a/agentigrid/tests/analyses/test_sweep_corrections.py b/agentigrid/tests/analyses/test_sweep_corrections.py index af0cd4d8..a7ab4141 100644 --- a/agentigrid/tests/analyses/test_sweep_corrections.py +++ b/agentigrid/tests/analyses/test_sweep_corrections.py @@ -287,7 +287,7 @@ def test_dispatched_q_recorded_at_adequate_bus(self, tmp_path): class TestSystemPromptNudge: def test_no_redundant_sweep_rule_present(self): - from agentigrid.prompts.system_prompt import build_system_prompt + from agentigrid.prompts.exago.system_prompt import build_system_prompt p = build_system_prompt("SCHEMA", "NET", application="opflow") assert "DO NOT RE-RUN AN IDENTICAL SWEEP" in p diff --git a/agentigrid/tests/analyses/test_topology.py b/agentigrid/tests/analyses/test_topology.py index 49de23f5..bc7a6e55 100644 --- a/agentigrid/tests/analyses/test_topology.py +++ b/agentigrid/tests/analyses/test_topology.py @@ -1,36 +1,25 @@ -"""Tests for agentigrid.engine.topology — hop-distance graph primitive (C.6). +"""Tests for agentigrid.engine.topology and the structured topology analyze queries. Covers: -- build_adjacency: undirected, in-service-only, every bus present, parallel collapse -- k_nearest_by_hops: BFS order, tie-break by bus number, truncation to k -- hop_distance: identity, direct neighbor, two-hop, unreachable -> None, unknown -> ValueError - incident_branches: stable file order, parallel circuits preserved, out-of-service exclusion -- count_reachable: total reachable count -- format_nearest_neighbors_view / format_incident_branches_view: content checks -- out-of-service toggle: synthetic 3-bus network -- determinism: two successive calls return identical results -- handler-level: _handle_topology_analyze sets _latest_results_text correctly +- format_incident_branches_view: content checks +- handler-level: _handle_topology_analyze for affected_elements (the contingency + neighbor search) and incident_branches sets _latest_results_text correctly + +Substation grouping and tiers (substation_map, substations_within) are covered +in test_contingency.py alongside the neighbor search that uses them. """ from __future__ import annotations -from dataclasses import dataclass, field from pathlib import Path -from typing import Optional from unittest.mock import MagicMock, patch import pytest -from agentigrid.engine.topology import ( - build_adjacency, - count_reachable, - format_incident_branches_view, - format_nearest_neighbors_view, - hop_distance, - incident_branches, - k_nearest_by_hops, -) -from agentigrid.parsers.matpower_model import Branch, Bus, GenCost, Generator, MATNetwork +from agentigrid.engine import contingency as C +from agentigrid.engine.topology import format_incident_branches_view, incident_branches +from agentigrid.parsers.matpower_model import Branch, Bus, MATNetwork from agentigrid.parsers.matpower_parser import parse_matpower DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "exago" / "examples" @@ -84,32 +73,6 @@ def net(self): # 1 --(in-service)--> 2 --(out-of-service, status=0)--> 3 return _tiny_net([1, 2, 3], [(1, 2, 1), (2, 3, 0)]) - def test_out_of_service_branch_excluded_by_default(self, net): - adj = build_adjacency(net) - assert 3 not in adj[2] - assert 2 not in adj[3] - - def test_out_of_service_branch_included_when_flag_set(self, net): - adj = build_adjacency(net, include_out_of_service=True) - assert 3 in adj[2] - assert 2 in adj[3] - - def test_isolated_bus_present_as_empty_key(self, net): - adj = build_adjacency(net) - assert 3 in adj - assert adj[3] == set() - - def test_k_nearest_excludes_oos_by_default(self, net): - # bus 3 is unreachable from bus 1 without OOS - neighbors = k_nearest_by_hops(net, 1, 10) - neighbor_buses = [nb for nb, _ in neighbors] - assert 3 not in neighbor_buses - - def test_k_nearest_includes_oos_when_flag_set(self, net): - neighbors = k_nearest_by_hops(net, 1, 10, include_out_of_service=True) - neighbor_buses = [nb for nb, _ in neighbors] - assert 3 in neighbor_buses - def test_incident_excludes_oos_by_default(self, net): inc = incident_branches(net, 2) assert all(br.status == 1 for br in inc) @@ -129,91 +92,6 @@ def net118(): return parse_matpower(IEEE118) -@pytest.mark.skipif(not _has_118, reason="ieee_118_bus_v10.m not available") -class TestBuildAdjacency: - - def test_adj_77(self, net118): - adj = build_adjacency(net118) - assert adj[77] == {69, 75, 76, 78, 80, 82} - - def test_adj_10(self, net118): - adj = build_adjacency(net118) - assert adj[10] == {9} - - def test_adj_40(self, net118): - adj = build_adjacency(net118) - assert adj[40] == {37, 42} - - def test_every_bus_present(self, net118): - adj = build_adjacency(net118) - for b in net118.buses: - assert b.bus_i in adj - - -@pytest.mark.skipif(not _has_118, reason="ieee_118_bus_v10.m not available") -class TestKNearestByHops: - - def test_k_nearest_77_k3(self, net118): - result = k_nearest_by_hops(net118, 77, 3) - assert result == [(69, 1), (75, 1), (76, 1)] - - def test_k_nearest_40_k3(self, net118): - result = k_nearest_by_hops(net118, 40, 3) - assert result == [(37, 1), (42, 1), (15, 2)] - - def test_k_nearest_10_k3(self, net118): - result = k_nearest_by_hops(net118, 10, 3) - assert result == [(9, 1), (8, 2), (5, 3)] - - def test_k_nearest_77_large_k_fewer_than_k_results(self, net118): - result = k_nearest_by_hops(net118, 77, 100) - assert len(result) < 100 - # Strictly ordered by (hop, bus_id) - for i in range(len(result) - 1): - nb1, h1 = result[i] - nb2, h2 = result[i + 1] - assert (h1, nb1) < (h2, nb2) - - def test_raises_for_unknown_bus(self, net118): - with pytest.raises(ValueError, match="9999"): - k_nearest_by_hops(net118, 9999, 3) - - def test_raises_for_k_zero(self, net118): - with pytest.raises(ValueError): - k_nearest_by_hops(net118, 77, 0) - - def test_determinism(self, net118): - r1 = k_nearest_by_hops(net118, 77, 3) - r2 = k_nearest_by_hops(net118, 77, 3) - assert r1 == r2 - - -@pytest.mark.skipif(not _has_118, reason="ieee_118_bus_v10.m not available") -class TestHopDistance: - - def test_identity(self, net118): - assert hop_distance(net118, 77, 77) == 0 - - def test_direct_neighbor(self, net118): - assert hop_distance(net118, 77, 69) == 1 - - def test_two_hops(self, net118): - assert hop_distance(net118, 40, 15) == 2 - - def test_unreachable_returns_none(self, net118): - # build a net with an isolated bus to guarantee unreachability - net_isolated = _tiny_net([1, 2, 99], [(1, 2, 1)]) - assert hop_distance(net_isolated, 1, 99) is None - - def test_unknown_src_raises(self, net118): - with pytest.raises(ValueError): - hop_distance(net118, 9999, 77) - - def test_unknown_dst_raises(self, net118): - with pytest.raises(ValueError): - hop_distance(net118, 77, 9999) - - @pytest.mark.skipif(not _has_118, reason="ieee_118_bus_v10.m not available") class TestIncidentBranches: @@ -235,14 +113,6 @@ def test_raises_for_unknown_bus(self, net118): incident_branches(net118, 9999) -@pytest.mark.skipif(not _has_118, reason="ieee_118_bus_v10.m not available") -class TestCountReachable: - - def test_count_reachable_from_77(self, net118): - total = count_reachable(net118, 77) - assert total == 99 # 100-bus case, 77 can reach all others - - # --------------------------------------------------------------------------- # View formatters # --------------------------------------------------------------------------- @@ -250,22 +120,6 @@ def test_count_reachable_from_77(self, net118): @pytest.mark.skipif(not _has_118, reason="ieee_118_bus_v10.m not available") class TestFormatters: - def test_nearest_neighbors_view_bus_77(self, net118): - neighbors = k_nearest_by_hops(net118, 77, 3) - total = count_reachable(net118, 77) - text = format_nearest_neighbors_view(77, 3, neighbors, total) - assert "69" in text - assert "75" in text - assert "76" in text - assert "99" in text # total reachable count - - def test_nearest_neighbors_view_short_result_note(self, net118): - net_tiny = _tiny_net([1, 2], [(1, 2, 1)]) - neighbors = k_nearest_by_hops(net_tiny, 1, 5) - total = count_reachable(net_tiny, 1) - text = format_nearest_neighbors_view(1, 5, neighbors, total) - assert "Only 1 neighbor" in text - def test_incident_branches_view_bus_77_has_7_rows(self, net118): branches = incident_branches(net118, 77) text = format_incident_branches_view(77, branches) @@ -310,17 +164,53 @@ def _make_controller(self, net118): ctrl._print = lambda *a, **k: None return ctrl - def test_nearest_neighbors_sets_results_text(self, net118): + def _affected(self, ctrl, **extra): + data = {"action": "analyze", "query_type": "affected_elements", + "mutation": {"action": "add_load_at_bus", "bus": 77, "Pd": 50.0}, **extra} + return ctrl._handle_topology_analyze( + iteration=1, data=data, query_type="affected_elements", + ) + + def test_affected_elements_matches_the_contingency_search(self, net118): + ctrl = self._make_controller(net118) + assert self._affected(ctrl, substation_depth=1) == ("analyze", True) + text = ctrl._latest_results_text + expected = C.find_affected_elements(net118, C.ChangedElement(kind="load", bus=77), 1) + assert f"branches ({len(expected.branches)})" in text + assert f"gens ({len(expected.gens)})" in text + assert f"loads ({len(expected.loads)})" in text + assert f"shunts ({len(expected.shunts)})" in text + assert "tier 0: [77]" in text + + def test_affected_elements_default_depth(self, net118): + ctrl = self._make_controller(net118) + assert self._affected(ctrl) == ("analyze", True) + assert f"substation depth {C.DEFAULT_SUBSTATION_DEPTH}" in ctrl._latest_results_text + + def test_affected_elements_requires_mutation(self, net118): ctrl = self._make_controller(net118) result = ctrl._handle_topology_analyze( iteration=1, - data={"action": "analyze", "query_type": "nearest_neighbors", "bus": 77, "k": 3}, - query_type="nearest_neighbors", + data={"action": "analyze", "query_type": "affected_elements", "bus": 77}, + query_type="affected_elements", ) - assert result == ("analyze", True) - assert ctrl._latest_results_text is not None - text = ctrl._latest_results_text - assert "69" in text and "75" in text and "76" in text + assert result[0] == "error" + assert "mutation" in ctrl._error_feedback + + def test_affected_elements_unknown_bus_returns_error(self, net118): + ctrl = self._make_controller(net118) + result = ctrl._handle_topology_analyze( + iteration=1, + data={"action": "analyze", "query_type": "affected_elements", + "mutation": {"action": "add_load_at_bus", "bus": 9999, "Pd": 1.0}}, + query_type="affected_elements", + ) + assert result[0] == "error" + assert "9999" in ctrl._error_feedback + + def test_affected_elements_bad_depth_returns_error(self, net118): + ctrl = self._make_controller(net118) + assert self._affected(ctrl, substation_depth=-1)[0] == "error" def test_incident_branches_sets_results_text(self, net118): ctrl = self._make_controller(net118) @@ -333,34 +223,31 @@ def test_incident_branches_sets_results_text(self, net118): assert ctrl._latest_results_text is not None assert "77" in ctrl._latest_results_text - def test_unknown_query_type_returns_error(self, net118): + def test_incident_branches_unknown_bus_returns_error(self, net118): ctrl = self._make_controller(net118) result = ctrl._handle_topology_analyze( iteration=1, - data={"action": "analyze", "query_type": "full_adjacency"}, - query_type="full_adjacency", + data={"action": "analyze", "query_type": "incident_branches", "bus": 9999}, + query_type="incident_branches", ) assert result[0] == "error" - assert ctrl._error_feedback is not None - def test_unknown_bus_returns_error(self, net118): + @pytest.mark.parametrize("query_type", ["full_adjacency", "k_hop_buses"]) + def test_unknown_query_type_returns_error(self, net118, query_type): ctrl = self._make_controller(net118) result = ctrl._handle_topology_analyze( iteration=1, - data={"action": "analyze", "query_type": "nearest_neighbors", "bus": 9999, "k": 3}, - query_type="nearest_neighbors", + data={"action": "analyze", "query_type": query_type, "bus": 77}, + query_type=query_type, ) assert result[0] == "error" + assert "affected_elements" in ctrl._error_feedback def test_no_backend_call_made(self, net118): """Structured topology query must not invoke any backend.""" ctrl = self._make_controller(net118) with patch("agentigrid.engine.agent_loop.create_backend") as mock_backend: - ctrl._handle_topology_analyze( - iteration=1, - data={"action": "analyze", "query_type": "nearest_neighbors", "bus": 77, "k": 3}, - query_type="nearest_neighbors", - ) + self._affected(ctrl) mock_backend.assert_not_called() def test_free_text_analyze_path_unaffected(self, net118): diff --git a/agentigrid/tests/exago/test_pflow.py b/agentigrid/tests/exago/test_pflow.py index b4360019..ec433e21 100644 --- a/agentigrid/tests/exago/test_pflow.py +++ b/agentigrid/tests/exago/test_pflow.py @@ -23,7 +23,7 @@ from agentigrid.engine.validation import validate_command from agentigrid.engine.metric_extractor import available_metrics_for_app from agentigrid.engine.goal_classifier import build_classification_prompts -from agentigrid.prompts.system_prompt import build_system_prompt, _app_section +from agentigrid.prompts.exago.system_prompt import build_system_prompt, _app_section # --------------------------------------------------------------------------- @@ -660,7 +660,9 @@ def test_pflow_section_voltage_limits_guidance(self): section = _app_section("pflow") assert "set_all_bus_vlimits" in section assert "CRITICAL" in section or "first action" in section.lower() - assert "0.95" in section + # The example uses the prompt's enforced voltage band. + from agentigrid.prompts.exago.system_prompt import VMAX, VMIN + assert f'"Vmin": {VMIN}, "Vmax": {VMAX}' in section def test_opflow_section_still_has_voltage_control(self): section = _app_section("opflow") diff --git a/agentigrid/tests/exago/test_sopflow.py b/agentigrid/tests/exago/test_sopflow.py index 7cf58b0f..ac221e64 100644 --- a/agentigrid/tests/exago/test_sopflow.py +++ b/agentigrid/tests/exago/test_sopflow.py @@ -29,7 +29,7 @@ ) from agentigrid.parsers.matpower_model import MATNetwork, Bus, Generator, Branch, GenCost from agentigrid.engine.journal import SearchJournal, JournalEntry -from agentigrid.prompts.system_prompt import build_system_prompt +from agentigrid.prompts.exago.system_prompt import build_system_prompt from agentigrid.engine.schema_description import command_schema_text from agentigrid.engine.goal_classifier import build_classification_prompts diff --git a/agentigrid/tests/exago/test_tcopflow.py b/agentigrid/tests/exago/test_tcopflow.py index afc485f8..ecf7bcca 100644 --- a/agentigrid/tests/exago/test_tcopflow.py +++ b/agentigrid/tests/exago/test_tcopflow.py @@ -26,7 +26,7 @@ from agentigrid.engine.modifier import apply_modifications, scale_load_profile_csv, ModificationReport from agentigrid.parsers.matpower_model import MATNetwork, Bus, Generator, Branch, GenCost from agentigrid.engine.journal import SearchJournal, JournalEntry -from agentigrid.prompts.system_prompt import build_system_prompt +from agentigrid.prompts.exago.system_prompt import build_system_prompt from agentigrid.engine.schema_description import command_schema_text from agentigrid.engine.goal_classifier import build_classification_prompts diff --git a/agentigrid/tests/journal/test_journal.py b/agentigrid/tests/journal/test_journal.py index 1ae8cdab..78ca87f5 100644 --- a/agentigrid/tests/journal/test_journal.py +++ b/agentigrid/tests/journal/test_journal.py @@ -540,7 +540,7 @@ def test_commands_stored_on_best(self): class TestSessionBestInPrompt: def test_format_session_best_contains_cost(self): - from agentigrid.prompts.user_prompt import _format_session_best + from agentigrid.prompts.exago.user_prompt import _format_session_best sb = {"cost": 29924.90, "iteration": 5, "variant_label": "A", "commands": []} text = _format_session_best(sb) assert "29,924.90" in text @@ -548,7 +548,7 @@ def test_format_session_best_contains_cost(self): assert "variant A" in text def test_session_best_injected_in_user_prompt(self): - from agentigrid.prompts.user_prompt import build_user_prompt + from agentigrid.prompts.exago.user_prompt import build_user_prompt sb = {"cost": 12345.67, "iteration": 3, "variant_label": "B", "commands": [{"action": "scale_all_loads", "factor": 1.1}]} prompt = build_user_prompt( @@ -561,7 +561,7 @@ def test_session_best_injected_in_user_prompt(self): assert "Session best" in prompt def test_no_session_best_section_when_absent(self): - from agentigrid.prompts.user_prompt import build_user_prompt + from agentigrid.prompts.exago.user_prompt import build_user_prompt prompt = build_user_prompt( goal="test", journal_text=None, @@ -748,6 +748,17 @@ def test_add_complete_creates_entry(self): assert entry.objective_value is None assert "completed" in entry.description.lower() + def test_add_complete_keeps_findings(self, tmp_path): + """The full answer (not just the summary line) is kept and exported.""" + j = SearchJournal() + findings = {"summary": "VERDICT: load@8 does NOT pass N-1", "details": "branch 8-7 out | did not converge"} + entry = j.add_complete(iteration=2, summary=findings["summary"], findings=findings) + assert entry.findings == findings + csv_path = tmp_path / "journal.csv" + j.export_csv(csv_path) + row = list(csv.DictReader(csv_path.open()))[0] + assert json.loads(row["findings"]) == findings + def test_add_complete_appears_in_entries(self): j = SearchJournal() j.add_entry(_make_entry(0, obj=1000.0)) @@ -886,7 +897,7 @@ class TestIterationBudgetInPrompt: """Tests for iteration counter and budget warning in build_user_prompt.""" def test_iteration_counter_with_budget(self): - from agentigrid.prompts.user_prompt import build_user_prompt + from agentigrid.prompts.exago.user_prompt import build_user_prompt prompt = build_user_prompt( goal="test", journal_text=None, @@ -898,7 +909,7 @@ def test_iteration_counter_with_budget(self): assert "remaining: 15" in prompt def test_iteration_counter_no_budget(self): - from agentigrid.prompts.user_prompt import build_user_prompt + from agentigrid.prompts.exago.user_prompt import build_user_prompt prompt = build_user_prompt( goal="test", journal_text=None, @@ -910,7 +921,7 @@ def test_iteration_counter_no_budget(self): assert "no budget limit" in prompt def test_budget_warning_when_remaining_2(self): - from agentigrid.prompts.user_prompt import build_user_prompt + from agentigrid.prompts.exago.user_prompt import build_user_prompt prompt = build_user_prompt( goal="test", journal_text=None, @@ -922,7 +933,7 @@ def test_budget_warning_when_remaining_2(self): assert "Prioritize consolidation" in prompt def test_budget_warning_absent_when_remaining_10(self): - from agentigrid.prompts.user_prompt import build_user_prompt + from agentigrid.prompts.exago.user_prompt import build_user_prompt prompt = build_user_prompt( goal="test", journal_text=None, @@ -933,7 +944,7 @@ def test_budget_warning_absent_when_remaining_10(self): assert "Prioritize consolidation" not in prompt def test_budget_warning_absent_when_no_budget(self): - from agentigrid.prompts.user_prompt import build_user_prompt + from agentigrid.prompts.exago.user_prompt import build_user_prompt prompt = build_user_prompt( goal="test", journal_text=None, @@ -970,32 +981,32 @@ class TestFormatBenchmarkForPrompt: } def test_real_benchmark_contains_bus189(self): - from agentigrid.prompts.system_prompt import format_benchmark_for_prompt + from agentigrid.prompts.exago.system_prompt import format_benchmark_for_prompt text = format_benchmark_for_prompt(self.REAL_BENCHMARK) assert "189" in text def test_real_benchmark_contains_delta(self): - from agentigrid.prompts.system_prompt import format_benchmark_for_prompt + from agentigrid.prompts.exago.system_prompt import format_benchmark_for_prompt text = format_benchmark_for_prompt(self.REAL_BENCHMARK) assert "352" in text def test_real_benchmark_over_dispatched_note(self): - from agentigrid.prompts.system_prompt import format_benchmark_for_prompt + from agentigrid.prompts.exago.system_prompt import format_benchmark_for_prompt text = format_benchmark_for_prompt(self.REAL_BENCHMARK) assert "over-dispatched" in text def test_not_converged_returns_unavailable(self): - from agentigrid.prompts.system_prompt import format_benchmark_for_prompt + from agentigrid.prompts.exago.system_prompt import format_benchmark_for_prompt text = format_benchmark_for_prompt({"opflow_converged": False}) assert text == "Benchmark unavailable." def test_none_returns_unavailable(self): - from agentigrid.prompts.system_prompt import format_benchmark_for_prompt + from agentigrid.prompts.exago.system_prompt import format_benchmark_for_prompt text = format_benchmark_for_prompt(None) assert text == "Benchmark unavailable." def test_negligible_cost_gap_note(self): - from agentigrid.prompts.system_prompt import format_benchmark_for_prompt + from agentigrid.prompts.exago.system_prompt import format_benchmark_for_prompt bench = { "opflow_converged": True, "opflow_objective": 10000.0, @@ -1008,7 +1019,7 @@ def test_negligible_cost_gap_note(self): assert "negligible" in text def test_large_gap_no_negligible_note(self): - from agentigrid.prompts.system_prompt import format_benchmark_for_prompt + from agentigrid.prompts.exago.system_prompt import format_benchmark_for_prompt bench = { "opflow_converged": True, "opflow_objective": 10000.0, diff --git a/agentigrid/tests/journal/test_regenerate_report.py b/agentigrid/tests/journal/test_regenerate_report.py index 1fae279f..d47a96f8 100644 --- a/agentigrid/tests/journal/test_regenerate_report.py +++ b/agentigrid/tests/journal/test_regenerate_report.py @@ -177,8 +177,12 @@ def test_load_session_preserves_sweep_fields(tmp_path): feasible_buses=[1, 2], ) journal.add_contingency( - iteration=2, description="n-1", target_bus=5, - neighbors=[(6, 1), (7, 2)], order=1, + iteration=2, description="n-1", + mutation={"action": "add_load_at_bus", "bus": 5, "Pd": 50.0}, changed_element={"type": "load", "bus": 5}, + substation_depth=1, order=1, + affected={"buses": [{"bus": 5, "substation": 5, "tier": 0}, {"bus": 6, "substation": 6, "tier": 1}], + "branches": [{"fbus": 5, "tbus": 6, "ckt": 0, "tier": 0}], + "gens": [{"bus": 6, "gen_id": 0, "tier": 1}], "loads": [], "shunts": []}, contingency_summaries=[{"label": "gen@6", "passed": False}], passed_count=0, failed_count=1, ) @@ -199,7 +203,8 @@ def test_load_session_preserves_sweep_fields(tmp_path): cont = next(e for e in entries if e.mode == "contingency") assert cont.contingency_meta is not None - assert cont.contingency_meta["target_bus"] == 5 + assert cont.contingency_meta["changed_element"] == {"type": "load", "bus": 5} + assert cont.contingency_meta["affected"]["gens"] == [{"bus": 6, "gen_id": 0, "tier": 1}] assert cont.candidate_count == 1 diff --git a/agentigrid/tests/journal/test_session_io.py b/agentigrid/tests/journal/test_session_io.py index 5ee1e44c..1c7797ef 100644 --- a/agentigrid/tests/journal/test_session_io.py +++ b/agentigrid/tests/journal/test_session_io.py @@ -88,6 +88,18 @@ def test_save_and_load_roundtrip(self, tmp_path, sample_journal): assert loaded["journal_entries"][2].objective_value == 48000 assert len(loaded["steering_history"]) == 1 + def test_roundtrip_keeps_complete_findings(self, tmp_path, sample_journal): + findings = {"summary": "VERDICT: load@8 passes N-1", "details": "no failures"} + sample_journal.add_complete(iteration=3, summary=findings["summary"], findings=findings) + save_dir = tmp_path / "test_session" + save_session( + save_dir=save_dir, goal="g", application="opflow", base_case_path=Path("/data/case.m"), + config_path=None, journal=sample_journal, steering_history=[], + active_steering_directives=[], current_network=None, total_prompt_tokens=0, + total_completion_tokens=0, last_iteration=3, + ) + assert load_session(save_dir)["journal_entries"][-1].findings == findings + def test_load_restores_objective_registry(self, tmp_path, sample_journal): save_dir = tmp_path / "test_session" save_session( diff --git a/agentigrid/tests/results/test_opflow_parser.py b/agentigrid/tests/results/test_opflow_parser.py index f0cedf0c..712c20db 100644 --- a/agentigrid/tests/results/test_opflow_parser.py +++ b/agentigrid/tests/results/test_opflow_parser.py @@ -54,7 +54,7 @@ def test_num_iterations(self, result: OPFLOWResult): assert result.num_iterations == 23 def test_solve_time(self, result: OPFLOWResult): - assert result.solve_time == pytest.approx(0.042, abs=0.001) + assert result.solve_time == pytest.approx(0.063, abs=0.001) def test_bus_count(self, result: OPFLOWResult): assert len(result.buses) == 200 diff --git a/agentigrid/tests/runner/test_executor.py b/agentigrid/tests/runner/test_executor.py index 1b46cbd5..bc258a40 100644 --- a/agentigrid/tests/runner/test_executor.py +++ b/agentigrid/tests/runner/test_executor.py @@ -349,9 +349,5 @@ def test_opflow_run(self, tmp_path: Path): assert result.input_file.exists() assert result.elapsed_seconds < exago.timeout assert result.exit_code == 0 or result.success # MPI noise tolerance - - # Save stdout for Step 1.6 (only if converged, to avoid overwriting good sample) - if "Optimal Solution Found" in result.stdout: - sample_path = Path(__file__).resolve().parent.parent / "fixtures" / "sample_opflow_output.txt" - sample_path.write_text(result.stdout, encoding="utf-8") - print(f"\nSaved sample output to {sample_path} ({len(result.stdout)} bytes)") + # Output is not saved: tests/fixtures/sample_opflow_output.txt is a fixed + # fixture whose exact numbers the parser tests check. From abd63d7cce001e1922e64d6916511525475c8428 Mon Sep 17 00:00:00 2001 From: tsybinae Date: Wed, 7 Oct 2026 14:31:48 -0700 Subject: [PATCH 2/2] AgentiGrid: GridKit transient-stability engine, first build (#135) First build of the GridKit side of AgentiGrid (--tool gridkit). The LLM picks a fault screen; AgentiGrid adds bus faults to a copy of the GridKit case, runs DynamicSimulation, applies PJM M-14B stability checks and returns a verdict. PJM tests that need elements GridKit does not have are reported as SKIPPED. New: gridkit_parsers/, gridkit_engine/, prompts/gridkit/, tests/gridkit/. Changed: cli.py (--tool flag), README.md and the GridKit folder READMEs, tests/README.md. No ExaGO code changed. Co-authored-by: tsybinae <186317713+tsybinae@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- agentigrid/README.md | 24 ++ agentigrid/agentigrid/cli.py | 50 +++ .../agentigrid/gridkit_engine/__init__.py | 1 + .../agentigrid/gridkit_engine/agent_loop.py | 318 +++++++++++++++ .../agentigrid/gridkit_engine/commands.py | 120 ++++++ .../agentigrid/gridkit_engine/criteria.py | 363 ++++++++++++++++++ .../agentigrid/gridkit_engine/executor.py | 97 +++++ .../agentigrid/gridkit_engine/journal.py | 80 ++++ .../agentigrid/gridkit_engine/modifier.py | 109 ++++++ .../gridkit_engine/schema_description.py | 46 +++ .../agentigrid/gridkit_engine/settings.py | 49 +++ .../gridkit_engine/sweep_metrics.py | 143 +++++++ .../agentigrid/gridkit_engine/topology.py | 61 +++ .../agentigrid/gridkit_engine/validation.py | 131 +++++++ .../agentigrid/gridkit_parsers/__init__.py | 30 ++ .../agentigrid/gridkit_parsers/case_parser.py | 272 +++++++++++++ .../gridkit_parsers/network_summary.py | 35 ++ .../gridkit_parsers/results_parser.py | 66 ++++ .../gridkit_parsers/status_parser.py | 181 +++++++++ .../agentigrid/prompts/gridkit/__init__.py | 5 + .../prompts/gridkit/system_prompt.py | 57 +++ agentigrid/applications/gridkit/README.md | 5 +- agentigrid/data/gridkit/README.md | 6 +- agentigrid/tests/README.md | 1 + agentigrid/tests/gridkit/__init__.py | 0 agentigrid/tests/gridkit/test_agent_loop.py | 113 ++++++ agentigrid/tests/gridkit/test_case_parser.py | 142 +++++++ agentigrid/tests/gridkit/test_criteria.py | 99 +++++ agentigrid/tests/gridkit/test_helpers.py | 257 +++++++++++++ .../tests/gridkit/test_status_parser.py | 123 ++++++ 30 files changed, 2980 insertions(+), 4 deletions(-) create mode 100644 agentigrid/agentigrid/gridkit_engine/__init__.py create mode 100644 agentigrid/agentigrid/gridkit_engine/agent_loop.py create mode 100644 agentigrid/agentigrid/gridkit_engine/commands.py create mode 100644 agentigrid/agentigrid/gridkit_engine/criteria.py create mode 100644 agentigrid/agentigrid/gridkit_engine/executor.py create mode 100644 agentigrid/agentigrid/gridkit_engine/journal.py create mode 100644 agentigrid/agentigrid/gridkit_engine/modifier.py create mode 100644 agentigrid/agentigrid/gridkit_engine/schema_description.py create mode 100644 agentigrid/agentigrid/gridkit_engine/settings.py create mode 100644 agentigrid/agentigrid/gridkit_engine/sweep_metrics.py create mode 100644 agentigrid/agentigrid/gridkit_engine/topology.py create mode 100644 agentigrid/agentigrid/gridkit_engine/validation.py create mode 100644 agentigrid/agentigrid/gridkit_parsers/__init__.py create mode 100644 agentigrid/agentigrid/gridkit_parsers/case_parser.py create mode 100644 agentigrid/agentigrid/gridkit_parsers/network_summary.py create mode 100644 agentigrid/agentigrid/gridkit_parsers/results_parser.py create mode 100644 agentigrid/agentigrid/gridkit_parsers/status_parser.py create mode 100644 agentigrid/agentigrid/prompts/gridkit/__init__.py create mode 100644 agentigrid/agentigrid/prompts/gridkit/system_prompt.py create mode 100644 agentigrid/tests/gridkit/__init__.py create mode 100644 agentigrid/tests/gridkit/test_agent_loop.py create mode 100644 agentigrid/tests/gridkit/test_case_parser.py create mode 100644 agentigrid/tests/gridkit/test_criteria.py create mode 100644 agentigrid/tests/gridkit/test_helpers.py create mode 100644 agentigrid/tests/gridkit/test_status_parser.py diff --git a/agentigrid/README.md b/agentigrid/README.md index a731bec0..2a417db7 100644 --- a/agentigrid/README.md +++ b/agentigrid/README.md @@ -61,6 +61,28 @@ Beyond the default cost metric and standard V-band/loading feasibility, a sweep - **`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." +``` + +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_