Spaces:
Sleeping
Sleeping
James Afful
commited on
Commit
·
458fa79
1
Parent(s):
170ed78
Add Grid-Gent Space code and Dockerfile (no binary assets)
Browse files- .gitignore +2 -0
- CHANGELOG.md +25 -0
- Dockerfile +14 -0
- LICENSE +1 -0
- README.md +69 -12
- app/__init__.py +1 -0
- app/server.py +127 -0
- app/web/index.html +389 -0
- config/feeders.json +25 -0
- config/uploaded_feedermodel.json +11 -0
- gridgent/__init__.py +1 -0
- gridgent/agents.py +146 -0
- gridgent/agents/__init__.py +1 -0
- gridgent/agents/intent.py +103 -0
- gridgent/agents/narrator.py +102 -0
- gridgent/agents/planning.py +100 -0
- gridgent/core/__init__.py +1 -0
- gridgent/core/orchestrator.py +45 -0
- gridgent/core/types.py +33 -0
- gridgent/orchestrator.py +55 -0
- gridgent/tools.py +108 -0
- gridgent/tools/__init__.py +1 -0
- gridgent/tools/grid_stub.py +251 -0
- main.py +11 -0
- templates/index.html +209 -0
- tests/test_http_api.py +57 -0
- tests/test_intent_agent.py +26 -0
- tests/test_orchestrator.py +24 -0
- tests/test_tools.py +68 -0
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
All notable changes to this project will be documented in this file.
|
| 4 |
+
|
| 5 |
+
The format follows the guidelines of [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
| 6 |
+
and this project adheres to **Semantic Versioning**.
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## [1.0.0] – 11-20-2025
|
| 11 |
+
### Added
|
| 12 |
+
- First public, versioned release of **Grid-Gent**.
|
| 13 |
+
- Support for uploading custom feeder models (`.json` or `.csv`).
|
| 14 |
+
- Agentic scenario engine with:
|
| 15 |
+
- Unknown/smalltalk handling
|
| 16 |
+
- Conceptual explanation mode
|
| 17 |
+
- Scenario mode (`simulation`, `hosting_capacity`)
|
| 18 |
+
- Lightweight grid screening logic for early-stage planning.
|
| 19 |
+
- Local web UI (`http://localhost:8000`).
|
| 20 |
+
- Packaging structure for PyPI distribution.
|
| 21 |
+
- Initial example screenshot included in README.
|
| 22 |
+
- GitHub release notes and version tag `v1.0.0`.
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
Dockerfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Work inside /code
|
| 4 |
+
WORKDIR /code
|
| 5 |
+
|
| 6 |
+
# Copy everything into the image
|
| 7 |
+
COPY . .
|
| 8 |
+
|
| 9 |
+
# Expose the port Hugging Face expects for Docker Spaces (default 7860)
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
|
| 12 |
+
# Run Grid-Gent using your existing entrypoint.
|
| 13 |
+
# main.py reads GRID_GENT_PORT and passes it to run_server(...)
|
| 14 |
+
CMD ["sh", "-c", "GRID_GENT_PORT=${PORT:-7860} python main.py"]
|
LICENSE
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
README.md
CHANGED
|
@@ -1,12 +1,69 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Grid-Gent Demo (v4) – Uploadable Grid Models
|
| 2 |
+
|
| 3 |
+
Agentic-style assistant for city distribution grid scenarios with:
|
| 4 |
+
|
| 5 |
+
- Unknown/smalltalk handling (no fake scenarios for 'hi', 'why', etc.).
|
| 6 |
+
- Conceptual explanation mode when no feeder/MW is given.
|
| 7 |
+
- Scenario mode (simulation / hosting_capacity) for actual what-if questions.
|
| 8 |
+
- **New:** Upload your own grid model (JSON or CSV) and the demo will use it.
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+

|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
## Running
|
| 15 |
+
|
| 16 |
+
```bash
|
| 17 |
+
python main.py
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
Then open http://localhost:8000.
|
| 21 |
+
|
| 22 |
+
On the right side of the UI you can upload a `.json` or `.csv` file with feeder definitions.
|
| 23 |
+
The server will replace the built-in demo feeders with your uploaded ones (still using a simplified
|
| 24 |
+
calculation, not a full AC power flow).
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
## Background: Why Lightweight Grid Scenario Screening Tools Matter
|
| 28 |
+
|
| 29 |
+
### Can Utilities Benefit from Lightweight Tools for Early-Stage Grid Scenario Screening?
|
| 30 |
+
|
| 31 |
+
“Can this feeder handle 1.5 MW of new load?”
|
| 32 |
+
“What if we add a rooftop solar portfolio in this neighborhood?”
|
| 33 |
+
|
| 34 |
+
Utility planning engineers are asked variations of these questions constantly. They seem simple, but they come from developers, facility managers, municipalities, and internal stakeholders who simply want to understand what is possible on the distribution grid.
|
| 35 |
+
|
| 36 |
+
Today, many utilities still rely on the same sophisticated processes used for full-scale planning studies to answer these early-stage questions. It’s often like killing a fly with a missile. This workflow mismatch has quietly become one of the least-discussed bottlenecks in distribution planning, despite the wide range of modeling tools utilities already maintain.
|
| 37 |
+
|
| 38 |
+
Technologies driving the energy transition—EV charging clusters, distributed solar, data centers, campus loads—create a surge of exploratory inquiries long before a formal interconnection request is filed. This was predictable, and it highlights the need for a middle layer between “no analysis” and “full analysis.”
|
| 39 |
+
|
| 40 |
+
Lightweight, reasonably accurate early-stage scenario screening could help triage straightforward questions before escalating them to deeper engineering study, reducing unnecessary load on specialists.
|
| 41 |
+
|
| 42 |
+
### What Should This Early-Stage Screening Tool Look Like?
|
| 43 |
+
|
| 44 |
+
An effective early-stage screening tool should:
|
| 45 |
+
|
| 46 |
+
- Translate everyday language into structured parameters a planner can validate.
|
| 47 |
+
- Provide quick approximations of peak loading, hosting capacity, and voltage sensitivities.
|
| 48 |
+
- Be transparent—planners must be able to see and correct the assumptions.
|
| 49 |
+
- Avoid black-box behavior; interpretability is essential.
|
| 50 |
+
|
| 51 |
+
### The Risks of Ignoring This Problem
|
| 52 |
+
|
| 53 |
+
If every inquiry is treated as a full-scale study:
|
| 54 |
+
|
| 55 |
+
- Interconnection backlogs will continue to grow.
|
| 56 |
+
- Developers will lose confidence in utility response times.
|
| 57 |
+
- Electrification and modernization goals will keep slipping.
|
| 58 |
+
|
| 59 |
+
Ironically, many delays stem from tasks requiring structured reasoning, not deep physics.
|
| 60 |
+
|
| 61 |
+
### A Path Forward
|
| 62 |
+
|
| 63 |
+
Agentic Artificial Intelligence (Agentic AI) may serve as this missing early-stage screening layer. A well-designed agentic system can plan a task, reason through assumptions, execute the steps needed for an approximate assessment, and present the results transparently.
|
| 64 |
+
|
| 65 |
+
Grid-Gent (https://github.com/jamesafful/grid-gent) is one example of an experimental assistant for grid scenario exploration. It enables planners and developers to test multiple scenarios on a simplified feeder model, without invoking a full power flow engine.
|
| 66 |
+
|
| 67 |
+
### Conclusion
|
| 68 |
+
|
| 69 |
+
Given the accelerating pace of the energy transition, modern planning workflows need dependable, lightweight tools that complement—not replace—rigorous engineering. Early-stage screening can help utilities respond faster, reduce bottlenecks, and improve overall planning efficiency.
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# package
|
app/server.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 5 |
+
from urllib.parse import urlparse
|
| 6 |
+
from typing import Tuple
|
| 7 |
+
|
| 8 |
+
from gridgent.core.orchestrator import GridGentOrchestrator
|
| 9 |
+
from gridgent.tools.grid_stub import parse_uploaded_grid, save_uploaded_grid, get_all_feeders
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ORCHESTRATOR = GridGentOrchestrator()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _read_file(path: str) -> str:
|
| 16 |
+
here = os.path.dirname(os.path.abspath(__file__))
|
| 17 |
+
full = os.path.join(here, "web", path)
|
| 18 |
+
with open(full, "r", encoding="utf-8") as f:
|
| 19 |
+
return f.read()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class GridGentHandler(BaseHTTPRequestHandler):
|
| 23 |
+
def _set_common_headers(self, status: int = 200, content_type: str = "text/html; charset=utf-8"):
|
| 24 |
+
self.send_response(status)
|
| 25 |
+
self.send_header("Content-Type", content_type)
|
| 26 |
+
self.send_header("Access-Control-Allow-Origin", "*")
|
| 27 |
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
| 28 |
+
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
| 29 |
+
self.end_headers()
|
| 30 |
+
|
| 31 |
+
def log_message(self, format, *args):
|
| 32 |
+
return
|
| 33 |
+
|
| 34 |
+
def do_OPTIONS(self):
|
| 35 |
+
self._set_common_headers(200)
|
| 36 |
+
|
| 37 |
+
def do_GET(self):
|
| 38 |
+
parsed = urlparse(self.path)
|
| 39 |
+
if parsed.path in ("/", "/index.html"):
|
| 40 |
+
try:
|
| 41 |
+
html = _read_file("index.html")
|
| 42 |
+
self._set_common_headers(200, "text/html; charset=utf-8")
|
| 43 |
+
self.wfile.write(html.encode("utf-8"))
|
| 44 |
+
except FileNotFoundError:
|
| 45 |
+
self._set_common_headers(500, "text/plain; charset=utf-8")
|
| 46 |
+
self.wfile.write(b"index.html not found")
|
| 47 |
+
elif parsed.path == "/api/feeders":
|
| 48 |
+
data = get_all_feeders()
|
| 49 |
+
self._set_common_headers(200, "application/json; charset=utf-8")
|
| 50 |
+
self.wfile.write(json.dumps({"feeders": data}).encode("utf-8"))
|
| 51 |
+
else:
|
| 52 |
+
self._set_common_headers(404, "text/plain; charset=utf-8")
|
| 53 |
+
self.wfile.write(b"Not Found")
|
| 54 |
+
|
| 55 |
+
def _read_json(self) -> Tuple[bool, dict]:
|
| 56 |
+
length = int(self.headers.get("Content-Length") or "0")
|
| 57 |
+
try:
|
| 58 |
+
body = self.rfile.read(length).decode("utf-8")
|
| 59 |
+
data = json.loads(body)
|
| 60 |
+
return True, data
|
| 61 |
+
except Exception as exc:
|
| 62 |
+
return False, {"error": f"Invalid JSON body: {exc}"}
|
| 63 |
+
|
| 64 |
+
def do_POST(self):
|
| 65 |
+
parsed = urlparse(self.path)
|
| 66 |
+
if parsed.path == "/api/ask":
|
| 67 |
+
ok, data = self._read_json()
|
| 68 |
+
if not ok:
|
| 69 |
+
self._set_common_headers(400, "application/json; charset=utf-8")
|
| 70 |
+
self.wfile.write(json.dumps(data).encode("utf-8"))
|
| 71 |
+
return
|
| 72 |
+
|
| 73 |
+
query = str(data.get("query") or "").strip()
|
| 74 |
+
if not query:
|
| 75 |
+
self._set_common_headers(400, "application/json; charset=utf-8")
|
| 76 |
+
self.wfile.write(json.dumps({"error": "Missing 'query' in request body"}).encode("utf-8"))
|
| 77 |
+
return
|
| 78 |
+
|
| 79 |
+
result = ORCHESTRATOR.run(query)
|
| 80 |
+
resp = result.to_dict()
|
| 81 |
+
self._set_common_headers(200, "application/json; charset=utf-8")
|
| 82 |
+
self.wfile.write(json.dumps(resp).encode("utf-8"))
|
| 83 |
+
elif parsed.path == "/api/upload-grid":
|
| 84 |
+
ok, data = self._read_json()
|
| 85 |
+
if not ok:
|
| 86 |
+
self._set_common_headers(400, "application/json; charset=utf-8")
|
| 87 |
+
self.wfile.write(json.dumps(data).encode("utf-8"))
|
| 88 |
+
return
|
| 89 |
+
raw = data.get("raw")
|
| 90 |
+
fmt = data.get("format")
|
| 91 |
+
if not raw or not fmt:
|
| 92 |
+
self._set_common_headers(400, "application/json; charset=utf-8")
|
| 93 |
+
self.wfile.write(json.dumps({"error": "Missing 'raw' or 'format' in request body"}).encode("utf-8"))
|
| 94 |
+
return
|
| 95 |
+
try:
|
| 96 |
+
cfg = parse_uploaded_grid(str(raw), str(fmt))
|
| 97 |
+
save_uploaded_grid(cfg)
|
| 98 |
+
feeders = list(cfg.get("feeders", {}).keys())
|
| 99 |
+
self._set_common_headers(200, "application/json; charset=utf-8")
|
| 100 |
+
self.wfile.write(json.dumps({"status": "ok", "feeders_loaded": feeders}).encode("utf-8"))
|
| 101 |
+
except Exception as exc:
|
| 102 |
+
self._set_common_headers(400, "application/json; charset=utf-8")
|
| 103 |
+
self.wfile.write(json.dumps({"error": str(exc)}).encode("utf-8"))
|
| 104 |
+
else:
|
| 105 |
+
self._set_common_headers(404, "application/json; charset=utf-8")
|
| 106 |
+
self.wfile.write(json.dumps({"error": "Not Found"}).encode("utf-8"))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def run_server(host: str = "0.0.0.0", port: int = 8000):
|
| 110 |
+
server_address = (host, port)
|
| 111 |
+
httpd = ThreadingHTTPServer(server_address, GridGentHandler)
|
| 112 |
+
print(f"Grid-Gent demo server running at http://{host}:{port}")
|
| 113 |
+
try:
|
| 114 |
+
httpd.serve_forever()
|
| 115 |
+
except KeyboardInterrupt:
|
| 116 |
+
print("\nShutting down server...")
|
| 117 |
+
finally:
|
| 118 |
+
httpd.server_close()
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
if __name__ == "__main__":
|
| 122 |
+
port_str = os.environ.get("GRID_GENT_PORT", "8000")
|
| 123 |
+
try:
|
| 124 |
+
port = int(port_str)
|
| 125 |
+
except ValueError:
|
| 126 |
+
port = 8000
|
| 127 |
+
run_server(port=port)
|
app/web/index.html
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<title>Grid-Gent Demo</title>
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 10 |
+
background-color: #0f172a;
|
| 11 |
+
color: #e5e7eb;
|
| 12 |
+
}
|
| 13 |
+
body { margin: 0; padding: 0; }
|
| 14 |
+
.page { max-width: 1040px; margin: 0 auto; padding: 24px 16px 48px; }
|
| 15 |
+
.card {
|
| 16 |
+
background: #020617;
|
| 17 |
+
border-radius: 16px;
|
| 18 |
+
padding: 20px;
|
| 19 |
+
box-shadow: 0 18px 40px rgba(15,23,42,0.8);
|
| 20 |
+
border: 1px solid rgba(148, 163, 184, 0.2);
|
| 21 |
+
}
|
| 22 |
+
h1 { font-size: 1.8rem; margin-bottom: 0.25rem; }
|
| 23 |
+
h2 { font-size: 1.1rem; margin: 0; color: #9ca3af; }
|
| 24 |
+
textarea {
|
| 25 |
+
width: 100%;
|
| 26 |
+
min-height: 90px;
|
| 27 |
+
padding: 10px 12px;
|
| 28 |
+
border-radius: 10px;
|
| 29 |
+
border: 1px solid #4b5563;
|
| 30 |
+
background: #020617;
|
| 31 |
+
color: #e5e7eb;
|
| 32 |
+
resize: vertical;
|
| 33 |
+
font-family: inherit;
|
| 34 |
+
font-size: 0.95rem;
|
| 35 |
+
}
|
| 36 |
+
textarea:focus {
|
| 37 |
+
outline: none;
|
| 38 |
+
border-color: #38bdf8;
|
| 39 |
+
box-shadow: 0 0 0 1px #0ea5e9;
|
| 40 |
+
}
|
| 41 |
+
button {
|
| 42 |
+
margin-top: 10px;
|
| 43 |
+
padding: 9px 16px;
|
| 44 |
+
border-radius: 999px;
|
| 45 |
+
border: none;
|
| 46 |
+
background: linear-gradient(135deg, #0ea5e9, #22c55e);
|
| 47 |
+
color: white;
|
| 48 |
+
font-weight: 600;
|
| 49 |
+
cursor: pointer;
|
| 50 |
+
font-size: 0.95rem;
|
| 51 |
+
display: inline-flex;
|
| 52 |
+
align-items: center;
|
| 53 |
+
gap: 6px;
|
| 54 |
+
}
|
| 55 |
+
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 56 |
+
.answer {
|
| 57 |
+
margin-top: 18px;
|
| 58 |
+
white-space: pre-wrap;
|
| 59 |
+
background: #020617;
|
| 60 |
+
border-radius: 10px;
|
| 61 |
+
padding: 12px 14px;
|
| 62 |
+
border: 1px solid #4b5563;
|
| 63 |
+
font-size: 0.9rem;
|
| 64 |
+
}
|
| 65 |
+
.answer-badge {
|
| 66 |
+
display: inline-block;
|
| 67 |
+
font-size: 0.75rem;
|
| 68 |
+
padding: 3px 8px;
|
| 69 |
+
border-radius: 999px;
|
| 70 |
+
border: 1px solid rgba(248, 250, 252, 0.2);
|
| 71 |
+
background: rgba(127, 29, 29, 0.6);
|
| 72 |
+
color: #fecaca;
|
| 73 |
+
margin-bottom: 6px;
|
| 74 |
+
}
|
| 75 |
+
.steps { margin-top: 16px; font-size: 0.8rem; }
|
| 76 |
+
.step {
|
| 77 |
+
padding: 8px 10px;
|
| 78 |
+
border-radius: 8px;
|
| 79 |
+
border: 1px solid rgba(75,85,99,0.7);
|
| 80 |
+
background: rgba(15,23,42,0.8);
|
| 81 |
+
margin-bottom: 6px;
|
| 82 |
+
}
|
| 83 |
+
.step strong { color: #a5b4fc; }
|
| 84 |
+
.badge {
|
| 85 |
+
display: inline-block;
|
| 86 |
+
padding: 2px 7px;
|
| 87 |
+
border-radius: 999px;
|
| 88 |
+
font-size: 0.7rem;
|
| 89 |
+
background: rgba(15,118,110,0.3);
|
| 90 |
+
color: #a7f3d0;
|
| 91 |
+
margin-left: 6px;
|
| 92 |
+
}
|
| 93 |
+
.badge-secondary {
|
| 94 |
+
background: rgba(30,64,175,0.5);
|
| 95 |
+
color: #bfdbfe;
|
| 96 |
+
}
|
| 97 |
+
.header-row {
|
| 98 |
+
display: flex;
|
| 99 |
+
justify-content: space-between;
|
| 100 |
+
align-items: baseline;
|
| 101 |
+
gap: 12px;
|
| 102 |
+
flex-wrap: wrap;
|
| 103 |
+
}
|
| 104 |
+
.pill {
|
| 105 |
+
font-size: 0.75rem;
|
| 106 |
+
padding: 4px 10px;
|
| 107 |
+
border-radius: 999px;
|
| 108 |
+
border: 1px solid rgba(148,163,184,0.5);
|
| 109 |
+
color: #e5e7eb;
|
| 110 |
+
}
|
| 111 |
+
.examples {
|
| 112 |
+
margin-top: 10px;
|
| 113 |
+
font-size: 0.8rem;
|
| 114 |
+
color: #9ca3af;
|
| 115 |
+
}
|
| 116 |
+
.examples code {
|
| 117 |
+
background: rgba(15,23,42,0.8);
|
| 118 |
+
padding: 2px 6px;
|
| 119 |
+
border-radius: 6px;
|
| 120 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
| 121 |
+
}
|
| 122 |
+
.layout {
|
| 123 |
+
display: grid;
|
| 124 |
+
grid-template-columns: minmax(0, 2.2fr) minmax(0, 1.2fr);
|
| 125 |
+
gap: 18px;
|
| 126 |
+
margin-top: 18px;
|
| 127 |
+
}
|
| 128 |
+
@media (max-width: 900px) {
|
| 129 |
+
.layout { grid-template-columns: minmax(0, 1fr); }
|
| 130 |
+
}
|
| 131 |
+
.panel {
|
| 132 |
+
border-radius: 12px;
|
| 133 |
+
border: 1px solid rgba(148,163,184,0.25);
|
| 134 |
+
padding: 12px 12px 14px;
|
| 135 |
+
background: rgba(15,23,42,0.8);
|
| 136 |
+
}
|
| 137 |
+
.panel-title {
|
| 138 |
+
font-size: 0.9rem;
|
| 139 |
+
font-weight: 600;
|
| 140 |
+
margin-bottom: 6px;
|
| 141 |
+
}
|
| 142 |
+
.panel-sub {
|
| 143 |
+
font-size: 0.75rem;
|
| 144 |
+
color: #9ca3af;
|
| 145 |
+
margin-bottom: 6px;
|
| 146 |
+
}
|
| 147 |
+
input[type="file"] {
|
| 148 |
+
font-size: 0.8rem;
|
| 149 |
+
}
|
| 150 |
+
.status {
|
| 151 |
+
margin-top: 6px;
|
| 152 |
+
font-size: 0.75rem;
|
| 153 |
+
color: #9ca3af;
|
| 154 |
+
white-space: pre-wrap;
|
| 155 |
+
}
|
| 156 |
+
.feeders-list {
|
| 157 |
+
margin-top: 4px;
|
| 158 |
+
font-size: 0.78rem;
|
| 159 |
+
}
|
| 160 |
+
.feeders-list code {
|
| 161 |
+
background: rgba(15,23,42,0.8);
|
| 162 |
+
padding: 1px 5px;
|
| 163 |
+
border-radius: 6px;
|
| 164 |
+
}
|
| 165 |
+
</style>
|
| 166 |
+
</head>
|
| 167 |
+
<body>
|
| 168 |
+
<div class="page">
|
| 169 |
+
<div class="card">
|
| 170 |
+
<div class="header-row">
|
| 171 |
+
<div>
|
| 172 |
+
<h1>Grid-Gent Demo</h1>
|
| 173 |
+
<h2>Agentic assistant for city distribution grid scenarios (simplified)</h2>
|
| 174 |
+
</div>
|
| 175 |
+
<div class="pill">Offline demo · no real grid connection</div>
|
| 176 |
+
</div>
|
| 177 |
+
|
| 178 |
+
<div class="layout">
|
| 179 |
+
<div>
|
| 180 |
+
<div>
|
| 181 |
+
<label for="query">Describe a grid scenario or question:</label>
|
| 182 |
+
<textarea id="query" placeholder="Example: What happens on feeder F2 if we add 5 MW of rooftop PV?"></textarea>
|
| 183 |
+
<button id="ask-btn" onclick="sendQuery()">
|
| 184 |
+
<span id="btn-label">Run Grid-Gent</span>
|
| 185 |
+
<span id="btn-spinner" style="display:none;">⏳</span>
|
| 186 |
+
</button>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<div class="examples">
|
| 190 |
+
Try things like:
|
| 191 |
+
<div><code>What happens on feeder F2 if we add 5 MW of rooftop PV?</code></div>
|
| 192 |
+
<div><code>Simulate adding 3 MW of load on feeder F1.</code></div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
<div id="answer" class="answer" style="display:none;">
|
| 196 |
+
<div class="answer-badge">Demo only · not using your real grid data unless you upload a model · not for operational decisions</div>
|
| 197 |
+
<div id="answer-text"></div>
|
| 198 |
+
</div>
|
| 199 |
+
<div id="steps" class="steps" style="display:none;"></div>
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
<div>
|
| 203 |
+
<div class="panel">
|
| 204 |
+
<div class="panel-title">Upload grid model (JSON or CSV)</div>
|
| 205 |
+
<div class="panel-sub">
|
| 206 |
+
We will replace the demo feeders with your uploaded ones (still using a simplified calculation).
|
| 207 |
+
</div>
|
| 208 |
+
<input type="file" id="grid-file" accept=".json,.csv" />
|
| 209 |
+
<button id="upload-btn" style="margin-top:8px;" onclick="uploadGrid()">
|
| 210 |
+
<span id="upload-label">Upload model</span>
|
| 211 |
+
<span id="upload-spinner" style="display:none;">⏳</span>
|
| 212 |
+
</button>
|
| 213 |
+
<div id="upload-status" class="status"></div>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
<div class="panel" style="margin-top:10px;">
|
| 217 |
+
<div class="panel-title">Currently loaded feeders</div>
|
| 218 |
+
<div class="panel-sub">Based on demo data or your last upload.</div>
|
| 219 |
+
<div id="feeders" class="feeders-list">Loading...</div>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
<script>
|
| 227 |
+
async function refreshFeeders() {
|
| 228 |
+
const el = document.getElementById("feeders");
|
| 229 |
+
try {
|
| 230 |
+
const resp = await fetch("/api/feeders");
|
| 231 |
+
if (!resp.ok) {
|
| 232 |
+
el.textContent = "Error loading feeders.";
|
| 233 |
+
return;
|
| 234 |
+
}
|
| 235 |
+
const data = await resp.json();
|
| 236 |
+
const feeders = data.feeders || {};
|
| 237 |
+
const ids = Object.keys(feeders);
|
| 238 |
+
if (!ids.length) {
|
| 239 |
+
el.textContent = "No feeders configured.";
|
| 240 |
+
return;
|
| 241 |
+
}
|
| 242 |
+
const parts = ids.map(id => {
|
| 243 |
+
const f = feeders[id];
|
| 244 |
+
const name = f.name || id;
|
| 245 |
+
const peak = f.peak_mw;
|
| 246 |
+
return id + " – " + name + " (" + peak + " MW peak)";
|
| 247 |
+
});
|
| 248 |
+
el.innerHTML = parts.map(p => "<div><code>" + p + "</code></div>").join("");
|
| 249 |
+
} catch (e) {
|
| 250 |
+
el.textContent = "Error loading feeders: " + e;
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
async function sendQuery() {
|
| 255 |
+
const textarea = document.getElementById("query");
|
| 256 |
+
const btn = document.getElementById("ask-btn");
|
| 257 |
+
const label = document.getElementById("btn-label");
|
| 258 |
+
const spinner = document.getElementById("btn-spinner");
|
| 259 |
+
const answerBox = document.getElementById("answer");
|
| 260 |
+
const answerText = document.getElementById("answer-text");
|
| 261 |
+
const stepsEl = document.getElementById("steps");
|
| 262 |
+
|
| 263 |
+
const query = textarea.value.trim();
|
| 264 |
+
if (!query) {
|
| 265 |
+
alert("Please enter a scenario or question.");
|
| 266 |
+
return;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
btn.disabled = true;
|
| 270 |
+
spinner.style.display = "inline-block";
|
| 271 |
+
label.textContent = "Running...";
|
| 272 |
+
|
| 273 |
+
try {
|
| 274 |
+
const resp = await fetch("/api/ask", {
|
| 275 |
+
method: "POST",
|
| 276 |
+
headers: { "Content-Type": "application/json" },
|
| 277 |
+
body: JSON.stringify({ query })
|
| 278 |
+
});
|
| 279 |
+
const data = await resp.json();
|
| 280 |
+
|
| 281 |
+
if (!resp.ok) {
|
| 282 |
+
answerBox.style.display = "block";
|
| 283 |
+
answerText.textContent = "Error: " + (data.error || resp.statusText);
|
| 284 |
+
stepsEl.style.display = "none";
|
| 285 |
+
return;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
answerBox.style.display = "block";
|
| 289 |
+
answerText.textContent = data.answer || "(No answer returned)";
|
| 290 |
+
|
| 291 |
+
stepsEl.innerHTML = "";
|
| 292 |
+
if (Array.isArray(data.steps)) {
|
| 293 |
+
stepsEl.style.display = "block";
|
| 294 |
+
data.steps.forEach(step => {
|
| 295 |
+
const div = document.createElement("div");
|
| 296 |
+
div.className = "step";
|
| 297 |
+
const role = step.role || "agent";
|
| 298 |
+
const meta = step.meta || {};
|
| 299 |
+
const intent = meta.intent || "";
|
| 300 |
+
const feeder = meta.feeder || "";
|
| 301 |
+
let badgesHtml = "";
|
| 302 |
+
if (intent) {
|
| 303 |
+
badgesHtml += "<span class=\"badge\">" + intent + "</span>";
|
| 304 |
+
}
|
| 305 |
+
if (feeder) {
|
| 306 |
+
badgesHtml += "<span class=\"badge badge-secondary\">" + feeder + "</span>";
|
| 307 |
+
}
|
| 308 |
+
div.innerHTML = "<div><strong>" + role + "</strong> " + badgesHtml + "</div>" +
|
| 309 |
+
"<div>" + step.content + "</div>";
|
| 310 |
+
stepsEl.appendChild(div);
|
| 311 |
+
});
|
| 312 |
+
} else {
|
| 313 |
+
stepsEl.style.display = "none";
|
| 314 |
+
}
|
| 315 |
+
} catch (err) {
|
| 316 |
+
answerBox.style.display = "block";
|
| 317 |
+
answerText.textContent = "Request failed: " + err;
|
| 318 |
+
stepsEl.style.display = "none";
|
| 319 |
+
} finally {
|
| 320 |
+
btn.disabled = false;
|
| 321 |
+
spinner.style.display = "none";
|
| 322 |
+
label.textContent = "Run Grid-Gent";
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
async function uploadGrid() {
|
| 327 |
+
const input = document.getElementById("grid-file");
|
| 328 |
+
const status = document.getElementById("upload-status");
|
| 329 |
+
const btn = document.getElementById("upload-btn");
|
| 330 |
+
const label = document.getElementById("upload-label");
|
| 331 |
+
const spinner = document.getElementById("upload-spinner");
|
| 332 |
+
|
| 333 |
+
const file = input.files[0];
|
| 334 |
+
if (!file) {
|
| 335 |
+
alert("Please choose a JSON or CSV file first.");
|
| 336 |
+
return;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
let fmt = "json";
|
| 340 |
+
if (file.name.toLowerCase().endsWith(".csv")) {
|
| 341 |
+
fmt = "csv";
|
| 342 |
+
} else if (file.name.toLowerCase().endsWith(".json")) {
|
| 343 |
+
fmt = "json";
|
| 344 |
+
} else {
|
| 345 |
+
alert("File extension must be .json or .csv");
|
| 346 |
+
return;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
btn.disabled = true;
|
| 350 |
+
spinner.style.display = "inline-block";
|
| 351 |
+
label.textContent = "Uploading...";
|
| 352 |
+
|
| 353 |
+
const reader = new FileReader();
|
| 354 |
+
reader.onload = async () => {
|
| 355 |
+
const raw = reader.result;
|
| 356 |
+
try {
|
| 357 |
+
const resp = await fetch("/api/upload-grid", {
|
| 358 |
+
method: "POST",
|
| 359 |
+
headers: { "Content-Type": "application/json" },
|
| 360 |
+
body: JSON.stringify({ raw, format: fmt })
|
| 361 |
+
});
|
| 362 |
+
const data = await resp.json();
|
| 363 |
+
if (!resp.ok) {
|
| 364 |
+
status.textContent = "Upload failed: " + (data.error || resp.statusText);
|
| 365 |
+
} else {
|
| 366 |
+
status.textContent = "Upload succeeded. Feeders loaded: " + (data.feeders_loaded || []).join(", ");
|
| 367 |
+
refreshFeeders();
|
| 368 |
+
}
|
| 369 |
+
} catch (e) {
|
| 370 |
+
status.textContent = "Upload failed: " + e;
|
| 371 |
+
} finally {
|
| 372 |
+
btn.disabled = false;
|
| 373 |
+
spinner.style.display = "none";
|
| 374 |
+
label.textContent = "Upload model";
|
| 375 |
+
}
|
| 376 |
+
};
|
| 377 |
+
reader.onerror = () => {
|
| 378 |
+
status.textContent = "Could not read file.";
|
| 379 |
+
btn.disabled = false;
|
| 380 |
+
spinner.style.display = "none";
|
| 381 |
+
label.textContent = "Upload model";
|
| 382 |
+
};
|
| 383 |
+
reader.readAsText(file);
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
refreshFeeders();
|
| 387 |
+
</script>
|
| 388 |
+
</body>
|
| 389 |
+
</html>
|
config/feeders.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"feeders": {
|
| 3 |
+
"F1": {
|
| 4 |
+
"name": "Feeder F1 - Downtown Core",
|
| 5 |
+
"base_kv": 13.8,
|
| 6 |
+
"num_customers": 4200,
|
| 7 |
+
"peak_mw": 18.5,
|
| 8 |
+
"pv_mw": 3.2
|
| 9 |
+
},
|
| 10 |
+
"F2": {
|
| 11 |
+
"name": "Feeder F2 - Residential West",
|
| 12 |
+
"base_kv": 13.8,
|
| 13 |
+
"num_customers": 5100,
|
| 14 |
+
"peak_mw": 14.3,
|
| 15 |
+
"pv_mw": 4.7
|
| 16 |
+
},
|
| 17 |
+
"F3": {
|
| 18 |
+
"name": "Feeder F3 - Industrial Park",
|
| 19 |
+
"base_kv": 13.8,
|
| 20 |
+
"num_customers": 830,
|
| 21 |
+
"peak_mw": 22.1,
|
| 22 |
+
"pv_mw": 0.8
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
}
|
config/uploaded_feedermodel.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"feeders": {
|
| 3 |
+
"Q1": {
|
| 4 |
+
"name": "Feeder Q1",
|
| 5 |
+
"base_kv": 13.8,
|
| 6 |
+
"num_customers": 100,
|
| 7 |
+
"peak_mw": 5.0,
|
| 8 |
+
"pv_mw": 0.5
|
| 9 |
+
}
|
| 10 |
+
}
|
| 11 |
+
}
|
gridgent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# package
|
gridgent/agents.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Dict, Any, List, Literal, Tuple
|
| 5 |
+
from . import tools
|
| 6 |
+
|
| 7 |
+
StepRole = Literal["user", "intent_agent", "planning_agent", "narrator_agent", "tool"]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class Step:
|
| 12 |
+
role: StepRole
|
| 13 |
+
content: str
|
| 14 |
+
meta: Dict[str, Any]
|
| 15 |
+
|
| 16 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 17 |
+
return {
|
| 18 |
+
"role": self.role,
|
| 19 |
+
"content": self.content,
|
| 20 |
+
"meta": self.meta,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class IntentAgent:
|
| 25 |
+
def classify(self, query: str) -> Dict[str, Any]:
|
| 26 |
+
text = query.lower()
|
| 27 |
+
if any(k in text for k in ["what if", "simulate", "scenario", "contingency", "impact of"]):
|
| 28 |
+
intent = "simulation"
|
| 29 |
+
elif any(k in text for k in ["host", "hosting capacity", "add pv", "add ev", "der"]):
|
| 30 |
+
intent = "hosting_capacity"
|
| 31 |
+
elif any(k in text for k in ["explain", "why", "how does"]):
|
| 32 |
+
intent = "explanation"
|
| 33 |
+
else:
|
| 34 |
+
intent = "simulation"
|
| 35 |
+
|
| 36 |
+
feeder = "F1"
|
| 37 |
+
if "f2" in text or "feeder 2" in text:
|
| 38 |
+
feeder = "F2"
|
| 39 |
+
elif "f3" in text or "feeder 3" in text:
|
| 40 |
+
feeder = "F3"
|
| 41 |
+
|
| 42 |
+
added_pv = 0.0
|
| 43 |
+
added_load = 0.0
|
| 44 |
+
import re
|
| 45 |
+
|
| 46 |
+
mw_matches = re.findall(r"(\d+(?:\.\d+)?)\s*mw", text)
|
| 47 |
+
if mw_matches:
|
| 48 |
+
value = float(mw_matches[0])
|
| 49 |
+
if "pv" in text or "solar" in text:
|
| 50 |
+
added_pv = value
|
| 51 |
+
else:
|
| 52 |
+
added_load = value
|
| 53 |
+
|
| 54 |
+
return {
|
| 55 |
+
"intent": intent,
|
| 56 |
+
"feeder": feeder,
|
| 57 |
+
"added_pv_mw": added_pv,
|
| 58 |
+
"added_load_mw": added_load,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class PlanningAgent:
|
| 63 |
+
def plan_and_analyze(self, query: str, intent_info: Dict[str, Any]) -> Tuple[str, Dict[str, Any], List[Step]]:
|
| 64 |
+
feeder = intent_info["feeder"]
|
| 65 |
+
added_pv = float(intent_info.get("added_pv_mw", 0.0))
|
| 66 |
+
added_load = float(intent_info.get("added_load_mw", 0.0))
|
| 67 |
+
|
| 68 |
+
steps: List[Step] = []
|
| 69 |
+
summary = (
|
| 70 |
+
f"Analyzing feeder {feeder} with added PV={added_pv:.1f} MW, "
|
| 71 |
+
f"added load={added_load:.1f} MW using a simplified power-flow stub."
|
| 72 |
+
)
|
| 73 |
+
steps.append(Step(role="planning_agent", content=summary, meta={"feeder": feeder}))
|
| 74 |
+
|
| 75 |
+
pf_result = tools.run_power_flow_scenario(feeder, added_pv_mw=added_pv, added_load_mw=added_load)
|
| 76 |
+
steps.append(
|
| 77 |
+
Step(
|
| 78 |
+
role="tool",
|
| 79 |
+
content="Ran simplified power-flow scenario.",
|
| 80 |
+
meta=pf_result.to_dict(),
|
| 81 |
+
)
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
feeder_meta = tools.get_feeder_summary(feeder)
|
| 85 |
+
steps.append(
|
| 86 |
+
Step(
|
| 87 |
+
role="tool",
|
| 88 |
+
content="Retrieved static feeder metadata.",
|
| 89 |
+
meta=feeder_meta,
|
| 90 |
+
)
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
technical_summary = {
|
| 94 |
+
"intent": intent_info["intent"],
|
| 95 |
+
"feeder": feeder,
|
| 96 |
+
"added_pv_mw": added_pv,
|
| 97 |
+
"added_load_mw": added_load,
|
| 98 |
+
"power_flow": pf_result.to_dict(),
|
| 99 |
+
"feeder_meta": feeder_meta,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
return "ok", technical_summary, steps
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class NarratorAgent:
|
| 106 |
+
def narrate(self, query: str, technical: Dict[str, Any]) -> str:
|
| 107 |
+
pf = technical["power_flow"]
|
| 108 |
+
meta = technical["feeder_meta"]
|
| 109 |
+
|
| 110 |
+
lines: List[str] = []
|
| 111 |
+
lines.append(f"You asked: {query.strip()}")
|
| 112 |
+
lines.append("")
|
| 113 |
+
lines.append(f"Here's what Grid-Gent found for {meta['name']}:")
|
| 114 |
+
lines.append(
|
| 115 |
+
f"- Base peak demand (demo data): {meta['peak_mw']} MW with about {meta['num_customers']} customers."
|
| 116 |
+
)
|
| 117 |
+
lines.append(
|
| 118 |
+
f"- In this scenario, we assumed +{technical['added_load_mw']:.1f} MW of extra load and "
|
| 119 |
+
f"+{technical['added_pv_mw']:.1f} MW of additional PV."
|
| 120 |
+
)
|
| 121 |
+
lines.append("")
|
| 122 |
+
lines.append("Simplified power-flow-style results (demo model):")
|
| 123 |
+
lines.append(f"- Peak loading: {pf['peak_loading_pct']:.1f}% of an approximate rating.")
|
| 124 |
+
lines.append(f"- Minimum voltage: {pf['min_voltage_pu']:.3f} pu")
|
| 125 |
+
lines.append(f"- Maximum voltage: {pf['max_voltage_pu']:.3f} pu")
|
| 126 |
+
|
| 127 |
+
if pf["overload_elements"]:
|
| 128 |
+
lines.append("")
|
| 129 |
+
lines.append("Potential issues flagged:")
|
| 130 |
+
for item in pf["overload_elements"]:
|
| 131 |
+
lines.append(f" • {item}")
|
| 132 |
+
else:
|
| 133 |
+
lines.append("")
|
| 134 |
+
lines.append("No major issues were flagged in this simplified view.")
|
| 135 |
+
|
| 136 |
+
lines.append("")
|
| 137 |
+
lines.append(pf["notes"])
|
| 138 |
+
|
| 139 |
+
lines.append("")
|
| 140 |
+
lines.append(
|
| 141 |
+
"Note: this is a deliberately simplified demo model. A real deployment would "
|
| 142 |
+
"use your actual network model, load/DER data, and planning criteria, and would "
|
| 143 |
+
"treat these results as advisory, subject to engineer review."
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
return "\n".join(lines)
|
gridgent/agents/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# package
|
gridgent/agents/intent.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class IntentAgent:
|
| 7 |
+
"""Deterministic intent classifier for demo."""
|
| 8 |
+
|
| 9 |
+
def classify(self, query: str) -> Dict[str, Any]:
|
| 10 |
+
text = (query or "").lower().strip()
|
| 11 |
+
|
| 12 |
+
smalltalk_tokens = {
|
| 13 |
+
"hi",
|
| 14 |
+
"hello",
|
| 15 |
+
"hey",
|
| 16 |
+
"yo",
|
| 17 |
+
"thanks",
|
| 18 |
+
"thank you",
|
| 19 |
+
"thx",
|
| 20 |
+
"ok",
|
| 21 |
+
"okay",
|
| 22 |
+
"k",
|
| 23 |
+
}
|
| 24 |
+
if text in smalltalk_tokens or text in {"why", "?", "??"} or len(text) <= 2:
|
| 25 |
+
return {
|
| 26 |
+
"intent": "unknown",
|
| 27 |
+
"feeder": None,
|
| 28 |
+
"added_pv_mw": 0.0,
|
| 29 |
+
"added_load_mw": 0.0,
|
| 30 |
+
"has_mw": False,
|
| 31 |
+
"has_feeder": False,
|
| 32 |
+
"has_grid_keywords": False,
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
has_mw = bool(re.search(r"(\d+(?:\.\d+)?)\s*mw", text))
|
| 36 |
+
mentions_feeder = any(
|
| 37 |
+
tok in text for tok in ["feeder f1", "feeder f2", "feeder f3", "f1", "f2", "f3", "feeder"]
|
| 38 |
+
)
|
| 39 |
+
grid_keywords = any(
|
| 40 |
+
k in text
|
| 41 |
+
for k in [
|
| 42 |
+
"load",
|
| 43 |
+
"pv",
|
| 44 |
+
"solar",
|
| 45 |
+
"rooftop",
|
| 46 |
+
"substation",
|
| 47 |
+
"transformer",
|
| 48 |
+
"grid",
|
| 49 |
+
"voltage",
|
| 50 |
+
"hosting capacity",
|
| 51 |
+
"hosting",
|
| 52 |
+
"scenario",
|
| 53 |
+
"contingency",
|
| 54 |
+
]
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
if not (has_mw or mentions_feeder or grid_keywords):
|
| 58 |
+
return {
|
| 59 |
+
"intent": "unknown",
|
| 60 |
+
"feeder": None,
|
| 61 |
+
"added_pv_mw": 0.0,
|
| 62 |
+
"added_load_mw": 0.0,
|
| 63 |
+
"has_mw": False,
|
| 64 |
+
"has_feeder": False,
|
| 65 |
+
"has_grid_keywords": False,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if any(k in text for k in ["host", "hosting capacity", "add pv", "rooftop pv", "solar"]):
|
| 69 |
+
intent = "hosting_capacity"
|
| 70 |
+
elif any(k in text for k in ["explain", "how does"]):
|
| 71 |
+
intent = "explanation"
|
| 72 |
+
elif "why" in text:
|
| 73 |
+
intent = "explanation"
|
| 74 |
+
else:
|
| 75 |
+
intent = "simulation"
|
| 76 |
+
|
| 77 |
+
feeder = None
|
| 78 |
+
if "feeder f2" in text or "feeder 2" in text or "f2" in text:
|
| 79 |
+
feeder = "F2"
|
| 80 |
+
elif "feeder f3" in text or "feeder 3" in text or "f3" in text:
|
| 81 |
+
feeder = "F3"
|
| 82 |
+
elif "feeder f1" in text or "feeder 1" in text or "f1" in text:
|
| 83 |
+
feeder = "F1"
|
| 84 |
+
|
| 85 |
+
added_pv = 0.0
|
| 86 |
+
added_load = 0.0
|
| 87 |
+
mw_matches = re.findall(r"(\d+(?:\.\d+)?)\s*mw", text)
|
| 88 |
+
if mw_matches:
|
| 89 |
+
value = float(mw_matches[0])
|
| 90 |
+
if "pv" in text or "solar" in text or "rooftop" in text:
|
| 91 |
+
added_pv = value
|
| 92 |
+
else:
|
| 93 |
+
added_load = value
|
| 94 |
+
|
| 95 |
+
return {
|
| 96 |
+
"intent": intent,
|
| 97 |
+
"feeder": feeder,
|
| 98 |
+
"added_pv_mw": added_pv,
|
| 99 |
+
"added_load_mw": added_load,
|
| 100 |
+
"has_mw": has_mw,
|
| 101 |
+
"has_feeder": feeder is not None,
|
| 102 |
+
"has_grid_keywords": grid_keywords,
|
| 103 |
+
}
|
gridgent/agents/narrator.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class NarratorAgent:
|
| 6 |
+
def narrate(self, query: str, technical: Dict[str, Any]) -> str:
|
| 7 |
+
intent = technical.get("intent", "simulation")
|
| 8 |
+
|
| 9 |
+
if intent == "unknown":
|
| 10 |
+
msg = technical.get(
|
| 11 |
+
"message",
|
| 12 |
+
"I couldn't recognize a specific grid scenario in your question.",
|
| 13 |
+
)
|
| 14 |
+
lines: List[str] = []
|
| 15 |
+
lines.append(f"You asked: {query.strip()}")
|
| 16 |
+
lines.append("")
|
| 17 |
+
lines.append("I didn't see enough detail to run a grid scenario.")
|
| 18 |
+
lines.append(msg)
|
| 19 |
+
lines.append("")
|
| 20 |
+
lines.append("Try asking something like:")
|
| 21 |
+
lines.append("- What happens on feeder F2 if we add 5 MW of rooftop PV?")
|
| 22 |
+
lines.append("- Simulate adding 3 MW of load on feeder F1.")
|
| 23 |
+
lines.append("- What is the impact on voltages if load grows by 2 MW on feeder F3?")
|
| 24 |
+
return "\n".join(lines)
|
| 25 |
+
|
| 26 |
+
if intent == "explanation" and "power_flow" not in technical:
|
| 27 |
+
topic = technical.get("topic_hint", "").lower()
|
| 28 |
+
lines = []
|
| 29 |
+
lines.append(f"You asked: {query.strip()}")
|
| 30 |
+
lines.append("")
|
| 31 |
+
lines.append("This looks like a high-level explanation request rather than a specific feeder scenario.")
|
| 32 |
+
lines.append(
|
| 33 |
+
"Grid-Gent is a demo of an agentic assistant for distribution grids. It can explore simplified "
|
| 34 |
+
"scenarios like adding MW of load or PV on a feeder and report approximate loading and voltage impacts."
|
| 35 |
+
)
|
| 36 |
+
if "hosting" in topic or "capacity" in topic:
|
| 37 |
+
lines.append("")
|
| 38 |
+
lines.append(
|
| 39 |
+
"In real systems, 'hosting capacity' refers to how much additional DER (like rooftop PV) can be "
|
| 40 |
+
"connected without violating voltage, thermal, protection, or power quality limits. "
|
| 41 |
+
"Utilities usually study this using detailed feeder models and time-series simulations."
|
| 42 |
+
)
|
| 43 |
+
lines.append("")
|
| 44 |
+
lines.append("To see the demo in action, try asking things like:")
|
| 45 |
+
lines.append("- What happens on feeder F2 if we add 5 MW of rooftop PV?")
|
| 46 |
+
lines.append("- Simulate adding 3 MW of load on feeder F1.")
|
| 47 |
+
return "\n".join(lines)
|
| 48 |
+
|
| 49 |
+
pf = technical["power_flow"]
|
| 50 |
+
meta = technical["feeder_meta"]
|
| 51 |
+
loading_margin = float(technical.get("loading_margin_pct", 0.0))
|
| 52 |
+
|
| 53 |
+
lines: List[str] = []
|
| 54 |
+
lines.append(f"You asked: {query.strip()}")
|
| 55 |
+
lines.append("")
|
| 56 |
+
lines.append(f"Here's what Grid-Gent found for {meta['name']}:")
|
| 57 |
+
lines.append(
|
| 58 |
+
f"- Base peak demand (demo data): {meta['peak_mw']} MW with about {meta['num_customers']} customers."
|
| 59 |
+
)
|
| 60 |
+
lines.append(
|
| 61 |
+
f"- In this scenario, we assumed +{technical['added_load_mw']:.1f} MW of extra load and "
|
| 62 |
+
f"+{technical['added_pv_mw']:.1f} MW of additional PV."
|
| 63 |
+
)
|
| 64 |
+
lines.append("")
|
| 65 |
+
lines.append("Simplified power-flow-style results (demo model):")
|
| 66 |
+
lines.append(f"- Peak loading: {pf['peak_loading_pct']:.1f}% of an approximate rating.")
|
| 67 |
+
lines.append(f"- Loading margin to 100% (approx): {loading_margin:.1f}% points.")
|
| 68 |
+
lines.append(f"- Minimum voltage: {pf['min_voltage_pu']:.3f} pu")
|
| 69 |
+
lines.append(f"- Maximum voltage: {pf['max_voltage_pu']:.3f} pu")
|
| 70 |
+
|
| 71 |
+
if pf["overload_elements"]:
|
| 72 |
+
lines.append("")
|
| 73 |
+
lines.append("Potential issues flagged in this simplified view:")
|
| 74 |
+
for item in pf["overload_elements"]:
|
| 75 |
+
lines.append(f" • {item}")
|
| 76 |
+
else:
|
| 77 |
+
lines.append("")
|
| 78 |
+
lines.append("No major issues were flagged in this simplified view.")
|
| 79 |
+
|
| 80 |
+
if intent == "hosting_capacity":
|
| 81 |
+
lines.append("")
|
| 82 |
+
lines.append(
|
| 83 |
+
"Hosting capacity interpretation (demo-only): In this toy model, we look at loading and "
|
| 84 |
+
"voltage margins as a proxy for how much additional PV the feeder might host. "
|
| 85 |
+
"Here, the approximate loading and voltage range suggest whether the added PV appears acceptable "
|
| 86 |
+
"in this simplified analysis. Real hosting capacity studies must use detailed feeder models, "
|
| 87 |
+
"time-series behavior, and utility planning criteria, and are typically performed by power "
|
| 88 |
+
"system engineers."
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
lines.append("")
|
| 92 |
+
lines.append(pf["notes"])
|
| 93 |
+
|
| 94 |
+
lines.append("")
|
| 95 |
+
lines.append(
|
| 96 |
+
"Important: This is a deliberately simplified demonstration model. A real deployment would "
|
| 97 |
+
"use your actual network model, load/DER data, and planning criteria, and would treat all outputs "
|
| 98 |
+
"as advisory, subject to engineer review and formal studies. Do not use this demo for operational "
|
| 99 |
+
"or investment decisions."
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
return "\n".join(lines)
|
gridgent/agents/planning.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from typing import Dict, Any, List, Tuple
|
| 3 |
+
|
| 4 |
+
from gridgent.core.types import Step
|
| 5 |
+
from gridgent.tools.grid_stub import run_power_flow_scenario, get_feeder_summary
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class PlanningAgent:
|
| 9 |
+
def plan_and_analyze(self, query: str, intent_info: Dict[str, Any]) -> Tuple[str, Dict[str, Any], List[Step]]:
|
| 10 |
+
intent = intent_info["intent"]
|
| 11 |
+
steps: List[Step] = []
|
| 12 |
+
|
| 13 |
+
if intent == "unknown":
|
| 14 |
+
msg = (
|
| 15 |
+
"Received an underspecified or conversational query; no grid scenario was run. "
|
| 16 |
+
"Please mention at least a feeder (F1/F2/F3) and a change in MW load or PV."
|
| 17 |
+
)
|
| 18 |
+
steps.append(
|
| 19 |
+
Step(
|
| 20 |
+
role="planning_agent",
|
| 21 |
+
content=msg,
|
| 22 |
+
meta={"intent": intent},
|
| 23 |
+
)
|
| 24 |
+
)
|
| 25 |
+
technical_summary: Dict[str, Any] = {
|
| 26 |
+
"intent": intent,
|
| 27 |
+
"message": msg,
|
| 28 |
+
}
|
| 29 |
+
return "no_scenario", technical_summary, steps
|
| 30 |
+
|
| 31 |
+
has_mw = bool(intent_info.get("has_mw"))
|
| 32 |
+
has_feeder = bool(intent_info.get("has_feeder"))
|
| 33 |
+
|
| 34 |
+
if intent == "explanation" and not has_mw and not has_feeder:
|
| 35 |
+
steps.append(
|
| 36 |
+
Step(
|
| 37 |
+
role="planning_agent",
|
| 38 |
+
content="No specific feeder or MW change detected; treating as conceptual explanation request.",
|
| 39 |
+
meta={"intent": intent},
|
| 40 |
+
)
|
| 41 |
+
)
|
| 42 |
+
technical_summary = {
|
| 43 |
+
"intent": intent,
|
| 44 |
+
"topic_hint": query.strip(),
|
| 45 |
+
}
|
| 46 |
+
return "conceptual", technical_summary, steps
|
| 47 |
+
|
| 48 |
+
feeder = intent_info.get("feeder")
|
| 49 |
+
defaulted_feeder = False
|
| 50 |
+
if not feeder:
|
| 51 |
+
feeder = "F1"
|
| 52 |
+
defaulted_feeder = True
|
| 53 |
+
|
| 54 |
+
added_pv = float(intent_info.get("added_pv_mw", 0.0))
|
| 55 |
+
added_load = float(intent_info.get("added_load_mw", 0.0))
|
| 56 |
+
|
| 57 |
+
summary = (
|
| 58 |
+
f"Analyzing feeder {feeder} with added PV={added_pv:.1f} MW, "
|
| 59 |
+
f"added load={added_load:.1f} MW using a simplified power-flow stub."
|
| 60 |
+
)
|
| 61 |
+
steps.append(
|
| 62 |
+
Step(
|
| 63 |
+
role="planning_agent",
|
| 64 |
+
content=summary,
|
| 65 |
+
meta={"feeder": feeder, "defaulted_feeder": defaulted_feeder},
|
| 66 |
+
)
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
pf_result = run_power_flow_scenario(feeder, added_pv_mw=added_pv, added_load_mw=added_load)
|
| 70 |
+
pf_dict = pf_result.to_dict()
|
| 71 |
+
steps.append(
|
| 72 |
+
Step(
|
| 73 |
+
role="tool",
|
| 74 |
+
content="Ran simplified power-flow scenario (demo).",
|
| 75 |
+
meta=pf_dict,
|
| 76 |
+
)
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
feeder_meta = get_feeder_summary(feeder)
|
| 80 |
+
steps.append(
|
| 81 |
+
Step(
|
| 82 |
+
role="tool",
|
| 83 |
+
content="Retrieved static feeder metadata from config.",
|
| 84 |
+
meta=feeder_meta,
|
| 85 |
+
)
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
technical_summary: Dict[str, Any] = {
|
| 89 |
+
"intent": intent,
|
| 90 |
+
"feeder": feeder,
|
| 91 |
+
"added_pv_mw": added_pv,
|
| 92 |
+
"added_load_mw": added_load,
|
| 93 |
+
"power_flow": pf_dict,
|
| 94 |
+
"feeder_meta": feeder_meta,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
rating_pct = pf_dict["peak_loading_pct"]
|
| 98 |
+
technical_summary["loading_margin_pct"] = max(0.0, 100.0 - rating_pct)
|
| 99 |
+
|
| 100 |
+
return "ok", technical_summary, steps
|
gridgent/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# package
|
gridgent/core/orchestrator.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import uuid
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
from gridgent.core.types import Step, OrchestratorResult
|
| 6 |
+
from gridgent.agents.intent import IntentAgent
|
| 7 |
+
from gridgent.agents.planning import PlanningAgent
|
| 8 |
+
from gridgent.agents.narrator import NarratorAgent
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class GridGentOrchestrator:
|
| 12 |
+
def __init__(self) -> None:
|
| 13 |
+
self.intent_agent = IntentAgent()
|
| 14 |
+
self.planning_agent = PlanningAgent()
|
| 15 |
+
self.narrator_agent = NarratorAgent()
|
| 16 |
+
|
| 17 |
+
def run(self, query: str) -> OrchestratorResult:
|
| 18 |
+
task_id = str(uuid.uuid4())
|
| 19 |
+
steps: List[Step] = []
|
| 20 |
+
|
| 21 |
+
intent_info = self.intent_agent.classify(query)
|
| 22 |
+
steps.append(
|
| 23 |
+
Step(
|
| 24 |
+
role="intent_agent",
|
| 25 |
+
content=(
|
| 26 |
+
f"Classified intent as '{intent_info['intent']}'"
|
| 27 |
+
+ (f" and selected feeder {intent_info['feeder']}." if intent_info.get("feeder") else ".")
|
| 28 |
+
),
|
| 29 |
+
meta=intent_info,
|
| 30 |
+
)
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
status, technical_summary, planning_steps = self.planning_agent.plan_and_analyze(query, intent_info)
|
| 34 |
+
steps.extend(planning_steps)
|
| 35 |
+
|
| 36 |
+
answer = self.narrator_agent.narrate(query, technical_summary)
|
| 37 |
+
steps.append(
|
| 38 |
+
Step(
|
| 39 |
+
role="narrator_agent",
|
| 40 |
+
content="Generated human-readable explanation for planner/operator.",
|
| 41 |
+
meta={"status": status},
|
| 42 |
+
)
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
return OrchestratorResult(task_id=task_id, answer=answer, steps=steps)
|
gridgent/core/types.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import Dict, Any, List, Literal
|
| 4 |
+
|
| 5 |
+
StepRole = Literal["user", "intent_agent", "planning_agent", "narrator_agent", "tool"]
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass
|
| 9 |
+
class Step:
|
| 10 |
+
role: StepRole
|
| 11 |
+
content: str
|
| 12 |
+
meta: Dict[str, Any]
|
| 13 |
+
|
| 14 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 15 |
+
return {
|
| 16 |
+
"role": self.role,
|
| 17 |
+
"content": self.content,
|
| 18 |
+
"meta": self.meta,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class OrchestratorResult:
|
| 24 |
+
task_id: str
|
| 25 |
+
answer: str
|
| 26 |
+
steps: List[Step]
|
| 27 |
+
|
| 28 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 29 |
+
return {
|
| 30 |
+
"task_id": self.task_id,
|
| 31 |
+
"answer": self.answer,
|
| 32 |
+
"steps": [s.to_dict() for s in self.steps],
|
| 33 |
+
}
|
gridgent/orchestrator.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import uuid
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import List, Dict, Any
|
| 6 |
+
|
| 7 |
+
from .agents import IntentAgent, PlanningAgent, NarratorAgent, Step
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class OrchestratorResult:
|
| 12 |
+
task_id: str
|
| 13 |
+
answer: str
|
| 14 |
+
steps: List[Step]
|
| 15 |
+
|
| 16 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 17 |
+
return {
|
| 18 |
+
"task_id": self.task_id,
|
| 19 |
+
"answer": self.answer,
|
| 20 |
+
"steps": [s.to_dict() for s in self.steps],
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class GridGentOrchestrator:
|
| 25 |
+
def __init__(self) -> None:
|
| 26 |
+
self.intent_agent = IntentAgent()
|
| 27 |
+
self.planning_agent = PlanningAgent()
|
| 28 |
+
self.narrator_agent = NarratorAgent()
|
| 29 |
+
|
| 30 |
+
def run(self, query: str) -> OrchestratorResult:
|
| 31 |
+
task_id = str(uuid.uuid4())
|
| 32 |
+
steps: List[Step] = []
|
| 33 |
+
|
| 34 |
+
intent_info = self.intent_agent.classify(query)
|
| 35 |
+
steps.append(
|
| 36 |
+
Step(
|
| 37 |
+
role="intent_agent",
|
| 38 |
+
content=f"Classified intent as '{intent_info['intent']}' and selected feeder {intent_info['feeder']}.",
|
| 39 |
+
meta=intent_info,
|
| 40 |
+
)
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
status, technical_summary, planning_steps = self.planning_agent.plan_and_analyze(query, intent_info)
|
| 44 |
+
steps.extend(planning_steps)
|
| 45 |
+
|
| 46 |
+
answer = self.narrator_agent.narrate(query, technical_summary)
|
| 47 |
+
steps.append(
|
| 48 |
+
Step(
|
| 49 |
+
role="narrator_agent",
|
| 50 |
+
content="Generated human-readable explanation for operator/planner.",
|
| 51 |
+
meta={},
|
| 52 |
+
)
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
return OrchestratorResult(task_id=task_id, answer=answer, steps=steps)
|
gridgent/tools.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Dict, Any, List
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class PowerFlowResult:
|
| 9 |
+
feeder: str
|
| 10 |
+
peak_loading_pct: float
|
| 11 |
+
min_voltage_pu: float
|
| 12 |
+
max_voltage_pu: float
|
| 13 |
+
overload_elements: List[str]
|
| 14 |
+
notes: str
|
| 15 |
+
|
| 16 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 17 |
+
return {
|
| 18 |
+
"feeder": self.feeder,
|
| 19 |
+
"peak_loading_pct": round(self.peak_loading_pct, 1),
|
| 20 |
+
"min_voltage_pu": round(self.min_voltage_pu, 3),
|
| 21 |
+
"max_voltage_pu": round(self.max_voltage_pu, 3),
|
| 22 |
+
"overload_elements": self.overload_elements,
|
| 23 |
+
"notes": self.notes,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_feeder_summary(feeder: str) -> Dict[str, Any]:
|
| 28 |
+
feeder = feeder.upper().strip()
|
| 29 |
+
data = {
|
| 30 |
+
"F1": {
|
| 31 |
+
"name": "Feeder F1 - Downtown Core",
|
| 32 |
+
"base_kv": 13.8,
|
| 33 |
+
"num_customers": 4200,
|
| 34 |
+
"peak_mw": 18.5,
|
| 35 |
+
"pv_mw": 3.2,
|
| 36 |
+
},
|
| 37 |
+
"F2": {
|
| 38 |
+
"name": "Feeder F2 - Residential West",
|
| 39 |
+
"base_kv": 13.8,
|
| 40 |
+
"num_customers": 5100,
|
| 41 |
+
"peak_mw": 14.3,
|
| 42 |
+
"pv_mw": 4.7,
|
| 43 |
+
},
|
| 44 |
+
"F3": {
|
| 45 |
+
"name": "Feeder F3 - Industrial Park",
|
| 46 |
+
"base_kv": 13.8,
|
| 47 |
+
"num_customers": 830,
|
| 48 |
+
"peak_mw": 22.1,
|
| 49 |
+
"pv_mw": 0.8,
|
| 50 |
+
},
|
| 51 |
+
}
|
| 52 |
+
return data.get(
|
| 53 |
+
feeder,
|
| 54 |
+
{
|
| 55 |
+
"name": f"Feeder {feeder} (demo placeholder)",
|
| 56 |
+
"base_kv": 13.8,
|
| 57 |
+
"num_customers": 3000,
|
| 58 |
+
"peak_mw": 10.0,
|
| 59 |
+
"pv_mw": 1.0,
|
| 60 |
+
},
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def run_power_flow_scenario(
|
| 65 |
+
feeder: str,
|
| 66 |
+
added_pv_mw: float = 0.0,
|
| 67 |
+
added_load_mw: float = 0.0,
|
| 68 |
+
) -> PowerFlowResult:
|
| 69 |
+
meta = get_feeder_summary(feeder)
|
| 70 |
+
base_peak = float(meta.get("peak_mw", 10.0))
|
| 71 |
+
base_pv = float(meta.get("pv_mw", 1.0))
|
| 72 |
+
|
| 73 |
+
new_peak = base_peak + added_load_mw - 0.5 * added_pv_mw
|
| 74 |
+
if new_peak < 0:
|
| 75 |
+
new_peak = 0.0
|
| 76 |
+
|
| 77 |
+
rating_mva = base_peak * 1.2 if base_peak > 0 else 12.0
|
| 78 |
+
peak_loading_pct = 100.0 * (new_peak / rating_mva)
|
| 79 |
+
|
| 80 |
+
min_voltage = 0.97 - 0.01 * (added_load_mw / max(base_peak, 1.0))
|
| 81 |
+
max_voltage = 1.03 + 0.01 * (added_pv_mw / max(base_pv, 0.5))
|
| 82 |
+
|
| 83 |
+
min_voltage = max(min_voltage, 0.9)
|
| 84 |
+
max_voltage = min(max_voltage, 1.10)
|
| 85 |
+
|
| 86 |
+
overload_elements: List[str] = []
|
| 87 |
+
if peak_loading_pct > 100.0:
|
| 88 |
+
overload_elements.append("Main transformer overloaded")
|
| 89 |
+
if peak_loading_pct > 95.0:
|
| 90 |
+
overload_elements.append("One or more line segments near thermal limit")
|
| 91 |
+
if min_voltage < 0.95:
|
| 92 |
+
overload_elements.append("Low voltage at end-of-line customers")
|
| 93 |
+
if max_voltage > 1.05:
|
| 94 |
+
overload_elements.append("Risk of over-voltage near PV clusters")
|
| 95 |
+
|
| 96 |
+
if overload_elements:
|
| 97 |
+
notes = "Potential issues detected; further detailed study recommended."
|
| 98 |
+
else:
|
| 99 |
+
notes = "Scenario appears within normal operating limits in this simplified model."
|
| 100 |
+
|
| 101 |
+
return PowerFlowResult(
|
| 102 |
+
feeder=feeder,
|
| 103 |
+
peak_loading_pct=peak_loading_pct,
|
| 104 |
+
min_voltage_pu=min_voltage,
|
| 105 |
+
max_voltage_pu=max_voltage,
|
| 106 |
+
overload_elements=overload_elements,
|
| 107 |
+
notes=notes,
|
| 108 |
+
)
|
gridgent/tools/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# package
|
gridgent/tools/grid_stub.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import Dict, Any, List
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import json
|
| 6 |
+
import csv
|
| 7 |
+
import io
|
| 8 |
+
|
| 9 |
+
_CONFIG_CACHE: Dict[str, Any] | None = None
|
| 10 |
+
_BASE_DIR = Path(__file__).resolve().parents[2]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _default_feeder_config() -> Dict[str, Any]:
|
| 14 |
+
return {
|
| 15 |
+
"feeders": {
|
| 16 |
+
"F1": {
|
| 17 |
+
"name": "Feeder F1 - Downtown Core",
|
| 18 |
+
"base_kv": 13.8,
|
| 19 |
+
"num_customers": 4200,
|
| 20 |
+
"peak_mw": 18.5,
|
| 21 |
+
"pv_mw": 3.2,
|
| 22 |
+
},
|
| 23 |
+
"F2": {
|
| 24 |
+
"name": "Feeder F2 - Residential West",
|
| 25 |
+
"base_kv": 13.8,
|
| 26 |
+
"num_customers": 5100,
|
| 27 |
+
"peak_mw": 14.3,
|
| 28 |
+
"pv_mw": 4.7,
|
| 29 |
+
},
|
| 30 |
+
"F3": {
|
| 31 |
+
"name": "Feeder F3 - Industrial Park",
|
| 32 |
+
"base_kv": 13.8,
|
| 33 |
+
"num_customers": 830,
|
| 34 |
+
"peak_mw": 22.1,
|
| 35 |
+
"pv_mw": 0.8,
|
| 36 |
+
},
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _load_feeder_config() -> Dict[str, Any]:
|
| 42 |
+
"""Load feeder configuration with upload override."""
|
| 43 |
+
global _CONFIG_CACHE
|
| 44 |
+
if _CONFIG_CACHE is not None:
|
| 45 |
+
return _CONFIG_CACHE
|
| 46 |
+
|
| 47 |
+
uploaded_path = _BASE_DIR / "config" / "uploaded_feedermodel.json"
|
| 48 |
+
base_path = _BASE_DIR / "config" / "feeders.json"
|
| 49 |
+
|
| 50 |
+
if uploaded_path.exists():
|
| 51 |
+
try:
|
| 52 |
+
with uploaded_path.open("r", encoding="utf-8") as f:
|
| 53 |
+
data = json.load(f)
|
| 54 |
+
if isinstance(data, dict) and "feeders" in data:
|
| 55 |
+
_CONFIG_CACHE = data
|
| 56 |
+
return _CONFIG_CACHE
|
| 57 |
+
except Exception:
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
if base_path.exists():
|
| 61 |
+
try:
|
| 62 |
+
with base_path.open("r", encoding="utf-8") as f:
|
| 63 |
+
data = json.load(f)
|
| 64 |
+
if isinstance(data, dict) and "feeders" in data:
|
| 65 |
+
_CONFIG_CACHE = data
|
| 66 |
+
return _CONFIG_CACHE
|
| 67 |
+
except Exception:
|
| 68 |
+
pass
|
| 69 |
+
|
| 70 |
+
_CONFIG_CACHE = _default_feeder_config()
|
| 71 |
+
return _CONFIG_CACHE
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def reload_feeder_config() -> None:
|
| 75 |
+
global _CONFIG_CACHE
|
| 76 |
+
_CONFIG_CACHE = None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def get_feeder_summary(feeder: str) -> Dict[str, Any]:
|
| 80 |
+
feeder_key = (feeder or "").upper().strip()
|
| 81 |
+
cfg = _load_feeder_config()
|
| 82 |
+
feeders = cfg.get("feeders", {})
|
| 83 |
+
if feeder_key and feeder_key in feeders:
|
| 84 |
+
return feeders[feeder_key]
|
| 85 |
+
return {
|
| 86 |
+
"name": f"Feeder {feeder_key or 'F?'} (demo placeholder)",
|
| 87 |
+
"base_kv": 13.8,
|
| 88 |
+
"num_customers": 3000,
|
| 89 |
+
"peak_mw": 10.0,
|
| 90 |
+
"pv_mw": 1.0,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_all_feeders() -> Dict[str, Dict[str, Any]]:
|
| 95 |
+
cfg = _load_feeder_config()
|
| 96 |
+
feeders = cfg.get("feeders", {})
|
| 97 |
+
out: Dict[str, Dict[str, Any]] = {}
|
| 98 |
+
for k, v in feeders.items():
|
| 99 |
+
out[str(k).upper()] = v
|
| 100 |
+
return out
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@dataclass
|
| 104 |
+
class PowerFlowResult:
|
| 105 |
+
feeder: str
|
| 106 |
+
peak_loading_pct: float
|
| 107 |
+
min_voltage_pu: float
|
| 108 |
+
max_voltage_pu: float
|
| 109 |
+
overload_elements: List[str]
|
| 110 |
+
notes: str
|
| 111 |
+
|
| 112 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 113 |
+
return {
|
| 114 |
+
"feeder": self.feeder,
|
| 115 |
+
"peak_loading_pct": round(self.peak_loading_pct, 1),
|
| 116 |
+
"min_voltage_pu": round(self.min_voltage_pu, 3),
|
| 117 |
+
"max_voltage_pu": round(self.max_voltage_pu, 3),
|
| 118 |
+
"overload_elements": self.overload_elements,
|
| 119 |
+
"notes": self.notes,
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def run_power_flow_scenario(
|
| 124 |
+
feeder: str,
|
| 125 |
+
added_pv_mw: float = 0.0,
|
| 126 |
+
added_load_mw: float = 0.0,
|
| 127 |
+
) -> PowerFlowResult:
|
| 128 |
+
meta = get_feeder_summary(feeder)
|
| 129 |
+
base_peak = float(meta.get("peak_mw", 10.0))
|
| 130 |
+
base_pv = float(meta.get("pv_mw", 1.0))
|
| 131 |
+
|
| 132 |
+
new_peak = base_peak + added_load_mw - 0.5 * added_pv_mw
|
| 133 |
+
if new_peak < 0:
|
| 134 |
+
new_peak = 0.0
|
| 135 |
+
|
| 136 |
+
rating_mva = base_peak * 1.2 if base_peak > 0 else 12.0
|
| 137 |
+
peak_loading_pct = 100.0 * (new_peak / rating_mva) if rating_mva > 0 else 0.0
|
| 138 |
+
|
| 139 |
+
min_voltage = 0.97 - 0.01 * (added_load_mw / max(base_peak, 1.0))
|
| 140 |
+
max_voltage = 1.03 + 0.01 * (added_pv_mw / max(base_pv, 0.5))
|
| 141 |
+
|
| 142 |
+
min_voltage = max(min_voltage, 0.9)
|
| 143 |
+
max_voltage = min(max_voltage, 1.10)
|
| 144 |
+
|
| 145 |
+
overload_elements: List[str] = []
|
| 146 |
+
if peak_loading_pct > 100.0:
|
| 147 |
+
overload_elements.append("Main transformer overloaded (demo flag)")
|
| 148 |
+
if peak_loading_pct > 95.0:
|
| 149 |
+
overload_elements.append("Line segments near thermal limit (demo flag)")
|
| 150 |
+
if min_voltage < 0.95:
|
| 151 |
+
overload_elements.append("Low voltage at end-of-line customers (demo flag)")
|
| 152 |
+
if max_voltage > 1.05:
|
| 153 |
+
overload_elements.append("Risk of over-voltage near PV clusters (demo flag)")
|
| 154 |
+
|
| 155 |
+
if overload_elements:
|
| 156 |
+
notes = "Potential issues detected; a detailed engineering study is recommended."
|
| 157 |
+
else:
|
| 158 |
+
notes = "Scenario appears within normal operating limits in this simplified model."
|
| 159 |
+
|
| 160 |
+
return PowerFlowResult(
|
| 161 |
+
feeder=(feeder or "").upper().strip() or "F?",
|
| 162 |
+
peak_loading_pct=peak_loading_pct,
|
| 163 |
+
min_voltage_pu=min_voltage,
|
| 164 |
+
max_voltage_pu=max_voltage,
|
| 165 |
+
overload_elements=overload_elements,
|
| 166 |
+
notes=notes,
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def parse_uploaded_grid(raw: str, fmt: str) -> Dict[str, Any]:
|
| 171 |
+
fmt = (fmt or "").lower().strip()
|
| 172 |
+
if fmt not in {"json", "csv"}:
|
| 173 |
+
raise ValueError("Unsupported format; expected 'json' or 'csv'.")
|
| 174 |
+
|
| 175 |
+
if fmt == "json":
|
| 176 |
+
try:
|
| 177 |
+
data = json.loads(raw)
|
| 178 |
+
except Exception as exc:
|
| 179 |
+
raise ValueError(f"Invalid JSON: {exc}") from exc
|
| 180 |
+
|
| 181 |
+
if isinstance(data, dict) and "feeders" in data and isinstance(data["feeders"], dict):
|
| 182 |
+
feeders_in = data["feeders"]
|
| 183 |
+
elif isinstance(data, list):
|
| 184 |
+
feeders_in = {}
|
| 185 |
+
for row in data:
|
| 186 |
+
if not isinstance(row, dict):
|
| 187 |
+
raise ValueError("JSON list must contain objects with feeder fields.")
|
| 188 |
+
fid = row.get("feeder_id") or row.get("id") or row.get("name")
|
| 189 |
+
if not fid:
|
| 190 |
+
raise ValueError("Each feeder row must contain a 'feeder_id' or 'id' or 'name'.")
|
| 191 |
+
feeders_in[str(fid).upper()] = {
|
| 192 |
+
"name": row.get("name", str(fid)),
|
| 193 |
+
"base_kv": float(row.get("base_kv", 13.8)),
|
| 194 |
+
"num_customers": int(row.get("num_customers", 1000)),
|
| 195 |
+
"peak_mw": float(row.get("peak_mw", 10.0)),
|
| 196 |
+
"pv_mw": float(row.get("pv_mw", 1.0)),
|
| 197 |
+
}
|
| 198 |
+
else:
|
| 199 |
+
raise ValueError("JSON must be an object with 'feeders' or a list of feeders.")
|
| 200 |
+
|
| 201 |
+
feeders_out: Dict[str, Any] = {}
|
| 202 |
+
for k, v in feeders_in.items():
|
| 203 |
+
fid = str(k).upper()
|
| 204 |
+
if not isinstance(v, dict):
|
| 205 |
+
raise ValueError("Each feeder entry must be an object.")
|
| 206 |
+
feeders_out[fid] = {
|
| 207 |
+
"name": v.get("name", fid),
|
| 208 |
+
"base_kv": float(v.get("base_kv", 13.8)),
|
| 209 |
+
"num_customers": int(v.get("num_customers", 1000)),
|
| 210 |
+
"peak_mw": float(v.get("peak_mw", 10.0)),
|
| 211 |
+
"pv_mw": float(v.get("pv_mw", 1.0)),
|
| 212 |
+
}
|
| 213 |
+
if not feeders_out:
|
| 214 |
+
raise ValueError("No feeders found in uploaded JSON.")
|
| 215 |
+
return {"feeders": feeders_out}
|
| 216 |
+
|
| 217 |
+
# CSV
|
| 218 |
+
f = io.StringIO(raw)
|
| 219 |
+
reader = csv.DictReader(f)
|
| 220 |
+
required = ["feeder_id", "name", "base_kv", "num_customers", "peak_mw", "pv_mw"]
|
| 221 |
+
if reader.fieldnames is None:
|
| 222 |
+
raise ValueError("CSV appears to have no header.")
|
| 223 |
+
for r in required:
|
| 224 |
+
if r not in reader.fieldnames:
|
| 225 |
+
raise ValueError(f"CSV missing required column '{r}'")
|
| 226 |
+
|
| 227 |
+
feeders_out: Dict[str, Any] = {}
|
| 228 |
+
for row in reader:
|
| 229 |
+
fid = row.get("feeder_id")
|
| 230 |
+
if not fid:
|
| 231 |
+
raise ValueError("CSV row missing feeder_id.")
|
| 232 |
+
fid_u = str(fid).upper()
|
| 233 |
+
feeders_out[fid_u] = {
|
| 234 |
+
"name": row.get("name") or fid_u,
|
| 235 |
+
"base_kv": float(row.get("base_kv") or 13.8),
|
| 236 |
+
"num_customers": int(row.get("num_customers") or 1000),
|
| 237 |
+
"peak_mw": float(row.get("peak_mw") or 10.0),
|
| 238 |
+
"pv_mw": float(row.get("pv_mw") or 1.0),
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
if not feeders_out:
|
| 242 |
+
raise ValueError("No feeders found in uploaded CSV.")
|
| 243 |
+
return {"feeders": feeders_out}
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def save_uploaded_grid(config: Dict[str, Any]) -> None:
|
| 247 |
+
if not isinstance(config, dict) or "feeders" not in config:
|
| 248 |
+
raise ValueError("Uploaded config must be a dict with 'feeders'.")
|
| 249 |
+
path = _BASE_DIR / "config" / "uploaded_feedermodel.json"
|
| 250 |
+
path.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
| 251 |
+
reload_feeder_config()
|
main.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import os
|
| 3 |
+
from app.server import run_server
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
port_str = os.environ.get("GRID_GENT_PORT", "8000")
|
| 7 |
+
try:
|
| 8 |
+
port = int(port_str)
|
| 9 |
+
except ValueError:
|
| 10 |
+
port = 8000
|
| 11 |
+
run_server(port=port)
|
templates/index.html
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
<!DOCTYPE html>
|
| 3 |
+
<html lang="en">
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8" />
|
| 6 |
+
<title>Grid-Gent Demo</title>
|
| 7 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 8 |
+
<style>
|
| 9 |
+
:root {
|
| 10 |
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 11 |
+
background-color: #0f172a;
|
| 12 |
+
color: #e5e7eb;
|
| 13 |
+
}
|
| 14 |
+
body { margin: 0; padding: 0; }
|
| 15 |
+
.page { max-width: 960px; margin: 0 auto; padding: 24px 16px 48px; }
|
| 16 |
+
.card {
|
| 17 |
+
background: #020617;
|
| 18 |
+
border-radius: 16px;
|
| 19 |
+
padding: 20px;
|
| 20 |
+
box-shadow: 0 18px 40px rgba(15,23,42,0.8);
|
| 21 |
+
border: 1px solid rgba(148, 163, 184, 0.2);
|
| 22 |
+
}
|
| 23 |
+
h1 { font-size: 1.8rem; margin-bottom: 0.25rem; }
|
| 24 |
+
h2 { font-size: 1.1rem; margin: 0; color: #9ca3af; }
|
| 25 |
+
textarea {
|
| 26 |
+
width: 100%;
|
| 27 |
+
min-height: 90px;
|
| 28 |
+
padding: 10px 12px;
|
| 29 |
+
border-radius: 10px;
|
| 30 |
+
border: 1px solid #4b5563;
|
| 31 |
+
background: #020617;
|
| 32 |
+
color: #e5e7eb;
|
| 33 |
+
resize: vertical;
|
| 34 |
+
font-family: inherit;
|
| 35 |
+
font-size: 0.95rem;
|
| 36 |
+
}
|
| 37 |
+
textarea:focus {
|
| 38 |
+
outline: none;
|
| 39 |
+
border-color: #38bdf8;
|
| 40 |
+
box-shadow: 0 0 0 1px #0ea5e9;
|
| 41 |
+
}
|
| 42 |
+
button {
|
| 43 |
+
margin-top: 10px;
|
| 44 |
+
padding: 9px 16px;
|
| 45 |
+
border-radius: 999px;
|
| 46 |
+
border: none;
|
| 47 |
+
background: linear-gradient(135deg, #0ea5e9, #22c55e);
|
| 48 |
+
color: white;
|
| 49 |
+
font-weight: 600;
|
| 50 |
+
cursor: pointer;
|
| 51 |
+
font-size: 0.95rem;
|
| 52 |
+
display: inline-flex;
|
| 53 |
+
align-items: center;
|
| 54 |
+
gap: 6px;
|
| 55 |
+
}
|
| 56 |
+
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 57 |
+
.answer {
|
| 58 |
+
margin-top: 18px;
|
| 59 |
+
white-space: pre-wrap;
|
| 60 |
+
background: #020617;
|
| 61 |
+
border-radius: 10px;
|
| 62 |
+
padding: 12px 14px;
|
| 63 |
+
border: 1px solid #4b5563;
|
| 64 |
+
font-size: 0.9rem;
|
| 65 |
+
}
|
| 66 |
+
.steps { margin-top: 16px; font-size: 0.8rem; }
|
| 67 |
+
.step {
|
| 68 |
+
padding: 8px 10px;
|
| 69 |
+
border-radius: 8px;
|
| 70 |
+
border: 1px solid rgba(75,85,99,0.7);
|
| 71 |
+
background: rgba(15,23,42,0.8);
|
| 72 |
+
margin-bottom: 6px;
|
| 73 |
+
}
|
| 74 |
+
.step strong { color: #a5b4fc; }
|
| 75 |
+
.badge {
|
| 76 |
+
display: inline-block;
|
| 77 |
+
padding: 2px 7px;
|
| 78 |
+
border-radius: 999px;
|
| 79 |
+
font-size: 0.7rem;
|
| 80 |
+
background: rgba(15,118,110,0.3);
|
| 81 |
+
color: #a7f3d0;
|
| 82 |
+
margin-left: 6px;
|
| 83 |
+
}
|
| 84 |
+
.header-row {
|
| 85 |
+
display: flex;
|
| 86 |
+
justify-content: space-between;
|
| 87 |
+
align-items: baseline;
|
| 88 |
+
gap: 12px;
|
| 89 |
+
flex-wrap: wrap;
|
| 90 |
+
}
|
| 91 |
+
.pill {
|
| 92 |
+
font-size: 0.75rem;
|
| 93 |
+
padding: 4px 10px;
|
| 94 |
+
border-radius: 999px;
|
| 95 |
+
border: 1px solid rgba(148,163,184,0.5);
|
| 96 |
+
color: #e5e7eb;
|
| 97 |
+
}
|
| 98 |
+
.examples {
|
| 99 |
+
margin-top: 10px;
|
| 100 |
+
font-size: 0.8rem;
|
| 101 |
+
color: #9ca3af;
|
| 102 |
+
}
|
| 103 |
+
.examples code {
|
| 104 |
+
background: rgba(15,23,42,0.8);
|
| 105 |
+
padding: 2px 6px;
|
| 106 |
+
border-radius: 6px;
|
| 107 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
| 108 |
+
}
|
| 109 |
+
</style>
|
| 110 |
+
</head>
|
| 111 |
+
<body>
|
| 112 |
+
<div class="page">
|
| 113 |
+
<div class="card">
|
| 114 |
+
<div class="header-row">
|
| 115 |
+
<div>
|
| 116 |
+
<h1>Grid-Gent Demo</h1>
|
| 117 |
+
<h2>Agentic assistant for city distribution grid scenarios (simplified)</h2>
|
| 118 |
+
</div>
|
| 119 |
+
<div class="pill">Offline demo · no real grid connection</div>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div style="margin-top:18px;">
|
| 123 |
+
<label for="query">Describe a grid scenario or question:</label>
|
| 124 |
+
<textarea id="query" placeholder="Example: What happens on feeder F2 if we add 5 MW of rooftop PV?"></textarea>
|
| 125 |
+
<button id="ask-btn" onclick="sendQuery()">
|
| 126 |
+
<span id="btn-label">Run Grid-Gent</span>
|
| 127 |
+
<span id="btn-spinner" style="display:none;">⏳</span>
|
| 128 |
+
</button>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
<div class="examples">
|
| 132 |
+
Try things like:
|
| 133 |
+
<div><code>What happens on feeder F2 if we add 5 MW of rooftop PV?</code></div>
|
| 134 |
+
<div><code>Simulate adding 3 MW of load on feeder F1.</code></div>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div id="answer" class="answer" style="display:none;"></div>
|
| 138 |
+
<div id="steps" class="steps" style="display:none;"></div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<script>
|
| 143 |
+
async function sendQuery() {
|
| 144 |
+
const textarea = document.getElementById("query");
|
| 145 |
+
const btn = document.getElementById("ask-btn");
|
| 146 |
+
const label = document.getElementById("btn-label");
|
| 147 |
+
const spinner = document.getElementById("btn-spinner");
|
| 148 |
+
const answerEl = document.getElementById("answer");
|
| 149 |
+
const stepsEl = document.getElementById("steps");
|
| 150 |
+
|
| 151 |
+
const query = textarea.value.trim();
|
| 152 |
+
if (!query) {
|
| 153 |
+
alert("Please enter a scenario or question.");
|
| 154 |
+
return;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
btn.disabled = true;
|
| 158 |
+
spinner.style.display = "inline-block";
|
| 159 |
+
label.textContent = "Running...";
|
| 160 |
+
|
| 161 |
+
try {
|
| 162 |
+
const resp = await fetch("/api/ask", {
|
| 163 |
+
method: "POST",
|
| 164 |
+
headers: { "Content-Type": "application/json" },
|
| 165 |
+
body: JSON.stringify({ query })
|
| 166 |
+
});
|
| 167 |
+
const data = await resp.json();
|
| 168 |
+
|
| 169 |
+
if (!resp.ok) {
|
| 170 |
+
answerEl.style.display = "block";
|
| 171 |
+
stepsEl.style.display = "none";
|
| 172 |
+
answerEl.textContent = "Error: " + (data.error || resp.statusText);
|
| 173 |
+
return;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
answerEl.style.display = "block";
|
| 177 |
+
answerEl.textContent = data.answer || "(No answer returned)";
|
| 178 |
+
|
| 179 |
+
stepsEl.innerHTML = "";
|
| 180 |
+
if (Array.isArray(data.steps)) {
|
| 181 |
+
stepsEl.style.display = "block";
|
| 182 |
+
data.steps.forEach(step => {
|
| 183 |
+
const div = document.createElement("div");
|
| 184 |
+
div.className = "step";
|
| 185 |
+
const role = step.role || "agent";
|
| 186 |
+
const metaIntent = step.meta && step.meta.intent ? step.meta.intent : "";
|
| 187 |
+
const metaFeeder = step.meta && step.meta.feeder ? step.meta.feeder : "";
|
| 188 |
+
const badgeText = metaIntent || metaFeeder;
|
| 189 |
+
div.innerHTML = "<strong>" + role + "</strong>" +
|
| 190 |
+
(badgeText ? "<span class=\"badge\">" + badgeText + "</span>" : "") +
|
| 191 |
+
"<div>" + step.content + "</div>";
|
| 192 |
+
stepsEl.appendChild(div);
|
| 193 |
+
});
|
| 194 |
+
} else {
|
| 195 |
+
stepsEl.style.display = "none";
|
| 196 |
+
}
|
| 197 |
+
} catch (err) {
|
| 198 |
+
answerEl.style.display = "block";
|
| 199 |
+
stepsEl.style.display = "none";
|
| 200 |
+
answerEl.textContent = "Request failed: " + err;
|
| 201 |
+
} finally {
|
| 202 |
+
btn.disabled = false;
|
| 203 |
+
spinner.style.display = "none";
|
| 204 |
+
label.textContent = "Run Grid-Gent";
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
</script>
|
| 208 |
+
</body>
|
| 209 |
+
</html>
|
tests/test_http_api.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
import threading
|
| 3 |
+
import time
|
| 4 |
+
import json
|
| 5 |
+
import urllib.request
|
| 6 |
+
|
| 7 |
+
from app.server import run_server
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestHTTPAPI(unittest.TestCase):
|
| 11 |
+
_server_started = False
|
| 12 |
+
|
| 13 |
+
@classmethod
|
| 14 |
+
def setUpClass(cls):
|
| 15 |
+
if not cls._server_started:
|
| 16 |
+
t = threading.Thread(target=run_server, kwargs={"host": "127.0.0.1", "port": 8765}, daemon=True)
|
| 17 |
+
t.start()
|
| 18 |
+
time.sleep(1.0)
|
| 19 |
+
cls._server_started = True
|
| 20 |
+
|
| 21 |
+
def test_api_ask(self):
|
| 22 |
+
body = json.dumps({"query": "Simulate adding 3 MW of load on feeder F1"}).encode("utf-8")
|
| 23 |
+
req = urllib.request.Request(
|
| 24 |
+
"http://127.0.0.1:8765/api/ask",
|
| 25 |
+
data=body,
|
| 26 |
+
headers={"Content-Type": "application/json"},
|
| 27 |
+
method="POST",
|
| 28 |
+
)
|
| 29 |
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
| 30 |
+
self.assertEqual(resp.status, 200)
|
| 31 |
+
data = json.loads(resp.read().decode("utf-8"))
|
| 32 |
+
self.assertIn("answer", data)
|
| 33 |
+
self.assertIn("steps", data)
|
| 34 |
+
self.assertIsInstance(data["steps"], list)
|
| 35 |
+
|
| 36 |
+
def test_api_upload_grid(self):
|
| 37 |
+
payload = {
|
| 38 |
+
"raw": "feeder_id,name,base_kv,num_customers,peak_mw,pv_mw\n"
|
| 39 |
+
"U1,Feeder U1,13.8,100,7.0,1.0\n",
|
| 40 |
+
"format": "csv",
|
| 41 |
+
}
|
| 42 |
+
body = json.dumps(payload).encode("utf-8")
|
| 43 |
+
req = urllib.request.Request(
|
| 44 |
+
"http://127.0.0.1:8765/api/upload-grid",
|
| 45 |
+
data=body,
|
| 46 |
+
headers={"Content-Type": "application/json"},
|
| 47 |
+
method="POST",
|
| 48 |
+
)
|
| 49 |
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
| 50 |
+
self.assertEqual(resp.status, 200)
|
| 51 |
+
data = json.loads(resp.read().decode("utf-8"))
|
| 52 |
+
self.assertEqual(data["status"], "ok")
|
| 53 |
+
self.assertIn("U1", data["feeders_loaded"])
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
if __name__ == "__main__":
|
| 57 |
+
unittest.main()
|
tests/test_intent_agent.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from gridgent.agents.intent import IntentAgent
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class TestIntentAgent(unittest.TestCase):
|
| 6 |
+
def setUp(self):
|
| 7 |
+
self.agent = IntentAgent()
|
| 8 |
+
|
| 9 |
+
def test_hosting_capacity_intent(self):
|
| 10 |
+
info = self.agent.classify("What happens on feeder F2 if we add 5 MW of rooftop PV?")
|
| 11 |
+
self.assertEqual(info["intent"], "hosting_capacity")
|
| 12 |
+
self.assertEqual(info["feeder"], "F2")
|
| 13 |
+
self.assertAlmostEqual(info["added_pv_mw"], 5.0, places=3)
|
| 14 |
+
|
| 15 |
+
def test_simulation_intent_load_growth(self):
|
| 16 |
+
info = self.agent.classify("Simulate load growth of 3 MW on feeder F1")
|
| 17 |
+
self.assertEqual(info["intent"], "simulation")
|
| 18 |
+
self.assertAlmostEqual(info["added_load_mw"], 3.0, places=3)
|
| 19 |
+
|
| 20 |
+
def test_unknown_for_smalltalk(self):
|
| 21 |
+
info = self.agent.classify("hi")
|
| 22 |
+
self.assertEqual(info["intent"], "unknown")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
if __name__ == "__main__":
|
| 26 |
+
unittest.main()
|
tests/test_orchestrator.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from gridgent.core.orchestrator import GridGentOrchestrator
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class TestOrchestrator(unittest.TestCase):
|
| 6 |
+
def setUp(self):
|
| 7 |
+
self.orch = GridGentOrchestrator()
|
| 8 |
+
|
| 9 |
+
def test_run_basic_query(self):
|
| 10 |
+
result = self.orch.run("What happens on feeder F2 if we add 5 MW of rooftop PV?")
|
| 11 |
+
self.assertTrue(result.task_id)
|
| 12 |
+
self.assertIn("You asked:", result.answer)
|
| 13 |
+
roles = [s.role for s in result.steps]
|
| 14 |
+
self.assertEqual(roles[0], "intent_agent")
|
| 15 |
+
self.assertIn("planning_agent", roles)
|
| 16 |
+
self.assertIn("narrator_agent", roles)
|
| 17 |
+
|
| 18 |
+
def test_run_unknown_query(self):
|
| 19 |
+
result = self.orch.run("hi")
|
| 20 |
+
self.assertIn("didn't see enough detail", result.answer.lower())
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
if __name__ == "__main__":
|
| 24 |
+
unittest.main()
|
tests/test_tools.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from gridgent.tools.grid_stub import (
|
| 3 |
+
run_power_flow_scenario,
|
| 4 |
+
get_feeder_summary,
|
| 5 |
+
parse_uploaded_grid,
|
| 6 |
+
save_uploaded_grid,
|
| 7 |
+
reload_feeder_config,
|
| 8 |
+
get_all_feeders,
|
| 9 |
+
)
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import json
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestTools(unittest.TestCase):
|
| 15 |
+
def test_feeder_summary_exists(self):
|
| 16 |
+
meta = get_feeder_summary("F1")
|
| 17 |
+
self.assertIn("peak_mw", meta)
|
| 18 |
+
self.assertGreater(meta["peak_mw"], 0)
|
| 19 |
+
|
| 20 |
+
def test_power_flow_baseline(self):
|
| 21 |
+
result = run_power_flow_scenario("F1", added_pv_mw=0.0, added_load_mw=0.0)
|
| 22 |
+
self.assertIsInstance(result.peak_loading_pct, float)
|
| 23 |
+
self.assertGreater(result.peak_loading_pct, 0)
|
| 24 |
+
self.assertGreaterEqual(result.min_voltage_pu, 0.9)
|
| 25 |
+
self.assertLessEqual(result.max_voltage_pu, 1.1)
|
| 26 |
+
|
| 27 |
+
def test_power_flow_high_load_flags_issue(self):
|
| 28 |
+
result = run_power_flow_scenario("F1", added_pv_mw=0.0, added_load_mw=20.0)
|
| 29 |
+
self.assertTrue(any("overloaded" in x.lower() for x in result.overload_elements))
|
| 30 |
+
|
| 31 |
+
def test_parse_uploaded_json(self):
|
| 32 |
+
raw = json.dumps({
|
| 33 |
+
"feeders": {
|
| 34 |
+
"X1": {"name": "Custom Feeder", "base_kv": 11.0, "num_customers": 100, "peak_mw": 5.0, "pv_mw": 0.5}
|
| 35 |
+
}
|
| 36 |
+
})
|
| 37 |
+
cfg = parse_uploaded_grid(raw, "json")
|
| 38 |
+
self.assertIn("feeders", cfg)
|
| 39 |
+
self.assertIn("X1", cfg["feeders"])
|
| 40 |
+
|
| 41 |
+
def test_parse_uploaded_csv(self):
|
| 42 |
+
raw = "feeder_id,name,base_kv,num_customers,peak_mw,pv_mw\n" "Y1,Feeder Y1,13.8,200,8.0,1.2\n"
|
| 43 |
+
cfg = parse_uploaded_grid(raw, "csv")
|
| 44 |
+
self.assertIn("Y1", cfg["feeders"])
|
| 45 |
+
|
| 46 |
+
def test_save_uploaded_overrides(self):
|
| 47 |
+
raw = json.dumps({
|
| 48 |
+
"feeders": {
|
| 49 |
+
"Z1": {"name": "Uploaded Feeder Z1", "base_kv": 13.8, "num_customers": 999, "peak_mw": 50.0, "pv_mw": 5.0}
|
| 50 |
+
}
|
| 51 |
+
})
|
| 52 |
+
cfg = parse_uploaded_grid(raw, "json")
|
| 53 |
+
save_uploaded_grid(cfg)
|
| 54 |
+
reload_feeder_config()
|
| 55 |
+
feeders = get_all_feeders()
|
| 56 |
+
self.assertIn("Z1", feeders)
|
| 57 |
+
meta = get_feeder_summary("Z1")
|
| 58 |
+
self.assertEqual(meta["peak_mw"], 50.0)
|
| 59 |
+
# Clean up uploaded file
|
| 60 |
+
base_dir = Path(__file__).resolve().parents[1]
|
| 61 |
+
up = base_dir / "config" / "uploaded_feedermodel.json"
|
| 62 |
+
if up.exists():
|
| 63 |
+
up.unlink()
|
| 64 |
+
reload_feeder_config()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
if __name__ == "__main__":
|
| 68 |
+
unittest.main()
|