Trae Assistant commited on
Commit
971cb65
·
0 Parent(s):

init microgrid dispatch agent

Browse files
Files changed (8) hide show
  1. .env +3 -0
  2. .gitattributes +35 -0
  3. .gitignore +4 -0
  4. Dockerfile +20 -0
  5. README.md +43 -0
  6. app.py +740 -0
  7. requirements.txt +4 -0
  8. templates/index.html +547 -0
.env ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ SILICONFLOW_API_KEY=your_key_here
2
+ SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1
3
+ SILICONFLOW_MODEL=deepseek-ai/DeepSeek-V3
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__/
2
+ instance/
3
+ .env
4
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ RUN mkdir -p instance
11
+ RUN chmod 777 instance
12
+
13
+ RUN useradd -m -u 1000 user
14
+ USER user
15
+ ENV HOME=/home/user \
16
+ PATH=/home/user/.local/bin:$PATH
17
+
18
+ EXPOSE 7860
19
+
20
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ short_description: 微电网调度闭环智能体
3
+ ---
4
+
5
+ # 微电网调度闭环智能体
6
+
7
+ 面向新能源微电网的闭环调度系统,包含推理/决策、工具行动、状态记忆、校验/验收、迭代与回放。适用于园区、工厂、冷链与分布式能源场景,支持移动端浏览。
8
+
9
+ ## 功能亮点
10
+ - 多智能体协同:规划、风险、优化三段式决策
11
+ - 工具行动:负荷预测、电价/碳价、并网约束模拟
12
+ - 状态记忆:持久化最近调度策略与KPI
13
+ - 校验与迭代:自动检查供需/并网/SOC并迭代修正
14
+ - 回放机制:完整日志追溯
15
+ - Markdown 展示:前端内置解析器
16
+
17
+ ## 快速启动
18
+
19
+ ```bash
20
+ pip install -r requirements.txt
21
+ python app.py
22
+ ```
23
+
24
+ 浏览器访问:http://localhost:7860
25
+
26
+ ## 环境变量
27
+
28
+ 支持硅基流模型服务(可选):
29
+
30
+ ```bash
31
+ SILICONFLOW_API_KEY=你的key
32
+ SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1
33
+ SILICONFLOW_MODEL=deepseek-ai/DeepSeek-V3
34
+ ```
35
+
36
+ 未配置时自动进入离线模拟模式。
37
+
38
+ ## Docker 运行
39
+
40
+ ```bash
41
+ docker build -t microgrid-dispatch-agent .
42
+ docker run -p 7860:7860 microgrid-dispatch-agent
43
+ ```
app.py ADDED
@@ -0,0 +1,740 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import uuid
4
+ import math
5
+ import random
6
+ import sqlite3
7
+ import datetime
8
+ import hashlib
9
+ import requests
10
+ from flask import Flask, render_template, request, jsonify, g, send_file
11
+ from dotenv import load_dotenv
12
+
13
+ load_dotenv()
14
+
15
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
16
+ INSTANCE_DIR = os.path.join(BASE_DIR, "instance")
17
+ os.makedirs(INSTANCE_DIR, exist_ok=True)
18
+
19
+ app = Flask(__name__)
20
+ app.config["DATABASE"] = os.path.join(INSTANCE_DIR, "microgrid.db")
21
+ app.config["SILICONFLOW_API_KEY"] = os.getenv("SILICONFLOW_API_KEY", "").strip()
22
+ app.config["SILICONFLOW_BASE_URL"] = os.getenv("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1").strip()
23
+ app.config["SILICONFLOW_MODEL"] = os.getenv("SILICONFLOW_MODEL", "deepseek-ai/DeepSeek-V3").strip()
24
+
25
+
26
+ def get_db():
27
+ db = getattr(g, "_database", None)
28
+ if db is None:
29
+ db = g._database = sqlite3.connect(app.config["DATABASE"])
30
+ db.row_factory = sqlite3.Row
31
+ return db
32
+
33
+
34
+ @app.teardown_appcontext
35
+ def close_connection(exception):
36
+ db = getattr(g, "_database", None)
37
+ if db is not None:
38
+ db.close()
39
+
40
+
41
+ def now_ts():
42
+ return datetime.datetime.now().isoformat()
43
+
44
+
45
+ def init_db():
46
+ db = sqlite3.connect(app.config["DATABASE"])
47
+ cursor = db.cursor()
48
+ cursor.execute(
49
+ """
50
+ CREATE TABLE IF NOT EXISTS cycles (
51
+ id TEXT PRIMARY KEY,
52
+ title TEXT,
53
+ scenario TEXT,
54
+ result TEXT,
55
+ status TEXT,
56
+ created_at TEXT
57
+ )
58
+ """
59
+ )
60
+ cursor.execute(
61
+ """
62
+ CREATE TABLE IF NOT EXISTS logs (
63
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ cycle_id TEXT,
65
+ role TEXT,
66
+ content TEXT,
67
+ created_at TEXT
68
+ )
69
+ """
70
+ )
71
+ cursor.execute(
72
+ """
73
+ CREATE TABLE IF NOT EXISTS memories (
74
+ key TEXT PRIMARY KEY,
75
+ value TEXT,
76
+ updated_at TEXT
77
+ )
78
+ """
79
+ )
80
+ cursor.execute(
81
+ """
82
+ CREATE TABLE IF NOT EXISTS validations (
83
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ cycle_id TEXT,
85
+ passed INTEGER,
86
+ issues TEXT,
87
+ created_at TEXT
88
+ )
89
+ """
90
+ )
91
+ cursor.execute(
92
+ """
93
+ CREATE TABLE IF NOT EXISTS files (
94
+ id TEXT PRIMARY KEY,
95
+ filename TEXT,
96
+ size_bytes INTEGER,
97
+ sha256 TEXT,
98
+ content_type TEXT,
99
+ is_binary INTEGER,
100
+ storage_path TEXT,
101
+ created_at TEXT
102
+ )
103
+ """
104
+ )
105
+ db.commit()
106
+ cursor.execute("SELECT COUNT(1) FROM cycles")
107
+ count = cursor.fetchone()[0]
108
+ if count == 0:
109
+ sample_id = str(uuid.uuid4())
110
+ scenario = {
111
+ "title": "示例:园区A峰值调度",
112
+ "demand_mw": 120,
113
+ "solar_mw": 42,
114
+ "wind_mw": 28,
115
+ "storage_mwh": 80,
116
+ "storage_soc": 0.45,
117
+ "priority": "成本优先",
118
+ "notes": "示例备注:保障冷链负荷,允许需求响应10%。",
119
+ }
120
+ db.execute(
121
+ "INSERT INTO cycles (id, title, scenario, result, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",
122
+ (
123
+ sample_id,
124
+ scenario["title"],
125
+ json.dumps(scenario, ensure_ascii=False),
126
+ json.dumps(
127
+ {
128
+ "scenario": scenario,
129
+ "decision": {
130
+ "dispatch": {
131
+ "solar_mw": 42,
132
+ "wind_mw": 28,
133
+ "storage_discharge_mw": 18,
134
+ "storage_charge_mw": 0,
135
+ "grid_import_mw": 32,
136
+ "grid_export_mw": 0,
137
+ "demand_response_mw": 0,
138
+ "storage_soc_next": 0.22,
139
+ },
140
+ "kpi": {"cost": 27.5, "carbon": 16.8, "reliability": 0.99},
141
+ "notes": "示例调度已完成",
142
+ },
143
+ "validation": {"passed": True, "issues": ["校验通过"]},
144
+ "memory": {
145
+ "last_priority": "成本优先",
146
+ "last_cost": 27.5,
147
+ "last_carbon": 16.8,
148
+ "last_reliability": 0.99,
149
+ "iterations": 0,
150
+ },
151
+ "iterations": 0,
152
+ },
153
+ ensure_ascii=False,
154
+ ),
155
+ "completed",
156
+ now_ts(),
157
+ ),
158
+ )
159
+ db.commit()
160
+ db.close()
161
+
162
+
163
+ init_db()
164
+
165
+
166
+ def log_event(cycle_id, role, content):
167
+ db = get_db()
168
+ db.execute(
169
+ "INSERT INTO logs (cycle_id, role, content, created_at) VALUES (?, ?, ?, ?)",
170
+ (cycle_id, role, content, now_ts()),
171
+ )
172
+ db.commit()
173
+
174
+
175
+ def set_memory(key, value):
176
+ db = get_db()
177
+ row = db.execute("SELECT key FROM memories WHERE key = ?", (key,)).fetchone()
178
+ payload = json.dumps(value, ensure_ascii=False)
179
+ if row:
180
+ db.execute("UPDATE memories SET value = ?, updated_at = ? WHERE key = ?", (payload, now_ts(), key))
181
+ else:
182
+ db.execute("INSERT INTO memories (key, value, updated_at) VALUES (?, ?, ?)", (key, payload, now_ts()))
183
+ db.commit()
184
+
185
+
186
+ def get_memory(key, default=None):
187
+ db = get_db()
188
+ row = db.execute("SELECT value FROM memories WHERE key = ?", (key,)).fetchone()
189
+ if not row:
190
+ return default
191
+ try:
192
+ return json.loads(row["value"])
193
+ except json.JSONDecodeError:
194
+ return default
195
+
196
+
197
+ def call_llm_json(system_prompt, user_prompt):
198
+ api_key = app.config["SILICONFLOW_API_KEY"]
199
+ if not api_key:
200
+ return None
201
+ url = f"{app.config['SILICONFLOW_BASE_URL']}/chat/completions"
202
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
203
+ payload = {
204
+ "model": app.config["SILICONFLOW_MODEL"],
205
+ "messages": [
206
+ {"role": "system", "content": system_prompt},
207
+ {"role": "user", "content": user_prompt},
208
+ ],
209
+ "temperature": 0.2,
210
+ "max_tokens": 900,
211
+ "response_format": {"type": "json_object"},
212
+ }
213
+ try:
214
+ response = requests.post(url, headers=headers, json=payload, timeout=25)
215
+ response.raise_for_status()
216
+ data = response.json()
217
+ content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
218
+ return json.loads(content)
219
+ except Exception:
220
+ return None
221
+
222
+
223
+ def tool_forecast(scenario):
224
+ demand = scenario["demand_mw"]
225
+ solar = scenario["solar_mw"]
226
+ wind = scenario["wind_mw"]
227
+ forecast = []
228
+ for hour in range(6):
229
+ factor = 0.92 + random.random() * 0.16
230
+ forecast.append(
231
+ {
232
+ "hour": hour + 1,
233
+ "demand_mw": round(demand * factor, 2),
234
+ "solar_mw": round(solar * (0.7 + random.random() * 0.4), 2),
235
+ "wind_mw": round(wind * (0.6 + random.random() * 0.5), 2),
236
+ }
237
+ )
238
+ return forecast
239
+
240
+
241
+ def tool_price():
242
+ schedule = []
243
+ base = 0.52 + random.random() * 0.12
244
+ for hour in range(6):
245
+ peak = 1.25 if hour in (2, 3, 4) else 1.0
246
+ schedule.append(round(base * peak + random.random() * 0.08, 3))
247
+ return {
248
+ "energy_price": round(base, 3),
249
+ "carbon_price": round(0.08 + random.random() * 0.05, 3),
250
+ "schedule": schedule,
251
+ }
252
+
253
+
254
+ def tool_grid_constraints():
255
+ return {"max_import_mw": 80.0, "max_export_mw": 40.0}
256
+
257
+
258
+ def tool_demand_response():
259
+ return {"max_response_mw": 12.0, "response_cost": 0.15}
260
+
261
+
262
+ def planner_agent(scenario, tools):
263
+ system_prompt = "你是微电网调度规划智能体,输出JSON,包含目标、优先级、步骤。"
264
+ user_prompt = f"场景: {json.dumps(scenario, ensure_ascii=False)}\n工具: {json.dumps(tools, ensure_ascii=False)}"
265
+ result = call_llm_json(system_prompt, user_prompt)
266
+ if result:
267
+ return result
268
+ priority = scenario.get("priority", "成本优先")
269
+ return {
270
+ "目标": "在满足负荷的前提下优化成本与碳排",
271
+ "优先级": priority,
272
+ "步骤": [
273
+ "读取需求与可再生出力",
274
+ "评估储能SOC与可调度能力",
275
+ "结合电价与碳价决定充放电与并网",
276
+ "生成调度方案并进入校验",
277
+ ],
278
+ }
279
+
280
+
281
+ def risk_agent(scenario, tools, plan):
282
+ system_prompt = "你是电力系统风险评估智能体,输出JSON,包含风险列表与缓释建议。"
283
+ user_prompt = f"计划: {json.dumps(plan, ensure_ascii=False)}\n场景: {json.dumps(scenario, ensure_ascii=False)}"
284
+ result = call_llm_json(system_prompt, user_prompt)
285
+ if result:
286
+ return result
287
+ risks = []
288
+ if scenario["storage_soc"] < 0.2:
289
+ risks.append("储能SOC偏低,抗波动能力下降")
290
+ if scenario["demand_mw"] > 120:
291
+ risks.append("负荷偏高,可能逼近并网上限")
292
+ return {
293
+ "风险": risks or ["暂无明显风险"],
294
+ "缓释": "必要时启用需求响应与负荷切分策略",
295
+ }
296
+
297
+
298
+ def optimizer_agent(scenario, tools, plan, risk):
299
+ demand = scenario["demand_mw"]
300
+ solar = scenario["solar_mw"]
301
+ wind = scenario["wind_mw"]
302
+ storage_mwh = scenario["storage_mwh"]
303
+ storage_soc = scenario["storage_soc"]
304
+ grid_limit = tools["grid"]["max_import_mw"]
305
+ export_limit = tools["grid"]["max_export_mw"]
306
+ response_limit = tools["response"]["max_response_mw"]
307
+
308
+ max_rate = storage_mwh * 0.5
309
+ available_discharge = storage_mwh * storage_soc
310
+ forecast = tools["forecast"]
311
+ schedule_price = tools["price"]["schedule"]
312
+ carbon_price = tools["price"]["carbon_price"]
313
+ grid_carbon = 0.72
314
+ priority = scenario.get("priority", "成本优先")
315
+ weight_cost = 0.5
316
+ weight_carbon = 0.3
317
+ weight_reliability = 0.2
318
+ if priority == "碳优先":
319
+ weight_cost, weight_carbon, weight_reliability = 0.2, 0.6, 0.2
320
+ elif priority == "可靠性优先":
321
+ weight_cost, weight_carbon, weight_reliability = 0.2, 0.2, 0.6
322
+
323
+ hourly = []
324
+ total_cost = 0.0
325
+ total_carbon = 0.0
326
+ total_demand = 0.0
327
+ total_unserved = 0.0
328
+ soc = storage_soc
329
+
330
+ for idx, point in enumerate(forecast):
331
+ hour_price = schedule_price[idx]
332
+ demand_h = point["demand_mw"]
333
+ solar_h = point["solar_mw"]
334
+ wind_h = point["wind_mw"]
335
+ total_demand += demand_h
336
+
337
+ max_rate_h = storage_mwh * 0.5
338
+ available_discharge_h = storage_mwh * soc
339
+ net = demand_h - solar_h - wind_h
340
+
341
+ if priority == "碳优先":
342
+ discharge = min(max_rate_h, available_discharge_h, max(0.0, net * 0.8))
343
+ elif priority == "成本优先" and hour_price < 0.6:
344
+ discharge = min(max_rate_h * 0.4, available_discharge_h, max(0.0, net))
345
+ else:
346
+ discharge = min(max_rate_h, available_discharge_h, max(0.0, net))
347
+
348
+ remaining = net - discharge
349
+ grid_import = min(max(0.0, remaining), grid_limit)
350
+ demand_response = min(response_limit, max(0.0, remaining - grid_import))
351
+ unserved = max(0.0, remaining - grid_import - demand_response)
352
+
353
+ surplus = max(0.0, solar_h + wind_h + discharge - demand_h)
354
+ charge = min(max_rate_h, storage_mwh * (1 - soc), surplus)
355
+ export = max(0.0, min(export_limit, surplus - charge))
356
+
357
+ soc = soc + (charge - discharge) / storage_mwh if storage_mwh > 0 else soc
358
+ soc = max(0.0, min(1.0, soc))
359
+
360
+ cost = grid_import * hour_price + demand_response * tools["response"]["response_cost"]
361
+ carbon = grid_import * grid_carbon - (solar_h + wind_h) * 0.18
362
+ total_cost += cost
363
+ total_carbon += carbon
364
+ total_unserved += unserved
365
+
366
+ hourly.append(
367
+ {
368
+ "hour": point["hour"],
369
+ "demand_mw": round(demand_h, 2),
370
+ "solar_mw": round(solar_h, 2),
371
+ "wind_mw": round(wind_h, 2),
372
+ "storage_discharge_mw": round(discharge, 2),
373
+ "storage_charge_mw": round(charge, 2),
374
+ "grid_import_mw": round(grid_import, 2),
375
+ "grid_export_mw": round(export, 2),
376
+ "demand_response_mw": round(demand_response, 2),
377
+ "unserved_mw": round(unserved, 2),
378
+ "soc": round(soc, 3),
379
+ "price": hour_price,
380
+ }
381
+ )
382
+
383
+ reliability = round(1 - (total_unserved / total_demand if total_demand > 0 else 0), 3)
384
+ cost = round(total_cost, 3)
385
+ carbon = round(total_carbon, 3)
386
+ score = round((100 - cost) * weight_cost + (100 - abs(carbon)) * weight_carbon + reliability * 100 * weight_reliability, 2)
387
+
388
+ return {
389
+ "dispatch": {
390
+ "solar_mw": round(solar, 2),
391
+ "wind_mw": round(wind, 2),
392
+ "storage_soc_next": round(soc, 3),
393
+ "hourly": hourly,
394
+ },
395
+ "kpi": {"cost": cost, "carbon": carbon, "reliability": reliability, "score": score},
396
+ "notes": "已综合考虑电价、碳价、需求响应与并网约束",
397
+ "plan": plan,
398
+ "risk": risk,
399
+ }
400
+
401
+
402
+ def validate_dispatch(scenario, decision, tools):
403
+ issues = []
404
+ hourly = decision["dispatch"].get("hourly", [])
405
+ grid_limit = tools["grid"]["max_import_mw"]
406
+ for point in hourly:
407
+ supplied = (
408
+ point["solar_mw"]
409
+ + point["wind_mw"]
410
+ + point["storage_discharge_mw"]
411
+ + point["grid_import_mw"]
412
+ - point["storage_charge_mw"]
413
+ - point["grid_export_mw"]
414
+ )
415
+ if supplied + 0.01 < point["demand_mw"]:
416
+ issues.append(f"小时{point['hour']}供给不足")
417
+ if point["grid_import_mw"] > grid_limit + 0.01:
418
+ issues.append(f"小时{point['hour']}并网功率超限")
419
+ if not (0 <= point["soc"] <= 1):
420
+ issues.append(f"小时{point['hour']}储能SOC越界")
421
+ passed = len(issues) == 0
422
+ return {"passed": passed, "issues": issues or ["校验通过"]}
423
+
424
+
425
+ def iterate_adjustment(scenario, decision, tools):
426
+ dispatch = decision["dispatch"].copy()
427
+ hourly = []
428
+ grid_limit = tools["grid"]["max_import_mw"]
429
+ for point in dispatch.get("hourly", []):
430
+ if point["unserved_mw"] > 0:
431
+ extra = min(point["unserved_mw"], grid_limit - point["grid_import_mw"])
432
+ point["grid_import_mw"] = round(point["grid_import_mw"] + extra, 2)
433
+ point["unserved_mw"] = round(max(0.0, point["unserved_mw"] - extra), 2)
434
+ hourly.append(point)
435
+ dispatch["hourly"] = hourly
436
+ decision["dispatch"] = dispatch
437
+ return decision
438
+
439
+
440
+ def run_cycle(payload):
441
+ cycle_id = str(uuid.uuid4())
442
+ title = payload.get("title", "未命名调度")
443
+ scenario = {
444
+ "title": title,
445
+ "demand_mw": float(payload.get("demand_mw", 120)),
446
+ "solar_mw": float(payload.get("solar_mw", 40)),
447
+ "wind_mw": float(payload.get("wind_mw", 30)),
448
+ "storage_mwh": float(payload.get("storage_mwh", 80)),
449
+ "storage_soc": float(payload.get("storage_soc", 0.45)),
450
+ "priority": payload.get("priority", "成本优先"),
451
+ "notes": payload.get("notes", ""),
452
+ }
453
+
454
+ tools = {
455
+ "forecast": tool_forecast(scenario),
456
+ "price": tool_price(),
457
+ "grid": tool_grid_constraints(),
458
+ "response": tool_demand_response(),
459
+ }
460
+
461
+ db = get_db()
462
+ db.execute(
463
+ "INSERT INTO cycles (id, title, scenario, result, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",
464
+ (cycle_id, title, json.dumps(scenario, ensure_ascii=False), "{}", "running", now_ts()),
465
+ )
466
+ db.commit()
467
+
468
+ log_event(cycle_id, "system", "进入闭环:推理/决策/工具行动/状态记忆/校验/迭代")
469
+ log_event(cycle_id, "tool_action", f"工具预测: {json.dumps(tools, ensure_ascii=False)}")
470
+
471
+ plan = planner_agent(scenario, tools)
472
+ log_event(cycle_id, "reasoning", json.dumps(plan, ensure_ascii=False))
473
+
474
+ risk = risk_agent(scenario, tools, plan)
475
+ log_event(cycle_id, "reasoning", json.dumps(risk, ensure_ascii=False))
476
+
477
+ decision = optimizer_agent(scenario, tools, plan, risk)
478
+ log_event(cycle_id, "decision", json.dumps(decision, ensure_ascii=False))
479
+
480
+ validation = validate_dispatch(scenario, decision, tools)
481
+ log_event(cycle_id, "validation", json.dumps(validation, ensure_ascii=False))
482
+
483
+ iteration_count = 0
484
+ while not validation["passed"] and iteration_count < 2:
485
+ iteration_count += 1
486
+ decision = iterate_adjustment(scenario, decision, tools)
487
+ log_event(cycle_id, "iteration", f"第{iteration_count}次迭代调整")
488
+ validation = validate_dispatch(scenario, decision, tools)
489
+ log_event(cycle_id, "validation", json.dumps(validation, ensure_ascii=False))
490
+
491
+ memory_snapshot = {
492
+ "last_priority": scenario["priority"],
493
+ "last_cost": decision["kpi"]["cost"],
494
+ "last_carbon": decision["kpi"]["carbon"],
495
+ "last_reliability": decision["kpi"]["reliability"],
496
+ "iterations": iteration_count,
497
+ "last_score": decision["kpi"]["score"],
498
+ }
499
+ set_memory("microgrid_state", memory_snapshot)
500
+ log_event(cycle_id, "memory", json.dumps(memory_snapshot, ensure_ascii=False))
501
+
502
+ db.execute(
503
+ "INSERT INTO validations (cycle_id, passed, issues, created_at) VALUES (?, ?, ?, ?)",
504
+ (cycle_id, 1 if validation["passed"] else 0, json.dumps(validation["issues"], ensure_ascii=False), now_ts()),
505
+ )
506
+ db.execute(
507
+ "UPDATE cycles SET result = ?, status = ? WHERE id = ?",
508
+ (
509
+ json.dumps(
510
+ {
511
+ "scenario": scenario,
512
+ "decision": decision,
513
+ "validation": validation,
514
+ "memory": memory_snapshot,
515
+ "iterations": iteration_count,
516
+ },
517
+ ensure_ascii=False,
518
+ ),
519
+ "completed" if validation["passed"] else "needs_review",
520
+ cycle_id,
521
+ ),
522
+ )
523
+ db.commit()
524
+
525
+ return {
526
+ "id": cycle_id,
527
+ "status": "completed" if validation["passed"] else "needs_review",
528
+ "scenario": scenario,
529
+ "decision": decision,
530
+ "validation": validation,
531
+ "memory": memory_snapshot,
532
+ "iterations": iteration_count,
533
+ }
534
+
535
+
536
+ @app.route("/")
537
+ def index():
538
+ return render_template("index.html")
539
+
540
+
541
+ @app.route("/api/run_cycle", methods=["POST"])
542
+ def api_run_cycle():
543
+ payload = request.json or {}
544
+ result = run_cycle(payload)
545
+ return jsonify(result)
546
+
547
+
548
+ @app.route("/api/cycles", methods=["GET"])
549
+ def api_cycles():
550
+ db = get_db()
551
+ rows = db.execute(
552
+ "SELECT id, title, status, created_at FROM cycles ORDER BY created_at DESC LIMIT 30"
553
+ ).fetchall()
554
+ return jsonify(
555
+ [
556
+ {
557
+ "id": row["id"],
558
+ "title": row["title"],
559
+ "status": row["status"],
560
+ "created_at": row["created_at"],
561
+ }
562
+ for row in rows
563
+ ]
564
+ )
565
+
566
+
567
+ @app.route("/api/cycles/<cycle_id>", methods=["GET"])
568
+ def api_cycle_detail(cycle_id):
569
+ db = get_db()
570
+ row = db.execute("SELECT * FROM cycles WHERE id = ?", (cycle_id,)).fetchone()
571
+ if not row:
572
+ return jsonify({"error": "未找到任务"}), 404
573
+ result = json.loads(row["result"] or "{}")
574
+ return jsonify(result)
575
+
576
+
577
+ @app.route("/api/replay/<cycle_id>", methods=["GET"])
578
+ def api_replay(cycle_id):
579
+ db = get_db()
580
+ logs = db.execute(
581
+ "SELECT role, content, created_at FROM logs WHERE cycle_id = ? ORDER BY id ASC", (cycle_id,)
582
+ ).fetchall()
583
+ return jsonify(
584
+ [
585
+ {"role": row["role"], "content": row["content"], "created_at": row["created_at"]}
586
+ for row in logs
587
+ ]
588
+ )
589
+
590
+
591
+ @app.route("/api/memory", methods=["GET"])
592
+ def api_memory():
593
+ snapshot = get_memory("microgrid_state", {})
594
+ return jsonify(snapshot)
595
+
596
+
597
+ @app.route("/api/stats", methods=["GET"])
598
+ def api_stats():
599
+ db = get_db()
600
+ rows = db.execute("SELECT result, status, created_at FROM cycles").fetchall()
601
+ total = len(rows)
602
+ completed = sum(1 for r in rows if r["status"] == "completed")
603
+ costs = []
604
+ carbons = []
605
+ for row in rows:
606
+ try:
607
+ result = json.loads(row["result"] or "{}")
608
+ kpi = result.get("decision", {}).get("kpi", {})
609
+ if "cost" in kpi:
610
+ costs.append(kpi["cost"])
611
+ if "carbon" in kpi:
612
+ carbons.append(kpi["carbon"])
613
+ except Exception:
614
+ continue
615
+ return jsonify(
616
+ {
617
+ "total": total,
618
+ "completed": completed,
619
+ "avg_cost": round(sum(costs) / len(costs), 3) if costs else 0,
620
+ "avg_carbon": round(sum(carbons) / len(carbons), 3) if carbons else 0,
621
+ "last_run": rows[0]["created_at"] if rows else "暂无",
622
+ }
623
+ )
624
+
625
+
626
+ @app.route("/api/scenario_suggest", methods=["GET"])
627
+ def api_scenario_suggest():
628
+ last = get_memory("microgrid_state", {})
629
+ return jsonify(
630
+ {
631
+ "title": "智能建议场景",
632
+ "demand_mw": 110 + random.randint(-10, 12),
633
+ "solar_mw": 45 + random.randint(-8, 6),
634
+ "wind_mw": 30 + random.randint(-6, 6),
635
+ "storage_mwh": 90 + random.randint(-15, 15),
636
+ "storage_soc": round(0.35 + random.random() * 0.45, 2),
637
+ "priority": last.get("last_priority", "成本优先"),
638
+ "notes": "建议场景:参考历史记忆与负荷波动生成。",
639
+ }
640
+ )
641
+
642
+
643
+ def detect_binary(file_path):
644
+ with open(file_path, "rb") as f:
645
+ chunk = f.read(1024)
646
+ return b"\0" in chunk
647
+
648
+
649
+ def finalize_file(file_id, filename, storage_path, content_type):
650
+ size = os.path.getsize(storage_path)
651
+ sha256_hash = hashlib.sha256()
652
+ with open(storage_path, "rb") as f:
653
+ for block in iter(lambda: f.read(4096), b""):
654
+ sha256_hash.update(block)
655
+ is_binary = 1 if detect_binary(storage_path) else 0
656
+ db = get_db()
657
+ db.execute(
658
+ "INSERT INTO files (id, filename, size_bytes, sha256, content_type, is_binary, storage_path, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
659
+ (file_id, filename, size, sha256_hash.hexdigest(), content_type or "", is_binary, storage_path, now_ts()),
660
+ )
661
+ db.commit()
662
+ return {
663
+ "id": file_id,
664
+ "filename": filename,
665
+ "size_bytes": size,
666
+ "sha256": sha256_hash.hexdigest(),
667
+ "is_binary": bool(is_binary),
668
+ }
669
+
670
+
671
+ @app.route("/api/upload", methods=["POST"])
672
+ def api_upload():
673
+ if "file" not in request.files:
674
+ return jsonify({"error": "未检测到文件"}), 400
675
+ file = request.files["file"]
676
+ if not file.filename:
677
+ return jsonify({"error": "文件名为空"}), 400
678
+ filename = os.path.basename(file.filename)
679
+ file_id = str(uuid.uuid4())
680
+ storage_path = os.path.join(INSTANCE_DIR, f"{file_id}_{filename}")
681
+ file.save(storage_path)
682
+ payload = finalize_file(file_id, filename, storage_path, file.content_type)
683
+ return jsonify({"status": "ok", "file": payload})
684
+
685
+
686
+ @app.route("/api/upload_chunk", methods=["POST"])
687
+ def api_upload_chunk():
688
+ file_id = request.headers.get("X-File-Id")
689
+ filename = request.headers.get("X-File-Name")
690
+ total = int(request.headers.get("X-Chunk-Total", "1"))
691
+ index = int(request.headers.get("X-Chunk-Index", "0"))
692
+ if not file_id or not filename:
693
+ return jsonify({"error": "缺少文件标识"}), 400
694
+ chunk = request.data
695
+ if not chunk:
696
+ return jsonify({"error": "分片内容为空"}), 400
697
+ temp_path = os.path.join(INSTANCE_DIR, f"{file_id}.part")
698
+ with open(temp_path, "ab") as f:
699
+ f.write(chunk)
700
+ if index + 1 == total:
701
+ final_name = os.path.basename(filename)
702
+ final_path = os.path.join(INSTANCE_DIR, f"{file_id}_{final_name}")
703
+ os.replace(temp_path, final_path)
704
+ payload = finalize_file(file_id, final_name, final_path, request.headers.get("X-Content-Type"))
705
+ return jsonify({"status": "completed", "file": payload})
706
+ return jsonify({"status": "chunk_received", "index": index})
707
+
708
+
709
+ @app.route("/api/files", methods=["GET"])
710
+ def api_files():
711
+ db = get_db()
712
+ rows = db.execute(
713
+ "SELECT id, filename, size_bytes, sha256, is_binary, created_at FROM files ORDER BY created_at DESC LIMIT 50"
714
+ ).fetchall()
715
+ return jsonify(
716
+ [
717
+ {
718
+ "id": row["id"],
719
+ "filename": row["filename"],
720
+ "size_bytes": row["size_bytes"],
721
+ "sha256": row["sha256"],
722
+ "is_binary": bool(row["is_binary"]),
723
+ "created_at": row["created_at"],
724
+ }
725
+ for row in rows
726
+ ]
727
+ )
728
+
729
+
730
+ @app.route("/api/files/<file_id>", methods=["GET"])
731
+ def api_download(file_id):
732
+ db = get_db()
733
+ row = db.execute("SELECT storage_path, filename FROM files WHERE id = ?", (file_id,)).fetchone()
734
+ if not row:
735
+ return jsonify({"error": "未找到文件"}), 404
736
+ return send_file(row["storage_path"], as_attachment=True, download_name=row["filename"])
737
+
738
+
739
+ if __name__ == "__main__":
740
+ app.run(host="0.0.0.0", port=7860, debug=True)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ flask==3.0.3
2
+ requests==2.32.3
3
+ python-dotenv==1.0.1
4
+ gunicorn==22.0.0
templates/index.html ADDED
@@ -0,0 +1,547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1" />
6
+ <title>微电网调度闭环智能体</title>
7
+ <link rel="preconnect" href="https://cdn.jsdelivr.net" />
8
+ <script src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"></script>
9
+ <style>
10
+ :root {
11
+ color-scheme: dark;
12
+ --bg: #070c1f;
13
+ --surface: rgba(18, 29, 58, 0.75);
14
+ --stroke: rgba(120, 145, 208, 0.2);
15
+ --text: #e6eeff;
16
+ --muted: #9fb2da;
17
+ --accent: #7dd3fc;
18
+ --accent-strong: #38bdf8;
19
+ --warn: #fda4af;
20
+ }
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+ body {
25
+ margin: 0;
26
+ font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
27
+ background: radial-gradient(circle at top, #1a2a6c 0%, #070c1f 60%);
28
+ color: var(--text);
29
+ }
30
+ header {
31
+ padding: 28px 20px 12px;
32
+ text-align: center;
33
+ }
34
+ header h1 {
35
+ margin: 0 0 6px;
36
+ font-size: 26px;
37
+ }
38
+ header p {
39
+ margin: 0;
40
+ color: var(--muted);
41
+ font-size: 14px;
42
+ }
43
+ main {
44
+ max-width: 1100px;
45
+ margin: 0 auto;
46
+ padding: 16px 16px 40px;
47
+ display: grid;
48
+ gap: 16px;
49
+ }
50
+ .grid {
51
+ display: grid;
52
+ gap: 16px;
53
+ }
54
+ .grid.two {
55
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
56
+ }
57
+ .card {
58
+ background: var(--surface);
59
+ border: 1px solid var(--stroke);
60
+ border-radius: 16px;
61
+ padding: 16px;
62
+ backdrop-filter: blur(14px);
63
+ }
64
+ .card h3 {
65
+ margin-top: 0;
66
+ font-size: 18px;
67
+ }
68
+ label {
69
+ display: block;
70
+ margin-bottom: 12px;
71
+ color: var(--muted);
72
+ font-size: 13px;
73
+ }
74
+ input,
75
+ select,
76
+ textarea {
77
+ width: 100%;
78
+ margin-top: 6px;
79
+ padding: 10px 12px;
80
+ border-radius: 12px;
81
+ border: 1px solid transparent;
82
+ background: rgba(8, 16, 38, 0.7);
83
+ color: var(--text);
84
+ font-size: 14px;
85
+ }
86
+ textarea {
87
+ min-height: 88px;
88
+ resize: vertical;
89
+ }
90
+ button {
91
+ padding: 10px 16px;
92
+ background: linear-gradient(120deg, #0ea5e9, #6366f1);
93
+ border: none;
94
+ border-radius: 999px;
95
+ color: #fff;
96
+ font-weight: 600;
97
+ cursor: pointer;
98
+ width: 100%;
99
+ }
100
+ button:disabled {
101
+ opacity: 0.6;
102
+ cursor: not-allowed;
103
+ }
104
+ .pill {
105
+ display: inline-block;
106
+ padding: 4px 10px;
107
+ border-radius: 999px;
108
+ background: rgba(125, 211, 252, 0.15);
109
+ color: var(--accent);
110
+ font-size: 12px;
111
+ }
112
+ .kpi {
113
+ display: grid;
114
+ gap: 8px;
115
+ }
116
+ .kpi span {
117
+ color: var(--muted);
118
+ font-size: 13px;
119
+ }
120
+ .kpi strong {
121
+ font-size: 16px;
122
+ }
123
+ .log {
124
+ background: rgba(10, 18, 40, 0.6);
125
+ border: 1px solid var(--stroke);
126
+ border-radius: 12px;
127
+ padding: 10px 12px;
128
+ margin-bottom: 8px;
129
+ }
130
+ .log header {
131
+ padding: 0;
132
+ text-align: left;
133
+ font-size: 12px;
134
+ color: var(--muted);
135
+ margin-bottom: 6px;
136
+ }
137
+ .warn {
138
+ color: var(--warn);
139
+ }
140
+ .cycle-list button {
141
+ width: 100%;
142
+ margin-bottom: 8px;
143
+ background: rgba(56, 189, 248, 0.16);
144
+ color: var(--text);
145
+ }
146
+ .toolbar {
147
+ display: grid;
148
+ gap: 10px;
149
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
150
+ }
151
+ .stat-grid {
152
+ display: grid;
153
+ gap: 10px;
154
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
155
+ }
156
+ .stat-card {
157
+ padding: 10px 12px;
158
+ border-radius: 12px;
159
+ border: 1px solid var(--stroke);
160
+ background: rgba(10, 16, 36, 0.55);
161
+ }
162
+ .stat-card span {
163
+ display: block;
164
+ font-size: 12px;
165
+ color: var(--muted);
166
+ }
167
+ .stat-card strong {
168
+ font-size: 18px;
169
+ }
170
+ @media (max-width: 720px) {
171
+ header h1 {
172
+ font-size: 22px;
173
+ }
174
+ main {
175
+ padding: 12px;
176
+ }
177
+ }
178
+ </style>
179
+ </head>
180
+ <body>
181
+ <header>
182
+ <h1>微电网调度闭环智能体</h1>
183
+ <p>面向新能源微电网的推理/决策/行动/校验/迭代与回放系统</p>
184
+ </header>
185
+ <main>
186
+ <section class="grid two">
187
+ <article class="card">
188
+ <h3>新建调度任务</h3>
189
+ <form id="cycleForm">
190
+ <label>
191
+ 任务标题
192
+ <input name="title" placeholder="例如:园区A峰值调度" required />
193
+ </label>
194
+ <label>
195
+ 需求负荷(MW��
196
+ <input name="demand_mw" type="number" step="0.1" value="120" required />
197
+ </label>
198
+ <label>
199
+ 光伏出力(MW)
200
+ <input name="solar_mw" type="number" step="0.1" value="40" required />
201
+ </label>
202
+ <label>
203
+ 风电出力(MW)
204
+ <input name="wind_mw" type="number" step="0.1" value="30" required />
205
+ </label>
206
+ <label>
207
+ 储能容量(MWh)
208
+ <input name="storage_mwh" type="number" step="0.1" value="80" required />
209
+ </label>
210
+ <label>
211
+ 储能SOC(0-1)
212
+ <input name="storage_soc" type="number" step="0.01" value="0.45" required />
213
+ </label>
214
+ <label>
215
+ 优先级策略
216
+ <select name="priority">
217
+ <option value="成本优先">成本优先</option>
218
+ <option value="碳优先">碳优先</option>
219
+ <option value="可靠性优先">可靠性优先</option>
220
+ </select>
221
+ </label>
222
+ <label>
223
+ 业务备注(支持Markdown)
224
+ <textarea name="notes" placeholder="例如:需要保障冷链仓储负荷、可接受需求响应10%"></textarea>
225
+ </label>
226
+ <button type="submit" id="runBtn">生成闭环调度</button>
227
+ </form>
228
+ <div style="margin-top: 14px" class="toolbar">
229
+ <button type="button" id="suggestBtn">生成建议场景</button>
230
+ <button type="button" id="demoBtn">一键演示</button>
231
+ </div>
232
+ </article>
233
+ <article class="card">
234
+ <h3>闭环结果概览</h3>
235
+ <div id="overview">
236
+ <span class="pill">等待调度</span>
237
+ <div class="kpi" style="margin-top: 12px">
238
+ <span>成本</span>
239
+ <strong id="kpiCost">-</strong>
240
+ <span>碳排影响</span>
241
+ <strong id="kpiCarbon">-</strong>
242
+ <span>可靠性</span>
243
+ <strong id="kpiReliability">-</strong>
244
+ </div>
245
+ </div>
246
+ <div style="margin-top: 14px">
247
+ <h4>记忆快照</h4>
248
+ <div id="memoryBox" class="log"></div>
249
+ </div>
250
+ </article>
251
+ </section>
252
+
253
+ <section class="grid two">
254
+ <article class="card">
255
+ <h3>调度方案与校验</h3>
256
+ <div id="planBox" class="log"></div>
257
+ <div id="riskBox" class="log"></div>
258
+ <div id="dispatchBox" class="log"></div>
259
+ <div id="validationBox" class="log"></div>
260
+ </article>
261
+ <article class="card">
262
+ <h3>闭环日志回放</h3>
263
+ <div id="logList"></div>
264
+ </article>
265
+ </section>
266
+
267
+ <section class="grid two">
268
+ <article class="card">
269
+ <h3>历史调度</h3>
270
+ <div id="cycleList" class="cycle-list"></div>
271
+ </article>
272
+ <article class="card">
273
+ <h3>Markdown 预览</h3>
274
+ <div id="markdownPreview" class="log"></div>
275
+ </article>
276
+ </section>
277
+
278
+ <section class="grid two">
279
+ <article class="card">
280
+ <h3>运行统计</h3>
281
+ <div id="statGrid" class="stat-grid"></div>
282
+ <button type="button" id="refreshStatsBtn" style="margin-top: 10px">刷新统计</button>
283
+ </article>
284
+ <article class="card">
285
+ <h3>文件与数据集</h3>
286
+ <div class="toolbar">
287
+ <input type="file" id="fileInput" />
288
+ <button type="button" id="uploadBtn">上传文件</button>
289
+ </div>
290
+ <progress id="uploadProgress" value="0" max="100" style="width: 100%; margin-top: 10px"></progress>
291
+ <div id="fileList" style="margin-top: 12px"></div>
292
+ </article>
293
+ </section>
294
+ </main>
295
+
296
+ <script>
297
+ const md = window.markdownit({ html: false, breaks: true, linkify: true });
298
+ const form = document.getElementById("cycleForm");
299
+ const runBtn = document.getElementById("runBtn");
300
+ const suggestBtn = document.getElementById("suggestBtn");
301
+ const demoBtn = document.getElementById("demoBtn");
302
+ const kpiCost = document.getElementById("kpiCost");
303
+ const kpiCarbon = document.getElementById("kpiCarbon");
304
+ const kpiReliability = document.getElementById("kpiReliability");
305
+ const dispatchBox = document.getElementById("dispatchBox");
306
+ const validationBox = document.getElementById("validationBox");
307
+ const planBox = document.getElementById("planBox");
308
+ const riskBox = document.getElementById("riskBox");
309
+ const logList = document.getElementById("logList");
310
+ const cycleList = document.getElementById("cycleList");
311
+ const memoryBox = document.getElementById("memoryBox");
312
+ const markdownPreview = document.getElementById("markdownPreview");
313
+ const statGrid = document.getElementById("statGrid");
314
+ const refreshStatsBtn = document.getElementById("refreshStatsBtn");
315
+ const fileInput = document.getElementById("fileInput");
316
+ const uploadBtn = document.getElementById("uploadBtn");
317
+ const uploadProgress = document.getElementById("uploadProgress");
318
+ const fileList = document.getElementById("fileList");
319
+
320
+ function uuid() {
321
+ if (window.crypto && window.crypto.randomUUID) {
322
+ return window.crypto.randomUUID();
323
+ }
324
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
325
+ const r = (Math.random() * 16) | 0;
326
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
327
+ return v.toString(16);
328
+ });
329
+ }
330
+
331
+ function renderMarkdown(target, content) {
332
+ target.innerHTML = md.render(content || "暂无内容");
333
+ }
334
+
335
+ function renderDecision(result) {
336
+ const kpi = result.decision?.kpi || {};
337
+ kpiCost.textContent = kpi.cost ?? "-";
338
+ kpiCarbon.textContent = kpi.carbon ?? "-";
339
+ kpiReliability.textContent = kpi.reliability ?? "-";
340
+ const plan = result.decision?.plan || result.plan || {};
341
+ const risk = result.decision?.risk || result.risk || {};
342
+ renderMarkdown(planBox, "#### 推理规划\n\n```json\n" + JSON.stringify(plan, null, 2) + "\n```");
343
+ renderMarkdown(riskBox, "#### 风险评估\n\n```json\n" + JSON.stringify(risk, null, 2) + "\n```");
344
+ const hourly = result.decision?.dispatch?.hourly || [];
345
+ if (hourly.length > 0) {
346
+ const head = "|小时|负荷|光伏|风电|储能放电|储能充电|并网|需求响应|SOC|电价|\n|---|---|---|---|---|---|---|---|---|---|";
347
+ const rows = hourly
348
+ .map(
349
+ (h) =>
350
+ `|${h.hour}|${h.demand_mw}|${h.solar_mw}|${h.wind_mw}|${h.storage_discharge_mw}|${h.storage_charge_mw}|${h.grid_import_mw}|${h.demand_response_mw}|${h.soc}|${h.price}|`
351
+ )
352
+ .join("\n");
353
+ renderMarkdown(dispatchBox, "#### 小时级调度表\n\n" + head + "\n" + rows);
354
+ } else {
355
+ renderMarkdown(dispatchBox, "暂无小时级调度结果");
356
+ }
357
+ const validation = result.validation || {};
358
+ const message = validation.passed ? "校验通过" : "校验未通过";
359
+ const issues = (validation.issues || []).join("、");
360
+ renderMarkdown(validationBox, `**${message}**\n\n${issues}`);
361
+ renderMarkdown(memoryBox, "```json\n" + JSON.stringify(result.memory || {}, null, 2) + "\n```");
362
+ const notes = result.scenario?.notes || "";
363
+ renderMarkdown(markdownPreview, notes || "暂无备注");
364
+ }
365
+
366
+ function renderLogs(logs) {
367
+ logList.innerHTML = "";
368
+ logs.forEach((item) => {
369
+ const wrap = document.createElement("div");
370
+ wrap.className = "log";
371
+ const header = document.createElement("header");
372
+ header.textContent = `${item.role} · ${item.created_at}`;
373
+ const body = document.createElement("div");
374
+ body.innerHTML = md.render(item.content || "");
375
+ wrap.appendChild(header);
376
+ wrap.appendChild(body);
377
+ logList.appendChild(wrap);
378
+ });
379
+ }
380
+
381
+ async function loadCycles() {
382
+ const res = await fetch("/api/cycles");
383
+ const data = await res.json();
384
+ cycleList.innerHTML = "";
385
+ data.forEach((cycle) => {
386
+ const btn = document.createElement("button");
387
+ btn.type = "button";
388
+ btn.textContent = `${cycle.title} · ${cycle.status}`;
389
+ btn.addEventListener("click", async () => {
390
+ const detailRes = await fetch(`/api/cycles/${cycle.id}`);
391
+ const detail = await detailRes.json();
392
+ renderDecision(detail);
393
+ const replayRes = await fetch(`/api/replay/${cycle.id}`);
394
+ const replay = await replayRes.json();
395
+ renderLogs(replay);
396
+ });
397
+ cycleList.appendChild(btn);
398
+ });
399
+ }
400
+
401
+ async function loadMemory() {
402
+ const res = await fetch("/api/memory");
403
+ const data = await res.json();
404
+ renderMarkdown(memoryBox, "```json\n" + JSON.stringify(data || {}, null, 2) + "\n```");
405
+ }
406
+
407
+ async function loadStats() {
408
+ const res = await fetch("/api/stats");
409
+ const data = await res.json();
410
+ statGrid.innerHTML = "";
411
+ const items = [
412
+ { label: "任务总数", value: data.total },
413
+ { label: "完成任务", value: data.completed },
414
+ { label: "平均成本", value: data.avg_cost },
415
+ { label: "平均碳排", value: data.avg_carbon },
416
+ { label: "最近运行", value: data.last_run },
417
+ ];
418
+ items.forEach((item) => {
419
+ const card = document.createElement("div");
420
+ card.className = "stat-card";
421
+ const span = document.createElement("span");
422
+ span.textContent = item.label;
423
+ const strong = document.createElement("strong");
424
+ strong.textContent = item.value;
425
+ card.appendChild(span);
426
+ card.appendChild(strong);
427
+ statGrid.appendChild(card);
428
+ });
429
+ }
430
+
431
+ async function loadFiles() {
432
+ const res = await fetch("/api/files");
433
+ const data = await res.json();
434
+ fileList.innerHTML = "";
435
+ data.forEach((file) => {
436
+ const row = document.createElement("div");
437
+ row.className = "log";
438
+ row.innerHTML = `<strong>${file.filename}</strong><br/>大小: ${Math.round(
439
+ file.size_bytes / 1024
440
+ )} KB · 二进制: ${file.is_binary ? "是" : "否"}<br/>SHA256: ${file.sha256}`;
441
+ fileList.appendChild(row);
442
+ });
443
+ }
444
+
445
+ async function fillSuggestedScenario() {
446
+ const res = await fetch("/api/scenario_suggest");
447
+ const data = await res.json();
448
+ Object.keys(data).forEach((key) => {
449
+ const field = form.querySelector(`[name="${key}"]`);
450
+ if (field) field.value = data[key];
451
+ });
452
+ }
453
+
454
+ async function runDemo() {
455
+ const payload = {
456
+ title: "一键演示调度",
457
+ demand_mw: 125,
458
+ solar_mw: 38,
459
+ wind_mw: 26,
460
+ storage_mwh: 78,
461
+ storage_soc: 0.4,
462
+ priority: "可靠性优先",
463
+ notes: "演示场景:保障核心负荷、允许有限需求响应。",
464
+ };
465
+ const res = await fetch("/api/run_cycle", {
466
+ method: "POST",
467
+ headers: { "Content-Type": "application/json" },
468
+ body: JSON.stringify(payload),
469
+ });
470
+ const data = await res.json();
471
+ renderDecision(data);
472
+ const replayRes = await fetch(`/api/replay/${data.id}`);
473
+ renderLogs(await replayRes.json());
474
+ await loadCycles();
475
+ await loadMemory();
476
+ await loadStats();
477
+ }
478
+
479
+ async function uploadFile(file) {
480
+ if (!file) return;
481
+ uploadProgress.value = 0;
482
+ const chunkSize = 4 * 1024 * 1024;
483
+ const totalChunks = Math.ceil(file.size / chunkSize);
484
+ if (file.size <= chunkSize) {
485
+ const formData = new FormData();
486
+ formData.append("file", file);
487
+ const res = await fetch("/api/upload", { method: "POST", body: formData });
488
+ await res.json();
489
+ uploadProgress.value = 100;
490
+ await loadFiles();
491
+ return;
492
+ }
493
+ const fileId = uuid();
494
+ for (let i = 0; i < totalChunks; i++) {
495
+ const start = i * chunkSize;
496
+ const end = Math.min(file.size, start + chunkSize);
497
+ const chunk = file.slice(start, end);
498
+ await fetch("/api/upload_chunk", {
499
+ method: "POST",
500
+ headers: {
501
+ "X-File-Id": fileId,
502
+ "X-File-Name": file.name,
503
+ "X-Chunk-Index": i,
504
+ "X-Chunk-Total": totalChunks,
505
+ "X-Content-Type": file.type,
506
+ },
507
+ body: chunk,
508
+ });
509
+ uploadProgress.value = Math.round(((i + 1) / totalChunks) * 100);
510
+ }
511
+ await loadFiles();
512
+ }
513
+
514
+ form.addEventListener("submit", async (event) => {
515
+ event.preventDefault();
516
+ runBtn.disabled = true;
517
+ const payload = Object.fromEntries(new FormData(form).entries());
518
+ const res = await fetch("/api/run_cycle", {
519
+ method: "POST",
520
+ headers: { "Content-Type": "application/json" },
521
+ body: JSON.stringify(payload),
522
+ });
523
+ const data = await res.json();
524
+ renderDecision(data);
525
+ const replayRes = await fetch(`/api/replay/${data.id}`);
526
+ renderLogs(await replayRes.json());
527
+ await loadCycles();
528
+ await loadMemory();
529
+ runBtn.disabled = false;
530
+ });
531
+
532
+ suggestBtn.addEventListener("click", fillSuggestedScenario);
533
+ demoBtn.addEventListener("click", runDemo);
534
+ refreshStatsBtn.addEventListener("click", loadStats);
535
+ uploadBtn.addEventListener("click", () => uploadFile(fileInput.files[0]));
536
+
537
+ loadCycles();
538
+ loadMemory();
539
+ loadStats();
540
+ loadFiles();
541
+ renderMarkdown(dispatchBox, "等待调度结果");
542
+ renderMarkdown(validationBox, "等待校验结果");
543
+ renderMarkdown(planBox, "等待推理规划");
544
+ renderMarkdown(riskBox, "等待风险评估");
545
+ </script>
546
+ </body>
547
+ </html>