Build a Solver-Grounded GridRAG Agent
Build an agent that grounds grid analysis in solver and retrieval evidence.
Introduction
30 Second Summary
A confident answer can still be wrong when the numbers behind it were guessed. For power-grid questions, a polished explanation means little unless every measurement can be traced to real calculations.
In this project, you will build a local command-line GridRAG agent powered by a balanced AC power-flow solver and persistent vector memory. Its language model can explain the evidence only after every required tool has run.
What You'll Build
Picture asking how a heavily loaded grid behaves and seeing a terminal answer backed by solver metrics, a retrieved historical case, and an explicit tool trace.
By the end of this project, you'll have:
- Run a load-scaled grid simulation that reports convergence, minimum voltage, weakest bus, maximum line loading, and total line losses.
- Recall a similar historical case from persistent simulation memory after the program restarts.
- Submit a natural-language grid request and see each evidence tool run before the agent presents its grounded comparison.
- Secret Mission: Add a multi-scenario comparison tool that ranks several load scales by minimum voltage and identifies the worst result.
Are there any prerequisites?
You'll need Windows 10 or newer, internet access for the initial downloads, and about 8 GB of free disk space.
You can start without installed project software, a cloud account, or a paid API.
Before We Start
This step commits you to a GridRAG agent for load-scaled IEEE 33-bus AC power-flow analysis. Pandapower solver results will supply every numerical voltage, loading, convergence, and loss claim.
Set Up the Local GridRAG Toolchain
The GridRAG agent depends on several local components. Their versions must stay compatible before the agent can exchange evidence.
Python runs every project script. A virtual environment isolates the pinned packages from other Python projects on your computer.
Visual Studio Code holds the workspace. Ollama serves both local models without cloud credentials.
In this step, get ready to:
- Install the required Windows applications.
- Create an isolated workspace with pinned Python packages.
- Download both local models for local inference.
Install the Windows applications
Python 3.13.15 is the pinned runtime for this project. Check your current Python installation before choosing the setup path that matches your computer.
- Press the Windows key to open the search bar.
- Type PowerShell.
- Press Enter.
- Check your installed Python version by running this command:
python --version
What does this command check?
This command asks the Python executable on your Windows PATH to report its version. The result determines whether you can continue or need the pinned installer.
✔️ I see version 3.13.15
Python 3.13.15 is ready for the project environment.
ⓧ I see an older version
Your existing Python installation can remain on your computer. Install the pinned release so VS Code can use it for this workspace.
- Visit the official Python file index.
- Download python-3.13.15-amd64.exe.
- Run the downloaded installer.
- Enable the installer option that makes Python available from the command line.
- Complete the installation.
- Close the current PowerShell window.
- Open a fresh PowerShell window through Windows search.
- Repeat the version check shown above. You should now see Python 3.13.15.
ⓧ Command not found
Windows cannot find Python on PATH yet. Install the pinned 64-bit release before continuing.
- Visit the official Python file index.
- Download python-3.13.15-amd64.exe.
- Run the downloaded installer.
- Enable the installer option that makes Python available from the command line.
- Complete the installation.
- Close the current PowerShell window.
- Open a fresh PowerShell window through Windows search.
- Repeat the version check shown above. You should now see Python 3.13.15.
The remaining applications provide your editor plus the local model runtime. Their Windows installers also update PATH for later terminal commands.
- Visit the official Visual Studio Code Windows setup page.
- Download Visual Studio Code User Setup.
- Run the Visual Studio Code installer.
- Complete the setup with its recommended options.
- Visit the official Ollama for Windows page.
- Download OllamaSetup.exe.
- Run OllamaSetup.exe.
- Complete the Ollama installation.
Why use Visual Studio Code User Setup?
User Setup installs Visual Studio Code for your Windows account without requiring administrator rights. It also makes the editor available from new terminal sessions.
Ollama runs locally in the background after installation. Its local service provides the models used later by the agent.
- Close the current PowerShell window so the new PATH settings can load.
- Press the Windows key.
- Type Visual Studio Code.
- Press Enter.
- Select the extension marketplace icon in the left sidebar.
- Search for Python.
- Select Python published by Microsoft.
- Click Install.
The three Windows applications are now in place. Visual Studio Code should show the Microsoft Python extension as installed.
Build the isolated workspace
The gridrag-agent folder keeps every project artifact in one location. Its .venv environment holds only the package versions required by GridRAG.
- Select Terminal from the Visual Studio Code top menu.
- Select New Terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
This command moves the terminal to your Windows Desktop folder. Creating the workspace here makes it easy to find outside Visual Studio Code.
- Create the project folder plus enter it by running these commands:
mkdir gridrag-agent
cd gridrag-agent
What do these commands do?
The first command creates the gridrag-agent folder on your Desktop. The second command makes that folder the terminal's current location.
Your terminal prompt should now end with gridrag-agent. This confirms the workspace folder exists.
- Select File from the Visual Studio Code top menu.
- Select Open Folder.
- Choose the gridrag-agent folder from your Desktop.
- Click Select Folder.
Pandapower supplies the electrical solver. Qdrant supplies persistent vector memory later in the project.
- Select the new-file icon in the Visual Studio Code Explorer sidebar.
- Name the file requirements.txt.
- Add the pinned dependencies by pasting these contents:
ollama==0.6.2
pandapower==3.5.4
qdrant-client==1.19.0
What does this file control?
- The ollama==0.6.2 entry installs the Python client used to send local model requests.
- The pandapower==3.5.4 entry installs the AC power-flow solver.
- The qdrant-client==1.19.0 entry installs the embedded vector database client.
- Save requirements.txt.
- Confirm that requirements.txt appears inside the gridrag-agent folder in the Explorer sidebar.
File missing from the Explorer sidebar?
- Confirm that the Explorer sidebar shows gridrag-agent as the open folder.
- Check that the filename is exactly requirements.txt.
- Ask for help with a missing requirements file.
✔️ Awesome, I've got everything!
Great. Double-check that you saved requirements.txt inside gridrag-agent.
ⓧ I'd like to double check the full code
ollama==0.6.2
pandapower==3.5.4
qdrant-client==1.19.0
Visual Studio Code can create the isolated environment from your selected Python interpreter. A fresh terminal then activates .venv for package installation.
- Press Ctrl+Shift+P to open the Command Palette.
- Type Python: Create Environment.
- Select Python: Create Environment.
- Select Venv.
- Select the Python interpreter showing version 3.13.15.
- Wait until the .venv folder appears in the Explorer sidebar.
- Select Terminal from the top menu.
- Select New Terminal.
The new terminal prompt should begin with (.venv). That prefix confirms that package installation stays inside the GridRAG environment.
- Install the pinned packages into .venv by running this command:
python -m pip install -r requirements.txt
What does this command do?
Python starts pip from the active .venv environment. Pip reads requirements.txt before installing each exact package version.
This is the longest package setup action in the step. Plan for a few minutes while the scientific dependencies download and install.
The terminal prints installation progress. The PowerShell prompt returns when the environment is ready.
Package installation stopped?
- Check that the terminal prompt begins with (.venv).
- Confirm that the terminal path ends with gridrag-agent.
- Repeat the package installation command after restoring your internet connection.
Ask for help with the package installation.
Download and verify the local models
The qwen3:4b model handles the agent's tool decisions. The embeddinggemma model turns simulation summaries into embeddings for similarity search.
These model downloads can take several minutes because they transfer several gigabytes in total. Ollama keeps the downloaded files on your computer for later runs.
- Download both GridRAG models by running these commands:
ollama pull qwen3:4b
ollama pull embeddinggemma
What do these commands download?
The first command downloads the quantized qwen3:4b chat model. Its model file is about 2.5 GB.
The second command downloads embeddinggemma for local vector generation. Its model file is about 622 MB.
Before you run the final check, do you expect the runtime, package environment, and model inventory to agree with every pinned project requirement?
- Verify the complete local toolchain by running these commands:
python --version
python -m pip list
ollama ls
What should you see?
- The Python check should report 3.13.15.
- The package list should include ollama 0.6.2, pandapower 3.5.4, plus qdrant-client 1.19.0.
- The Ollama model list should include qwen3:4b plus embeddinggemma.
Is anything missing from the final check?
- Return to the Python outcome tabs if the version differs from 3.13.15.
- Open a new Visual Studio Code terminal if (.venv) is missing from the prompt.
- Repeat the package installation command if a pinned package is absent.
- Open Ollama through Windows search if the model list cannot reach the local service.
- Repeat the relevant model pull command if either model tag is absent.
Ask for help with the final toolchain check.
That completes the local foundation. Your pinned Python environment plus both Ollama models are ready to support GridRAG.
Your local toolchain is ready. Next up, you will run a real AC power-flow simulation and inspect solver-backed grid metrics.
Run a Real Grid Simulation
Your Visual Studio Code workspace now has every local tool this project needs. Your next goal is to produce electrical evidence with a balanced AC power flow from pandapower.
One solver run measures a scaled-load scenario. It cannot remember earlier cases. This step creates the trustworthy snapshot that later retrieval can compare.
In this step, get ready to:
- Load the 33-bus test network in solver.py.
- Scale its active and reactive loads before running the power-flow solver.
- Print convergence, voltage, line-loading, and loss measurements.
Load the 33-bus test network
The IEEE 33-bus test network gives you a known distribution-grid case. The case33bw() function loads this network from the test cases included with pandapower.
- Select the new-file icon in the file sidebar from earlier.
- Enter solver.py as the file name.
- Press Enter to create the file.
- Add the imports and the start of run_power_flow() by pasting this code:
import json
from typing import Any
import pandapower as pp
from pandapower.networks.power_system_test_cases import case33bw
def run_power_flow(load_scale: float) -> dict[str, Any]:
if load_scale <= 0:
raise ValueError("load_scale must be greater than zero")
net = case33bw()
What Does This Code Do?
- The json module prepares solver measurements for readable terminal output.
- The Any type describes a result dictionary containing several value types.
- The case33bw() call creates a fresh copy of the 33-bus network for each simulation.
- The positive-scale check prevents a scenario with zero or reversed demand.
- Save solver.py.
- Confirm the file sidebar lists solver.py inside gridrag-agent.
Cannot Find solver.py?
Check that the file sits beside requirements.txt in gridrag-agent. Move it into that folder if it was created elsewhere.
If the editor marks an import as unresolved, confirm the selected interpreter still points to the activated .venv from the previous step.
Help me check my solver file and selected Python environment.
Scale the loads and run the solver
A load scale multiplies the network's active demand in p_mw and reactive demand in q_mvar. Applying one factor to both fields creates a consistent scenario for the solver.
- Place your cursor below the net = case33bw() line inside run_power_flow().
- Add the load scaling, solver call, and initial result by pasting this code:
net.load["p_mw"] = net.load["p_mw"] * load_scale
net.load["q_mvar"] = net.load["q_mvar"] * load_scale
pp.runpp(net, algorithm="bfsw")
if not bool(net.converged):
raise RuntimeError("Power flow did not converge")
return {
"network": "case33bw",
"load_scale": float(load_scale),
"converged": bool(net.converged),
}
How Does the Solve Work?
- The first two lines scale every active and reactive load in the network.
- The pp.runpp() call runs the balanced AC power flow with the bfsw algorithm.
- The convergence check stops the script if the solver cannot produce a valid result.
- The initial dictionary records which network ran, which scale it used, and whether the solve converged.
- Save solver.py.
- Confirm the editor leaves the new Python lines free of red syntax underlines.
Seeing Syntax Underlines?
Make sure every new line remains indented inside run_power_flow(). The return block must align with pp.runpp().
Help me fix the indentation in run_power_flow().
A main entry point turns run_power_flow() into a terminal program. It collects a load scale before printing the first solver-backed result.
- Place your cursor two blank lines below the closing brace of run_power_flow().
- Add the program entry point by pasting this code:
def main() -> None:
load_scale = float(input("Enter a positive load scale, for example 1.25: "))
result = run_power_flow(load_scale)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
What Does the Entry Point Do?
- The input prompt converts the learner's value into a floating-point load scale.
- The main() function passes that value to run_power_flow().
- The json.dumps() call formats the returned dictionary with indentation.
- The final condition runs main() when the file starts from the terminal.
Before you run the script, predict whether the power flow will converge at a load scale of 1.25.
- Save solver.py.
- Use 1.25 when the load-scale prompt appears.
- Start the first simulation in the terminal from earlier by running this command:
python solver.py
What Happens When This Runs?
The command starts solver.py with the activated Python environment. Your input scales the test network before pandapower solves it.
You should see formatted JSON naming case33bw with a load scale of 1.25. The converged field should be true.
Did the First Solve Fail?
If the script rejects the scale, enter a positive number such as 1.25. A zero or negative value triggers the validation inside run_power_flow().
If Python cannot import pandapower, return to the activated terminal from the previous step. Confirm its prompt still shows the selected .venv.
Help me diagnose why python solver.py did not produce converged JSON.
Extract and summarize the grid metrics
Convergence confirms that the solver found a result. Voltage, loading, and losses reveal what happened inside that result.
- Find the convergence check inside run_power_flow().
- Place your cursor below the raise RuntimeError("Power flow did not converge") line.
- Add the metric extraction logic by pasting this code:
voltages = net.res_bus["vm_pu"].dropna()
loadings = net.res_line["loading_percent"].dropna()
weakest_bus_index = int(voltages.idxmin())
line_loading_available = not loadings.empty
max_line_index = int(loadings.idxmax()) if line_loading_available else -1
max_line_loading = float(loadings.max()) if line_loading_available else 0.0
How Are the Key Measurements Chosen?
- The voltage series comes from vm_pu after empty values are removed.
- The idxmin() result identifies the bus with the lowest voltage.
- The line-loading series comes from loading_percent.
- The availability check supplies safe fallback values if the network has no line-loading results.
- Save solver.py.
- Confirm the inserted lines remain inside run_power_flow().
- Find the current result dictionary at the bottom of run_power_flow().
- Use this block to identify the dictionary you need to replace:
return {
"network": "case33bw",
"load_scale": float(load_scale),
"converged": bool(net.converged),
}
What Is Missing From the Current Result?
The current dictionary proves the solve converged. It does not expose the voltage, line-loading, or loss measurements you just extracted.
- Replace the current dictionary with this expanded result:
return {
"network": "case33bw",
"load_scale": float(load_scale),
"converged": bool(net.converged),
"minimum_voltage_pu": float(voltages.min()),
"weakest_bus_index": weakest_bus_index,
"line_loading_available": line_loading_available,
"maximum_line_loading_percent": max_line_loading,
"maximum_line_index": max_line_index,
"total_line_losses_mw": float(net.res_line["pl_mw"].sum()),
}
What Does the Expanded Result Contain?
- The minimum voltage and weakest bus locate the lowest-voltage point in the solved network.
- The maximum loading and line index locate the most heavily loaded line.
- The summed pl_mw values report total active line losses in megawatts.
- The availability field records whether line-loading measurements were present.
Before you rerun the script, predict which additional measurement names will appear in the JSON.
- Save solver.py.
- Use 1.25 at the input prompt.
- Run the expanded simulation with this command:
python solver.py
What Does This Check Prove?
This run confirms that every extracted value can be serialized into the result dictionary. It also proves the bus, line, and loss calculations execute after a converged solve.
You should now see values for minimum_voltage_pu, weakest_bus_index, maximum_line_loading_percent, maximum_line_index, and total_line_losses_mw.
Are Metric Fields Missing?
Check that the expanded return dictionary replaced the smaller version. Keeping both dictionaries causes the function to stop at the first one.
Check that voltages and loadings are defined before the expanded dictionary.
Help me find why my solver result is missing electrical metrics.
A compact summary turns the structured result into evidence that an agent can read later. It keeps every numerical claim tied to fields returned by the solver.
- Place your cursor between run_power_flow() and main().
- Add two blank lines around the new function.
- Add the summary formatter by pasting this code:
def summarize_result(result: dict[str, Any]) -> str:
if result["line_loading_available"]:
loading_text = (
f"maximum line loading {result['maximum_line_loading_percent']:.2f}% "
f"on line {result['maximum_line_index']}"
)
else:
loading_text = "line loading was not available"
return (
f"case33bw AC power-flow simulation at load scale {result['load_scale']:.2f}; "
f"converged {result['converged']}; "
f"minimum voltage {result['minimum_voltage_pu']:.4f} pu "
f"at bus {result['weakest_bus_index']}; "
f"{loading_text}; "
f"total line losses {result['total_line_losses_mw']:.4f} MW."
)
How Does the Summary Stay Grounded?
- Every formatted measurement comes from the result dictionary returned by run_power_flow().
- The line-loading branch reports the measured maximum when that result is available.
- The formatted precision makes voltage, loading, and losses easier to compare across future simulations.
- Save solver.py.
- Find this existing print line inside main():
print(json.dumps(result, indent=2))
What Does the Current Print Line Show?
This line prints the structured JSON result. The human-readable summary still needs its own print call.
- Add the summary print directly below the existing line so both lines look like this:
print(json.dumps(result, indent=2))
print(summarize_result(result))
Why Print Both Formats?
The JSON preserves named fields for code and storage. The summary presents the same solver evidence as a readable sentence.
- Save solver.py.
✔️ Awesome, I've got everything!
Your solver file is complete. Make sure solver.py is saved before the final run.
ⓧ I'd like to double check the full code
import json
from typing import Any
import pandapower as pp
from pandapower.networks.power_system_test_cases import case33bw
def run_power_flow(load_scale: float) -> dict[str, Any]:
if load_scale <= 0:
raise ValueError("load_scale must be greater than zero")
net = case33bw()
net.load["p_mw"] = net.load["p_mw"] * load_scale
net.load["q_mvar"] = net.load["q_mvar"] * load_scale
pp.runpp(net, algorithm="bfsw")
if not bool(net.converged):
raise RuntimeError("Power flow did not converge")
voltages = net.res_bus["vm_pu"].dropna()
loadings = net.res_line["loading_percent"].dropna()
weakest_bus_index = int(voltages.idxmin())
line_loading_available = not loadings.empty
max_line_index = int(loadings.idxmax()) if line_loading_available else -1
max_line_loading = float(loadings.max()) if line_loading_available else 0.0
return {
"network": "case33bw",
"load_scale": float(load_scale),
"converged": bool(net.converged),
"minimum_voltage_pu": float(voltages.min()),
"weakest_bus_index": weakest_bus_index,
"line_loading_available": line_loading_available,
"maximum_line_loading_percent": max_line_loading,
"maximum_line_index": max_line_index,
"total_line_losses_mw": float(net.res_line["pl_mw"].sum()),
}
def summarize_result(result: dict[str, Any]) -> str:
if result["line_loading_available"]:
loading_text = (
f"maximum line loading {result['maximum_line_loading_percent']:.2f}% "
f"on line {result['maximum_line_index']}"
)
else:
loading_text = "line loading was not available"
return (
f"case33bw AC power-flow simulation at load scale {result['load_scale']:.2f}; "
f"converged {result['converged']}; "
f"minimum voltage {result['minimum_voltage_pu']:.4f} pu "
f"at bus {result['weakest_bus_index']}; "
f"{loading_text}; "
f"total line losses {result['total_line_losses_mw']:.4f} MW."
)
def main() -> None:
load_scale = float(input("Enter a positive load scale, for example 1.25: "))
result = run_power_flow(load_scale)
print(json.dumps(result, indent=2))
print(summarize_result(result))
if __name__ == "__main__":
main()
Before the final run, predict which bus will have the lowest voltage. Predict which line will have the highest loading.
- Use 1.25 when the script requests a positive load scale.
- Run the completed solver with this command:
python solver.py
What Does the Final Run Verify?
The final run exercises input validation, load scaling, the AC power-flow solve, metric extraction, JSON formatting, and summary formatting. One command now produces the full grounded snapshot.
You should see formatted JSON with converged set to true. The output should also report a minimum bus voltage, weakest bus index, maximum line loading, line index, and total line losses.
Below the JSON, you should see a sentence summarizing the same 1.25 simulation. You have your first grounded grid result. Every displayed measurement now comes from the power-flow solver.
Is the Summary Missing or Incomplete?
Check that summarize_result() sits outside run_power_flow(). Its def line should start at the far left.
Check that print(summarize_result(result)) sits inside main() directly below the JSON print line.
Help me fix my final solver output.
Your solver now produces the electrical evidence GridRAG needs. Next, you will give these simulation snapshots persistent vector memory.
Give Simulations Persistent Vector Memory
The solver from the previous step now produces trustworthy electrical measurements for any positive load scale. Each run still stands alone after its output leaves the terminal.
This step gives those simulations persistent memory with Qdrant. You will represent each solver summary with an embedding. You will use cosine similarity to retrieve related historical cases.
In this step, get ready to:
- Create a persistent cosine-vector collection for grid simulations.
- Store solver summaries with their electrical metrics.
- Seed the memory with three scenarios and retrieve similar results.
Create the local vector collection
A vector collection needs a storage path plus a fixed vector size. The local client keeps this data inside the existing gridrag-agent workspace.
- Create memory.py inside the gridrag-agent folder using the VS Code file sidebar.
- Add the imports plus local database configuration by pasting this code:
from typing import Any
from uuid import NAMESPACE_URL, uuid5
import ollama
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
from solver import run_power_flow, summarize_result
EMBED_MODEL = "embeddinggemma"
COLLECTION_NAME = "grid_simulations"
DB_PATH = "gridrag_db"
client = QdrantClient(path=DB_PATH)
What does this setup establish?
- The imports bring in the local model client plus Qdrant's collection and point types.
- The three constants keep the embedding model tag plus collection and database names consistent.
- The client stores vector data at the local gridrag_db path.
- Save memory.py.
- Check the initial module by running this command in the activated VS Code terminal:
python memory.py
What does this check prove?
Python loads the imports plus initializes the local Qdrant client. Returning to the terminal prompt without a traceback confirms that this foundation can load.
You should return to the terminal prompt without a traceback. The local client is ready for the collection logic.
Seeing an import or database error?
Confirm that the VS Code terminal still shows the activated .venv environment. Check that memory.py sits beside solver.py inside gridrag-agent.
Help me diagnose the module-loading error in memory.py.
The collection size must match the vectors produced by EmbeddingGemma. A probe embedding lets the code discover that size directly from the local model.
- Add get_embedding() below the client assignment by pasting this code:
def get_embedding(text: str) -> list[float]:
response = ollama.embed(model=EMBED_MODEL, input=text)
return list(response.embeddings[0])
How does the embedding helper work?
- The local Ollama embedding API converts summary text into a numeric vector.
- The helper returns the first vector from response.embeddings as a Python list.
- Save memory.py.
- Confirm that the editor shows get_embedding without a red syntax underline.
Is the embedding helper underlined?
Check that the function starts at the left edge of the file. Confirm that both indented lines sit inside get_embedding().
Help me fix the syntax or type warning in get_embedding().
The probe vector supplies the collection dimension. The collection uses cosine distance so searches rank summaries by vector direction.
- Add ensure_collection() below get_embedding() by pasting this code:
def ensure_collection() -> None:
if client.collection_exists(collection_name=COLLECTION_NAME):
return
probe_vector = get_embedding("GridRAG simulation memory")
client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(
size=len(probe_vector),
distance=Distance.COSINE,
),
)
How is the collection created?
- The existence check preserves the current collection when the script runs again.
- The probe vector determines the exact vector length returned by the selected model.
- The cosine distance setting prepares Qdrant to compare simulation meanings.
- Save memory.py.
- Check the expanded module by running this command:
python memory.py
What does this module check cover?
Python parses the new embedding and collection functions while loading the local client. A clean return confirms that the module remains valid after the collection logic was added.
You should return to the prompt without a traceback. Your collection setup is now ready to be called by the storage workflow.
Does the module fail to load now?
Check the closing parentheses inside VectorParams plus client.create_collection. Confirm that Distance.COSINE uses the same capitalization shown above.
Help me fix the collection setup in memory.py.
Store embedded solver results
A useful memory record needs both semantic search data and precise electrical facts. The vector supports retrieval while the payload preserves every metric returned by run_power_flow().
- Add store_simulation() below ensure_collection() by pasting this code:
def store_simulation(result: dict[str, Any]) -> str:
ensure_collection()
summary = summarize_result(result)
vector = get_embedding(summary)
point_id = str(
uuid5(
NAMESPACE_URL,
f"gridrag:case33bw:{result['load_scale']:.6f}",
)
)
payload = dict(result)
payload["summary"] = summary
client.upsert(
collection_name=COLLECTION_NAME,
wait=True,
points=[
PointStruct(
id=point_id,
vector=vector,
payload=payload,
)
],
)
return point_id
What does the storage function preserve?
- The solver summary becomes the text used to generate the searchable vector.
- The UUID5 value gives the same load scale a deterministic point ID on repeated runs.
- The payload contains the original solver metrics plus the readable summary.
- The upsert writes the record before returning its point ID.
- Save memory.py.
- Check that the storage function parses by running this command:
python memory.py
What does this syntax check confirm?
Python loads the new UUID and upsert logic as part of the module. Returning cleanly confirms that the storage function is structurally valid.
You should return to the prompt without a traceback. The next substep connects this storage function to seeding and search.
Seeing an error in the point record?
Check that PointStruct receives id plus vector and payload inside the points list. Confirm that return point_id stays inside the function.
Help me debug store_simulation().
Seed and search the simulation memory
Retrieval embeds a question with the same model used for stored summaries. Qdrant can then return the closest simulation records with payloads and similarity scores.
- Add search_simulation_memory() below store_simulation() by pasting this code:
def search_simulation_memory(query: str, limit: int = 3) -> str:
ensure_collection()
query_vector = get_embedding(query)
hits = client.query_points(
collection_name=COLLECTION_NAME,
query=query_vector,
with_payload=True,
limit=limit,
).points
if not hits:
return "No simulation memories matched the query."
lines = []
for hit in hits:
payload = hit.payload or {}
summary = payload.get("summary", "Simulation summary unavailable")
lines.append(f"score={hit.score:.3f} | {summary}")
return "\n".join(lines)
How does retrieval produce evidence?
- The query uses get_embedding() so stored vectors and search vectors share one representation.
- The query requests payloads so each match includes its solver-backed summary.
- The formatted result keeps the similarity score beside the corresponding electrical evidence.
- Save memory.py.
- Confirm that the editor shows search_simulation_memory without a red syntax underline.
Is the search function underlined?
Check that ).points closes the query call before the empty-results check. Confirm that return "\n".join(lines) stays inside the function.
Help me fix search_simulation_memory().
Three seed scenarios give retrieval a small history to compare. Each scenario comes from a fresh solver run before it enters the vector store.
- Add seed_memory() below search_simulation_memory() by pasting this code:
def seed_memory() -> None:
for load_scale in (0.80, 1.00, 1.20):
result = run_power_flow(load_scale)
point_id = store_simulation(result)
print(f"Stored {load_scale:.2f} as {point_id}")
What does the seed loop create?
The loop runs the existing solver at three load scales. Each successful result is embedded plus stored before its deterministic point ID is printed.
- Save memory.py.
- Check the expanded module by running this command:
python memory.py
What does this module check cover?
Python parses the seed loop together with the collection and storage functions. Returning to the prompt without a traceback confirms that the assembled helpers can load together.
You should return to the terminal prompt without a traceback. The final entry point now needs to call the seed and search functions.
Does the seed loop fail to parse?
Confirm that the three load scales stay inside one tuple. Check that the solver call plus storage call and print statement are indented beneath the for line.
Help me repair the seed_memory loop.
The entry point turns the helpers into one repeatable workflow. It seeds the database before asking for a heavily loaded case with lower voltage.
- Add main() plus the script entry point below seed_memory() by pasting this code:
def main() -> None:
seed_memory()
print("\nRetrieved simulation memory:")
print(search_simulation_memory("A heavily loaded grid case with lower voltage"))
if __name__ == "__main__":
main()
How does the workflow finish?
- The entry point calls seed_memory() to create or update all three records.
- The search request asks for historical cases that combine heavier loading with lower voltage.
- The final print exposes the retrieved summaries plus their similarity scores.
- Save memory.py.
Before you run the complete workflow, which seeded load case do you expect the semantic search to rank most strongly?
- Seed and query the persistent memory by running this command:
python memory.py
What happens during this run?
The script solves three grid scenarios plus generates local embeddings for their summaries. It writes the vectors and payloads to gridrag_db before running the similarity query.
The local embedding work may pause briefly while the model processes each summary. Continued terminal activity means the workflow is still progressing.
You should see three lines beginning with Stored followed by deterministic point IDs. Below Retrieved simulation memory: you should see at least one solver summary beginning with score=.
That is the memory loop working. Your solver results can now survive between runs and return as evidence for related questions.
Missing stored IDs or retrieved summaries?
Confirm that the local Ollama service is running with the embeddinggemma model available. Check that the activated terminal is inside the gridrag-agent folder.
If the collection reports a vector mismatch after an earlier experiment, remove only the local gridrag_db folder through File Explorer before rerunning the script.
Help me diagnose the persistent memory workflow.
✔️ Awesome, I've got everything!
Your three solver scenarios are stored in persistent vector memory. Make sure memory.py is saved before continuing.
ⓧ I'd like to double check the full code
from typing import Any
from uuid import NAMESPACE_URL, uuid5
import ollama
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
from solver import run_power_flow, summarize_result
EMBED_MODEL = "embeddinggemma"
COLLECTION_NAME = "grid_simulations"
DB_PATH = "gridrag_db"
client = QdrantClient(path=DB_PATH)
def get_embedding(text: str) -> list[float]:
response = ollama.embed(model=EMBED_MODEL, input=text)
return list(response.embeddings[0])
def ensure_collection() -> None:
if client.collection_exists(collection_name=COLLECTION_NAME):
return
probe_vector = get_embedding("GridRAG simulation memory")
client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(
size=len(probe_vector),
distance=Distance.COSINE,
),
)
def store_simulation(result: dict[str, Any]) -> str:
ensure_collection()
summary = summarize_result(result)
vector = get_embedding(summary)
point_id = str(
uuid5(
NAMESPACE_URL,
f"gridrag:case33bw:{result['load_scale']:.6f}",
)
)
payload = dict(result)
payload["summary"] = summary
client.upsert(
collection_name=COLLECTION_NAME,
wait=True,
points=[
PointStruct(
id=point_id,
vector=vector,
payload=payload,
)
],
)
return point_id
def search_simulation_memory(query: str, limit: int = 3) -> str:
ensure_collection()
query_vector = get_embedding(query)
hits = client.query_points(
collection_name=COLLECTION_NAME,
query=query_vector,
with_payload=True,
limit=limit,
).points
if not hits:
return "No simulation memories matched the query."
lines = []
for hit in hits:
payload = hit.payload or {}
summary = payload.get("summary", "Simulation summary unavailable")
lines.append(f"score={hit.score:.3f} | {summary}")
return "\n".join(lines)
def seed_memory() -> None:
for load_scale in (0.80, 1.00, 1.20):
result = run_power_flow(load_scale)
point_id = store_simulation(result)
print(f"Stored {load_scale:.2f} as {point_id}")
def main() -> None:
seed_memory()
print("\nRetrieved simulation memory:")
print(search_simulation_memory("A heavily loaded grid case with lower voltage"))
if __name__ == "__main__":
main()
Your simulations now have persistent history plus semantic retrieval. Next up, you will expose what happens when a local model tries to analyze the grid without either source of evidence.
Expose the Ungrounded Model Problem
Your Qdrant collection now stores solver-backed cases with similarity scores. The summaries still need a clear interpretation before they can answer a comparison request.
The local Ollama model can produce a fluent grid comparison from the question alone. This step tests whether that fluency carries evidence from retrieval or a power-flow solve.
In this step, get ready to:
- Create a model-only baseline for the grid comparison.
- Send the comparison request directly to the local model.
- Inspect whether the response includes solver or retrieval evidence.
Create the model-only baseline
A baseline gives you a controlled reference point for evaluating later changes. This version produces one model response beside an explicit tool count.
- Select the new-file icon at the top of the VS Code file sidebar.
- Enter baseline.py as the file name.
- Press Enter to create the file.
- Add the baseline request to baseline.py by pasting this code:
from ollama import chat
MODEL = "qwen3:4b"
QUESTION = (
"Analyze the case33bw grid at a 1.25 load scale and compare it "
"with similar previous simulations."
)
response = chat(
model=MODEL,
messages=[{"role": "user", "content": QUESTION}],
think=False,
)
print("Tool calls available: 0")
print(response.message.content)
What Does This Baseline Do?
- The MODEL value selects qwen3:4b for the response.
- The QUESTION value asks for analysis of a 1.25 load-scale case.
- The chat call sends one user message with the think=False setting.
- The last two lines place the tool-count marker before the model response.
- Press Ctrl+S to save baseline.py.
- Confirm baseline.py appears beside solver.py and memory.py in the file sidebar.
File Not Appearing in the Workspace?
- Confirm baseline.py appears inside the same gridrag-agent workspace as your existing files.
- Return to the gridrag-agent workspace if the file sidebar shows a different folder.
Help me find or create baseline.py in my existing VS Code workspace.
✔️ Awesome, I've got everything!
Your model-only baseline is saved in the correct workspace.
ⓧ I'd like to double check the full code
from ollama import chat
MODEL = "qwen3:4b"
QUESTION = (
"Analyze the case33bw grid at a 1.25 load scale and compare it "
"with similar previous simulations."
)
response = chat(
model=MODEL,
messages=[{"role": "user", "content": QUESTION}],
think=False,
)
print("Tool calls available: 0")
print(response.message.content)
What Should Match?
This complete file sends one model-only comparison request. It prints the zero-tool marker before the response.
Run the evidence check
A tool trace records which external function supplied evidence to the model. Use the baseline output to check whether its electrical claims come with that record.
Before you run it, predict whether the answer will include a source that proves where its grid claims came from.
- Run baseline.py in the VS Code terminal by using this command:
python baseline.py
The Missing Evidence Is the Result
The terminal shows Tool calls available: 0 before the response. That marker proves the model used neither run_power_flow nor search_simulation_memory.
The response may still sound confident. Any electrical measurements or historical comparisons inside it have no tool-backed evidence.
- Inspect the response for voltage claims.
- Inspect the response for loading or loss claims.
- Inspect the response for comparisons with previous simulations.
- Match each claim against the tool count printed above it.
Baseline Not Returning a Response?
- Confirm the local Ollama service is still running.
- Confirm the terminal prompt shows the activated .venv environment.
- Confirm qwen3:4b remains available in your local model list.
Help me debug the missing baseline response.
You found the core flaw: the model can speak fluently without proving a single grid measurement. Next, you will connect Ollama to retrieval and the solver so every final answer waits for both evidence sources.
Connect Ollama to Retrieval and the Solver
Your Ollama baseline produced fluent grid analysis with zero tool calls. That result exposed the missing factual path between the model and your electrical evidence.
Now you'll connect retrieval from Qdrant to fresh simulations from pandapower through function calling. An agent loop must collect both forms of evidence before it prints an answer.
In this step, get ready to:
- Expose retrieval and simulation as typed tools.
- Register both tools in a multi-turn agent loop.
- Block the final answer until both evidence tools have run.
Create the retrieval and solver tools
Type hints define the arguments each tool accepts. Docstrings tell Ollama when each tool is useful.
- Create agent.py next to memory.py in the existing gridrag-agent workspace by adding this code:
import json
from ollama import ChatResponse, chat
from memory import search_simulation_memory, store_simulation
from solver import run_power_flow, summarize_result
MODEL = "qwen3:4b"
REQUIRED_TOOLS = {"retrieve_simulation_history", "simulate_grid"}
def retrieve_simulation_history(query: str) -> str:
"""Retrieve prior power-flow simulations that are relevant to a query.
Args:
query: A natural-language description of the grid case to retrieve.
Returns:
Similar stored simulation summaries with similarity scores.
"""
return search_simulation_memory(query)
What does this code do?
- The imports reuse the simulation memory from memory.py and the solver functions from solver.py.
- The MODEL constant selects qwen3:4b for local reasoning.
- The REQUIRED_TOOLS set names the two evidence sources that must run.
- The retrieve_simulation_history() function searches the existing vector memory for relevant solver summaries.
- Save agent.py.
- Confirm the workspace file list now shows agent.py next to memory.py.
Cannot find the imported modules?
Check that agent.py, memory.py, and solver.py sit directly inside the same gridrag-agent folder.
Check that the existing virtual environment remains active in the VS Code terminal.
Help me diagnose missing local imports in agent.py.
The simulation tool creates fresh electrical evidence. It also stores each successful result as JSON with a memory ID and solver-backed summary.
- Add the simulation tool directly below retrieve_simulation_history() by pasting this code:
def simulate_grid(load_scale: float) -> str:
"""Run and store a case33bw AC power-flow simulation.
Args:
load_scale: Positive multiplier applied to active and reactive loads.
Returns:
JSON containing the stored memory ID and solver-backed summary.
"""
result = run_power_flow(load_scale)
point_id = store_simulation(result)
return json.dumps(
{
"memory_id": point_id,
"summary": summarize_result(result),
}
)
How does the solver tool stay grounded?
- The load_scale argument passes the model's requested scale into run_power_flow().
- The store_simulation() call writes the completed run into persistent Qdrant memory.
- The returned summary contains measurements produced by the solver.
- The memory ID traces the answer back to the stored simulation.
- Save agent.py.
- Check the editor for the retrieve_simulation_history() and simulate_grid() definitions.
You should see both typed tools in agent.py with no incomplete blocks between them.
Seeing indentation problems?
Make sure simulate_grid() begins at the far left. It must sit outside retrieve_simulation_history().
Help me fix the function indentation in agent.py.
Register and execute both tools
The model receives the Python functions as available tools. The message history preserves each request and tool result across turns.
- Add the opening section of run_agent() below simulate_grid() by pasting this code:
def run_agent(question: str) -> None:
available_functions = {
"retrieve_simulation_history": retrieve_simulation_history,
"simulate_grid": simulate_grid,
}
messages = [
{
"role": "system",
"content": (
"You are GridRAG in a tool-calling agent loop. For every grid "
"analysis request with a load scale, retrieve similar simulation "
"history and run the grid solver before answering. Treat tool "
"results as the only source of electrical measurements. Never "
"invent, estimate, or silently calculate grid metrics. State that "
"the evidence comes from retrieved simulations and a new solver run."
),
},
{"role": "user", "content": question},
]
tools_called: set[str] = set()
What controls the agent?
- The available_functions dictionary maps each tool name to the Python function that executes it.
- The system message limits electrical measurements to tool results.
- The user question starts the working conversation.
- The tools_called set records which evidence sources actually executed.
- Save agent.py.
- Check the indentation inside run_agent().
You should see the function registry and message history nested beneath run_agent().
Does the system message sit outside the list?
Check that the closing brackets for messages line up with its assignment. The tools_called line stays inside run_agent().
Help me inspect the nested message structure in agent.py.
Each model turn can request one or more functions. The loop executes those functions and returns their results as tool messages.
- Continue run_agent() directly below tools_called by adding this loop:
for _ in range(8):
response: ChatResponse = chat(
model=MODEL,
messages=messages,
tools=list(available_functions.values()),
think=False,
)
messages.append(response.message)
if response.message.tool_calls:
for tool_call in response.message.tool_calls:
name = tool_call.function.name
arguments = tool_call.function.arguments
function_to_call = available_functions.get(name)
if function_to_call is None:
result = f"Unknown tool: {name}"
else:
print(f"Tool trace: {name}({arguments})")
result = function_to_call(**arguments)
tools_called.add(name)
messages.append(
{
"role": "tool",
"tool_name": name,
"content": str(result),
}
)
continue
How does the tool loop work?
- The chat() call gives Ollama the full message history and both registered functions.
- Each tool call supplies a function name and typed arguments.
- The matching Python function produces the retrieval or solver result.
- A tool-result message returns that evidence to the model for the next turn.
- The printed tool trace makes every executed function visible in the terminal.
- Save agent.py.
- Check that the loop remains indented inside run_agent().
You should see the tool-call branch nested beneath the model response with consistent indentation.
Is a tool call outside the loop?
Check that every line from response through continue remains inside the eight-turn loop.
Help me check the tool loop indentation in agent.py.
Enforce the evidence gate and test GridRAG
A model can attempt to answer before collecting enough evidence. The grounding gate checks executed tool names before any final response reaches the terminal.
- Complete run_agent() below the tool-call branch by adding this guard:
if REQUIRED_TOOLS.issubset(tools_called):
print("\nGrounded GridRAG answer:")
print(response.message.content)
return
missing = ", ".join(sorted(REQUIRED_TOOLS - tools_called))
messages.append(
{
"role": "user",
"content": (
f"Do not answer yet. Call the missing evidence tools: {missing}."
),
}
)
raise RuntimeError("GridRAG stopped because it could not complete its grounding tools")
Why does the guard matter?
- The subset check permits a final answer only after retrieval and simulation have both executed.
- A missing-tool message sends the model back into the loop with a direct instruction.
- The final runtime check stops the process if the evidence tools remain incomplete after eight turns.
- Save agent.py.
- Check that the grounding condition stays inside the eight-turn loop.
You should see the final runtime check aligned with the loop rather than nested inside it.
- Add the interactive entry point at the end of agent.py by pasting this code:
def main() -> None:
question = input(
"Ask GridRAG a load-scale question, or press Enter for the demo: "
).strip()
if not question:
question = (
"Analyze the grid at a 1.25 load scale and compare it with similar "
"previous simulations."
)
run_agent(question)
if __name__ == "__main__":
main()
What does the entry point do?
- The prompt accepts a natural-language grid question.
- An empty response selects the built-in load-scale comparison.
- The run_agent() call starts the guarded tool loop.
- Save agent.py.
✔️ Awesome, I've got everything!
Your complete agent.py file is ready for its grounded test.
ⓧ I'd like to double check the full code
import json
from ollama import ChatResponse, chat
from memory import search_simulation_memory, store_simulation
from solver import run_power_flow, summarize_result
MODEL = "qwen3:4b"
REQUIRED_TOOLS = {"retrieve_simulation_history", "simulate_grid"}
def retrieve_simulation_history(query: str) -> str:
"""Retrieve prior power-flow simulations that are relevant to a query.
Args:
query: A natural-language description of the grid case to retrieve.
Returns:
Similar stored simulation summaries with similarity scores.
"""
return search_simulation_memory(query)
def simulate_grid(load_scale: float) -> str:
"""Run and store a case33bw AC power-flow simulation.
Args:
load_scale: Positive multiplier applied to active and reactive loads.
Returns:
JSON containing the stored memory ID and solver-backed summary.
"""
result = run_power_flow(load_scale)
point_id = store_simulation(result)
return json.dumps(
{
"memory_id": point_id,
"summary": summarize_result(result),
}
)
def run_agent(question: str) -> None:
available_functions = {
"retrieve_simulation_history": retrieve_simulation_history,
"simulate_grid": simulate_grid,
}
messages = [
{
"role": "system",
"content": (
"You are GridRAG in a tool-calling agent loop. For every grid "
"analysis request with a load scale, retrieve similar simulation "
"history and run the grid solver before answering. Treat tool "
"results as the only source of electrical measurements. Never "
"invent, estimate, or silently calculate grid metrics. State that "
"the evidence comes from retrieved simulations and a new solver run."
),
},
{"role": "user", "content": question},
]
tools_called: set[str] = set()
for _ in range(8):
response: ChatResponse = chat(
model=MODEL,
messages=messages,
tools=list(available_functions.values()),
think=False,
)
messages.append(response.message)
if response.message.tool_calls:
for tool_call in response.message.tool_calls:
name = tool_call.function.name
arguments = tool_call.function.arguments
function_to_call = available_functions.get(name)
if function_to_call is None:
result = f"Unknown tool: {name}"
else:
print(f"Tool trace: {name}({arguments})")
result = function_to_call(**arguments)
tools_called.add(name)
messages.append(
{
"role": "tool",
"tool_name": name,
"content": str(result),
}
)
continue
if REQUIRED_TOOLS.issubset(tools_called):
print("\nGrounded GridRAG answer:")
print(response.message.content)
return
missing = ", ".join(sorted(REQUIRED_TOOLS - tools_called))
messages.append(
{
"role": "user",
"content": (
f"Do not answer yet. Call the missing evidence tools: {missing}."
),
}
)
raise RuntimeError("GridRAG stopped because it could not complete its grounding tools")
def main() -> None:
question = input(
"Ask GridRAG a load-scale question, or press Enter for the demo: "
).strip()
if not question:
question = (
"Analyze the grid at a 1.25 load scale and compare it with similar "
"previous simulations."
)
run_agent(question)
if __name__ == "__main__":
main()
Before you run the agent, predict whether both evidence tools appear before the final answer.
- Start GridRAG from the existing VS Code terminal by running this command:
python agent.py
What happens when the agent starts?
The command loads the persistent simulation memory and waits for your analysis request. Ollama decides which registered function to call on each turn.
- Submit the solver comparison at the prompt by entering:
Analyze the grid at a 1.25 load scale and compare it with similar previous simulations.
What evidence does this request need?
The comparison requires relevant historical simulations from Qdrant. The requested load scale also requires a fresh solver run.
You should see a Tool trace: retrieve_simulation_history line and a Tool trace: simulate_grid line before the answer.
You should then see Grounded GridRAG answer: followed by a comparison based on retrieved simulations and the new solver metrics.
Missing a tool trace or final answer?
- Confirm the Ollama application from earlier is still running.
- Check that REQUIRED_TOOLS contains retrieve_simulation_history and simulate_grid.
- Check that both functions appear in available_functions.
Help me debug missing GridRAG tool calls.
You did it. GridRAG now retrieves prior cases and runs a fresh power flow before explaining the measurements.
Secret mission
Compare Multiple Load Scenarios
Extend GridRAG with a batch solver tool that compares several load conditions in one request. The agent stores every successful scenario before ranking the solver results by minimum voltage.
Clean Up Your Resources
Clean Up Your Resources
All GridRAG resources stay on your Windows machine, so this project has no cloud charges. The options below let you keep the workspace, pause its local processes, or reclaim its disk space.
Resources you used:
- The gridrag-agent source folder containing your GridRAG scripts.
- The .venv Python environment inside gridrag-agent.
- The persistent gridrag_db folder containing local Qdrant simulation records.
- The qwen3:4b model downloaded through Ollama.
- The embeddinggemma model downloaded through Ollama.
Keep everything running
No action is needed. This option preserves the agent plus its simulation history for more local studies.
- Keep the gridrag-agent folder in its current location.
- Keep gridrag_db so future retrievals can use your stored simulation records.
- Keep qwen3:4b for local tool-calling responses.
- Keep embeddinggemma for indexing new simulations with compatible vectors.
- Leave the Ollama service available if you plan to run another analysis now.
Pause - I'll come back to this later
This option stops local processes while preserving every project file. Your saved simulation history remains available in gridrag_db.
- Stop any active agent.py process by pressing Ctrl+C in the VS Code terminal.
- Close Ollama from its Windows system tray icon.
- Close Visual Studio Code.
- Close any PowerShell windows that still point to gridrag-agent.
Your source code, Python environment, downloaded models, and vector memory remain on disk for your next session.
Delete - I don't want to use this again
Deleting the workspace permanently removes your code plus its stored simulation history. A backup is your only recovery path for anything you still need.
- Confirm the downloaded model tags by running this command in the VS Code terminal:
ollama ls
What does this command show?
This lists the model tags currently stored by Ollama. You should find qwen3:4b plus embeddinggemma in the output.
- Record the first model tag from the output here: first model tag from ollama ls.
- Record the second model tag from the output here: second model tag from ollama ls.
- Remove both recorded models by running these commands:
ollama rm [[FIRST_MODEL_TAG="first model tag from ollama ls"]]
ollama rm [[SECOND_MODEL_TAG="second model tag from ollama ls"]]
What do these commands remove?
Each line applies Ollama's removal pattern to one tag copied from your local list. This reclaims the disk space occupied by both downloaded models.
- Verify that both model tags are gone by running:
ollama ls
What should you see?
The output should no longer list qwen3:4b or embeddinggemma.
Does a model still appear?
Check that the copied tag matches the value shown by ollama ls. Stop any active GridRAG process before retrying the removal command.
Help me remove a model that still appears in Ollama.
- Close Ollama from its Windows system tray icon.
- Use the gridrag-agent folder's context menu in the VS Code Explorer sidebar to reveal it in Windows File Explorer.
- Close Visual Studio Code.
- Select gridrag-agent in Windows File Explorer.
- Press the Delete key.
- Empty the Windows Recycle Bin.
- Confirm that gridrag-agent no longer appears in Windows File Explorer.
That clears the project source plus its virtual environment. The persistent simulation memory is gone with gridrag_db.
Nice Work!
Nice Work!
You made it! Your local GridRAG agent now explains grid conditions using retrieved simulation history plus fresh solver results.
You've learned how to:
- Build a balanced AC power flow wrapper around case33bw(). It turns each load scale into solver-backed grid metrics.
- Create persistent vector memory in Qdrant using EmbeddingGemma. Similar historical simulations now return with similarity scores.
- Expose the ungrounded model problem with a zero-tool baseline. Build an Ollama agent loop that requires retrieval plus solver evidence before the final answer.
- Secret Mission: Extended the agent with compare_load_scenarios to store several solver runs. Its batch result ranks scenarios by minimum_voltage_pu.
Ready to quiz yourself?