James Afful commited on
Commit
458fa79
·
1 Parent(s): 170ed78

Add Grid-Gent Space code and Dockerfile (no binary assets)

Browse files
.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
- title: Grid Gent
3
- emoji: 🐠
4
- colorFrom: pink
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- short_description: Lightweight agentic assistant for early-stage distribution g
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ![Grid-Gent UI](app/web/Grid-Gent-1.png)
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()