Sheldon123z commited on
Commit
f4c1c59
·
verified ·
1 Parent(s): bdad00b

Deploy PowerZoo-SmartGrid HuggingFace Space

Browse files
Files changed (4) hide show
  1. README.md +23 -6
  2. __pycache__/app.cpython-310.pyc +0 -0
  3. app.py +895 -0
  4. requirements.txt +4 -0
README.md CHANGED
@@ -1,12 +1,29 @@
1
  ---
2
- title: PowerZoo SmartGrid
3
- emoji: 📉
4
- colorFrom: red
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.5.1
8
  app_file: app.py
9
  pinned: false
 
 
 
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: PowerZoo SmartGrid - PV Integration
3
+ emoji:
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 4.44.1
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ tags:
12
+ - reinforcement-learning
13
+ - multi-agent
14
+ - power-systems
15
+ - smart-grid
16
+ - CMDP
17
+ - OpenDSS
18
  ---
19
 
20
+ # PowerZoo SmartGrid: Modular PV Integration Environment
21
+
22
+ Interactive demo for the SmartGrid environment in PowerZoo.
23
+
24
+ Features homogeneous agents with CMDP framework (Lagrangian relaxation),
25
+ 360-step annual episodes, and OOP component-based circuit modeling.
26
+
27
+ **Paper**: IEEE Transactions on Smart Grid, 2025
28
+
29
+ **GitHub**: [PowerZoo Repository](https://github.com/XJTU-RL/PowerZoo)
__pycache__/app.cpython-310.pyc ADDED
Binary file (20.9 kB). View file
 
app.py ADDED
@@ -0,0 +1,895 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PowerZoo SmartGrid: Interactive CMDP Environment Demo
3
+ HuggingFace Spaces application with Gradio + Plotly.
4
+
5
+ 5 Tabs: Overview | Voltage Heatmap | Lagrangian Trajectory | Component Status | Training Dashboard
6
+
7
+ SmartGrid is a modular PV integration environment using CMDP (Constrained MDP)
8
+ with Lagrangian relaxation. 360-step annual episodes (1 step = 1 day),
9
+ homogeneous agents controlling capacitors, regulators, batteries, and PV.
10
+ """
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ import plotly.graph_objects as go
15
+ from plotly.subplots import make_subplots
16
+
17
+ import gradio as gr
18
+
19
+ # === Monkey-patch: fix Gradio additionalProperties schema error with Plotly ===
20
+ _original_plot_init = gr.Plot.__init__
21
+
22
+
23
+ def _patched_plot_init(self, *args, **kwargs):
24
+ _original_plot_init(self, *args, **kwargs)
25
+ if hasattr(self, "schema") and isinstance(self.schema, dict):
26
+ self.schema.pop("additionalProperties", None)
27
+
28
+
29
+ gr.Plot.__init__ = _patched_plot_init
30
+
31
+
32
+ # === Color Palette ===
33
+ COLORS = {
34
+ "primary": "#10B981", # emerald
35
+ "secondary": "#059669", # emerald dark
36
+ "accent": "#06B6D4", # cyan
37
+ "warning": "#F59E0B", # amber
38
+ "danger": "#EF4444", # red
39
+ "bg_card": "rgba(16, 185, 129, 0.05)",
40
+ "grid": "rgba(255, 255, 255, 0.08)",
41
+ "text": "#E2E8F0",
42
+ "text_dim": "#94A3B8",
43
+ }
44
+
45
+ PLOTLY_LAYOUT_DEFAULTS = dict(
46
+ template="plotly_dark",
47
+ paper_bgcolor="rgba(0,0,0,0)",
48
+ plot_bgcolor="rgba(0,0,0,0)",
49
+ font=dict(family="Inter, system-ui, sans-serif", color=COLORS["text"]),
50
+ margin=dict(l=60, r=30, t=50, b=50),
51
+ xaxis=dict(gridcolor=COLORS["grid"], zerolinecolor=COLORS["grid"]),
52
+ yaxis=dict(gridcolor=COLORS["grid"], zerolinecolor=COLORS["grid"]),
53
+ )
54
+
55
+ # Deterministic RNG for reproducible demo data
56
+ RNG = np.random.default_rng(seed=42)
57
+
58
+
59
+ # ============================================================
60
+ # Demo Data Generators
61
+ # ============================================================
62
+
63
+ BUS_NAMES_13 = [
64
+ "650", "632", "633", "634", "645", "646",
65
+ "671", "680", "684", "611", "652", "692", "675",
66
+ ]
67
+
68
+
69
+ def generate_voltage_heatmap_data() -> np.ndarray:
70
+ """Generate 360x13 voltage matrix with seasonal PV patterns.
71
+
72
+ Summer months show higher voltage from PV injection;
73
+ winter shows lower voltage from increased load.
74
+
75
+ Returns:
76
+ np.ndarray: shape (360, 13), voltage in per-unit.
77
+ """
78
+ days = np.arange(360)
79
+ n_buses = len(BUS_NAMES_13)
80
+
81
+ # Base voltage profile: sinusoidal annual pattern
82
+ # Summer peak (day ~180) pushes voltage up from PV generation
83
+ seasonal = 0.025 * np.sin(2 * np.pi * (days - 90) / 360)
84
+
85
+ # Per-bus baseline offset (some buses naturally higher/lower)
86
+ bus_offset = RNG.uniform(-0.015, 0.015, size=n_buses)
87
+
88
+ # Build matrix
89
+ voltage = np.ones((360, n_buses))
90
+ for b in range(n_buses):
91
+ voltage[:, b] += seasonal + bus_offset[b]
92
+
93
+ # Add daily noise
94
+ noise = RNG.normal(0, 0.008, size=(360, n_buses))
95
+ voltage += noise
96
+
97
+ # Inject realistic violations:
98
+ # Summer PV overvoltage on downstream buses (indices 6-12)
99
+ for b in range(6, n_buses):
100
+ summer_mask = (days >= 120) & (days <= 240)
101
+ voltage[summer_mask, b] += RNG.uniform(0.02, 0.045, size=summer_mask.sum())
102
+
103
+ # Winter undervoltage on load-heavy buses (indices 3, 10, 12)
104
+ for b in [3, 10, 12]:
105
+ winter_mask = (days <= 60) | (days >= 300)
106
+ voltage[winter_mask, b] -= RNG.uniform(0.01, 0.035, size=winter_mask.sum())
107
+
108
+ return np.clip(voltage, 0.90, 1.12)
109
+
110
+
111
+ def generate_lagrangian_data(n_episodes: int = 500) -> dict[str, np.ndarray]:
112
+ """Generate CMDP convergence curves for Lagrangian multiplier and constraint violation.
113
+
114
+ Lambda starts high (~10) and converges to ~2.
115
+ Violation starts at ~15% and drops below 2%.
116
+
117
+ Returns:
118
+ dict with keys: episodes, lambda_values, violation_rate
119
+ """
120
+ episodes = np.arange(n_episodes)
121
+
122
+ # Lambda convergence: exponential decay + noise
123
+ lambda_base = 2.0 + 8.0 * np.exp(-episodes / 80)
124
+ lambda_noise = RNG.normal(0, 0.3, size=n_episodes) * np.exp(-episodes / 200)
125
+ lambda_values = np.clip(lambda_base + lambda_noise, 0.5, 12.0)
126
+
127
+ # Constraint violation rate: sigmoid-like decrease
128
+ violation_base = 0.15 / (1 + np.exp((episodes - 100) / 40))
129
+ violation_noise = RNG.uniform(-0.005, 0.005, size=n_episodes)
130
+ violation_rate = np.clip(violation_base + violation_noise + 0.012, 0.0, 0.20)
131
+ # Final episodes settle below 2%
132
+ violation_rate[400:] = np.clip(violation_rate[400:] * 0.6, 0.005, 0.02)
133
+
134
+ return {
135
+ "episodes": episodes,
136
+ "lambda_values": lambda_values,
137
+ "violation_rate": violation_rate * 100, # percentage
138
+ }
139
+
140
+
141
+ def generate_component_data(day_of_year: int) -> dict[str, np.ndarray]:
142
+ """Generate 24-hour component operation data for a given day.
143
+
144
+ Seasonal variation affects PV output and battery cycling.
145
+
146
+ Args:
147
+ day_of_year: 0-indexed day (0=Jan1, 90=Apr1, 181=Jul1, 272=Oct1).
148
+
149
+ Returns:
150
+ dict with keys: hours, cap_kvar, reg_tap, battery_soc, pv_kw, pv_curtail
151
+ """
152
+ hours = np.arange(24)
153
+
154
+ # Season factor (0=winter, 1=summer peak)
155
+ season_factor = 0.5 + 0.5 * np.sin(2 * np.pi * (day_of_year - 90) / 360)
156
+
157
+ # Capacitor reactive power: switched based on load/voltage
158
+ # Higher in afternoon, lower at night
159
+ load_pattern = np.array([
160
+ 0.3, 0.25, 0.2, 0.2, 0.25, 0.35, 0.5, 0.65,
161
+ 0.75, 0.8, 0.85, 0.9, 0.95, 1.0, 0.95, 0.9,
162
+ 0.85, 0.9, 0.95, 0.85, 0.7, 0.55, 0.45, 0.35,
163
+ ])
164
+ cap_kvar = load_pattern * 300 + RNG.normal(0, 15, size=24)
165
+ cap_kvar = np.clip(cap_kvar, 0, 400)
166
+
167
+ # Regulator tap position: -16 to +16, tracks voltage deviation
168
+ reg_base = np.array([
169
+ 2, 2, 3, 3, 2, 1, 0, -1,
170
+ -2, -3, -4, -5, -6, -7, -6, -5,
171
+ -4, -3, -2, -1, 0, 1, 2, 2,
172
+ ])
173
+ # PV pushes tap down in summer
174
+ reg_tap = reg_base - int(season_factor * 4) + RNG.integers(-1, 2, size=24)
175
+ reg_tap = np.clip(reg_tap, -16, 16)
176
+
177
+ # Battery SOC: charges from PV midday, discharges evening peak
178
+ soc = np.zeros(24)
179
+ soc[0] = 50 + season_factor * 10 # initial SOC
180
+ for h in range(1, 24):
181
+ if 9 <= h <= 15: # charging from PV
182
+ soc[h] = soc[h - 1] + (3.0 + season_factor * 2.0) + RNG.normal(0, 0.5)
183
+ elif 17 <= h <= 21: # evening discharge
184
+ soc[h] = soc[h - 1] - (4.0 + season_factor * 1.5) + RNG.normal(0, 0.5)
185
+ else:
186
+ soc[h] = soc[h - 1] + RNG.normal(0, 0.3)
187
+ soc = np.clip(soc, 10, 95)
188
+
189
+ # PV output: bell curve centered at noon, scaled by season
190
+ pv_peak = 200 + season_factor * 300 # kW
191
+ pv_raw = pv_peak * np.exp(-0.5 * ((hours - 12) / 2.8) ** 2)
192
+ pv_raw[:6] = 0
193
+ pv_raw[19:] = 0
194
+ pv_noise = RNG.normal(0, 10, size=24) * (pv_raw > 0)
195
+ pv_kw = np.clip(pv_raw + pv_noise, 0, 600)
196
+
197
+ # PV curtailment: only in summer midday when overvoltage risk
198
+ pv_curtail = np.zeros(24)
199
+ if season_factor > 0.6:
200
+ curtail_hours = (hours >= 10) & (hours <= 15)
201
+ pv_curtail[curtail_hours] = pv_kw[curtail_hours] * RNG.uniform(0.05, 0.20, size=curtail_hours.sum())
202
+
203
+ return {
204
+ "hours": hours,
205
+ "cap_kvar": cap_kvar,
206
+ "reg_tap": reg_tap.astype(float),
207
+ "battery_soc": soc,
208
+ "pv_kw": pv_kw,
209
+ "pv_curtail": pv_curtail,
210
+ }
211
+
212
+
213
+ def generate_training_data(n_episodes: int = 500) -> dict[str, np.ndarray]:
214
+ """Generate CMDP training curves: reward, Lagrangian objective decomposition, constraint rate.
215
+
216
+ Returns:
217
+ dict with keys: episodes, rewards, primal_obj, dual_penalty,
218
+ lagrangian_obj, constraint_satisfaction
219
+ """
220
+ episodes = np.arange(n_episodes)
221
+
222
+ # Episode reward: starts bad, improves with noise
223
+ reward_base = -50 + 45 * (1 - np.exp(-episodes / 120))
224
+ reward_noise = RNG.normal(0, 3.0, size=n_episodes) * np.exp(-episodes / 300)
225
+ rewards = reward_base + reward_noise
226
+
227
+ # Primal objective J(pi): the reward part of CMDP
228
+ primal_obj = rewards.copy()
229
+
230
+ # Lagrangian multiplier (same trajectory as in lagrangian data)
231
+ lambda_vals = 2.0 + 8.0 * np.exp(-episodes / 80)
232
+ lambda_noise = RNG.normal(0, 0.2, size=n_episodes) * np.exp(-episodes / 200)
233
+ lambda_vals = np.clip(lambda_vals + lambda_noise, 0.5, 12.0)
234
+
235
+ # Constraint cost g(pi): violation magnitude
236
+ g_pi = 0.12 / (1 + np.exp((episodes - 100) / 40))
237
+ g_noise = RNG.uniform(-0.003, 0.003, size=n_episodes)
238
+ g_pi = np.clip(g_pi + g_noise + 0.008, 0.001, 0.2)
239
+ g_pi[400:] = np.clip(g_pi[400:] * 0.5, 0.002, 0.015)
240
+
241
+ # Dual penalty: lambda * g(pi)
242
+ dual_penalty = lambda_vals * g_pi * 100 # scaled for visibility
243
+
244
+ # Lagrangian objective: J(pi) - lambda * g(pi)
245
+ lagrangian_obj = primal_obj - dual_penalty
246
+
247
+ # Constraint satisfaction rate: 1 - violation_rate
248
+ violation_rate = g_pi / 0.12 # normalized
249
+ constraint_satisfaction = np.clip((1 - violation_rate) * 100, 50, 100)
250
+ constraint_satisfaction[350:] = np.clip(
251
+ constraint_satisfaction[350:] + RNG.uniform(0, 2, size=150), 96, 100
252
+ )
253
+
254
+ return {
255
+ "episodes": episodes,
256
+ "rewards": rewards,
257
+ "primal_obj": primal_obj,
258
+ "dual_penalty": dual_penalty,
259
+ "lagrangian_obj": lagrangian_obj,
260
+ "constraint_satisfaction": constraint_satisfaction,
261
+ }
262
+
263
+
264
+ # Pre-generate all demo data at module load
265
+ VOLTAGE_DATA = generate_voltage_heatmap_data()
266
+ LAGRANGIAN_DATA = generate_lagrangian_data()
267
+ TRAINING_DATA = generate_training_data()
268
+
269
+
270
+ # ============================================================
271
+ # Plot Factory Functions
272
+ # ============================================================
273
+
274
+ def plot_voltage_heatmap(time_window: str = "Full Year") -> go.Figure:
275
+ """Create voltage heatmap with bus names on y-axis and days on x-axis.
276
+
277
+ Args:
278
+ time_window: one of 'Full Year', 'Q1', 'Q2', 'Q3', 'Q4'.
279
+
280
+ Returns:
281
+ Plotly Figure with annotated heatmap.
282
+ """
283
+ # Slice by quarter
284
+ slices = {
285
+ "Full Year": (0, 360),
286
+ "Q1 (Jan-Mar)": (0, 90),
287
+ "Q2 (Apr-Jun)": (90, 180),
288
+ "Q3 (Jul-Sep)": (180, 270),
289
+ "Q4 (Oct-Dec)": (270, 360),
290
+ }
291
+ start, end = slices.get(time_window, (0, 360))
292
+ data = VOLTAGE_DATA[start:end, :].T # shape: (n_buses, n_days)
293
+ days = list(range(start, end))
294
+
295
+ # Custom colorscale: blue (low) -> green (normal) -> red (high)
296
+ colorscale = [
297
+ [0.0, "#2563EB"], # blue: severe undervoltage
298
+ [0.25, "#3B82F6"], # blue: undervoltage
299
+ [0.40, "#10B981"], # green: entering safe zone
300
+ [0.50, "#059669"], # dark green: nominal 1.0 pu
301
+ [0.60, "#10B981"], # green: leaving safe zone
302
+ [0.75, "#F59E0B"], # amber: overvoltage warning
303
+ [1.0, "#EF4444"], # red: severe overvoltage
304
+ ]
305
+
306
+ fig = go.Figure(data=go.Heatmap(
307
+ z=data,
308
+ x=days,
309
+ y=BUS_NAMES_13,
310
+ colorscale=colorscale,
311
+ zmin=0.92,
312
+ zmax=1.10,
313
+ colorbar=dict(
314
+ title=dict(text="Voltage (p.u.)", side="right"),
315
+ tickvals=[0.93, 0.95, 1.00, 1.05, 1.08],
316
+ ticktext=["0.93", "0.95", "1.00", "1.05", "1.08"],
317
+ ),
318
+ hovertemplate=(
319
+ "Day: %{x}<br>"
320
+ "Bus: %{y}<br>"
321
+ "Voltage: %{z:.4f} p.u."
322
+ "<extra></extra>"
323
+ ),
324
+ ))
325
+
326
+ # Add violation boundary lines
327
+ fig.add_hline(y=None) # hlines not applicable for heatmap y
328
+ # Instead, add shapes for voltage limit annotations
329
+ fig.add_annotation(
330
+ text="V_min=0.95 | V_max=1.05",
331
+ xref="paper", yref="paper",
332
+ x=1.0, y=1.05,
333
+ showarrow=False,
334
+ font=dict(size=11, color=COLORS["warning"]),
335
+ xanchor="right",
336
+ )
337
+
338
+ layout_overrides = {**PLOTLY_LAYOUT_DEFAULTS}
339
+ layout_overrides["yaxis"] = dict(
340
+ gridcolor=COLORS["grid"],
341
+ zerolinecolor=COLORS["grid"],
342
+ type="category",
343
+ title_text="Bus Name",
344
+ )
345
+ fig.update_layout(
346
+ **layout_overrides,
347
+ height=520,
348
+ title=f"Bus Voltage Profile - {time_window}",
349
+ xaxis_title="Day of Year",
350
+ )
351
+ return fig
352
+
353
+
354
+ def plot_lagrangian_trajectory() -> go.Figure:
355
+ """Create dual subplot: lambda convergence + constraint violation over episodes.
356
+
357
+ Returns:
358
+ Plotly Figure with 1x2 subplots.
359
+ """
360
+ data = LAGRANGIAN_DATA
361
+ ep = data["episodes"]
362
+
363
+ fig = make_subplots(
364
+ rows=1, cols=2,
365
+ subplot_titles=(
366
+ "Lagrangian Multiplier (lambda) Convergence",
367
+ "Voltage Constraint Violation Rate",
368
+ ),
369
+ horizontal_spacing=0.12,
370
+ )
371
+
372
+ # Lambda convergence
373
+ fig.add_trace(
374
+ go.Scatter(
375
+ x=ep, y=data["lambda_values"],
376
+ mode="lines",
377
+ name="lambda",
378
+ line=dict(color=COLORS["primary"], width=1.8),
379
+ opacity=0.7,
380
+ ),
381
+ row=1, col=1,
382
+ )
383
+ # Smoothed lambda (moving average)
384
+ window = 20
385
+ lambda_smooth = np.convolve(data["lambda_values"], np.ones(window) / window, mode="valid")
386
+ fig.add_trace(
387
+ go.Scatter(
388
+ x=ep[window - 1:], y=lambda_smooth,
389
+ mode="lines",
390
+ name="lambda (smoothed)",
391
+ line=dict(color=COLORS["warning"], width=2.5),
392
+ ),
393
+ row=1, col=1,
394
+ )
395
+ # Target lambda reference
396
+ fig.add_hline(
397
+ y=2.0, line_dash="dot", line_color=COLORS["text_dim"],
398
+ annotation_text="converged ~2.0",
399
+ annotation_position="bottom right",
400
+ row=1, col=1,
401
+ )
402
+
403
+ # Constraint violation rate
404
+ fig.add_trace(
405
+ go.Scatter(
406
+ x=ep, y=data["violation_rate"],
407
+ mode="lines",
408
+ name="violation %",
409
+ line=dict(color=COLORS["danger"], width=1.8),
410
+ opacity=0.7,
411
+ ),
412
+ row=1, col=2,
413
+ )
414
+ # Smoothed violation
415
+ viol_smooth = np.convolve(data["violation_rate"], np.ones(window) / window, mode="valid")
416
+ fig.add_trace(
417
+ go.Scatter(
418
+ x=ep[window - 1:], y=viol_smooth,
419
+ mode="lines",
420
+ name="violation % (smoothed)",
421
+ line=dict(color=COLORS["accent"], width=2.5),
422
+ ),
423
+ row=1, col=2,
424
+ )
425
+ # Target violation threshold
426
+ fig.add_hline(
427
+ y=2.0, line_dash="dot", line_color=COLORS["text_dim"],
428
+ annotation_text="target < 2%",
429
+ annotation_position="bottom right",
430
+ row=1, col=2,
431
+ )
432
+
433
+ fig.update_xaxes(title_text="Training Episode", row=1, col=1)
434
+ fig.update_xaxes(title_text="Training Episode", row=1, col=2)
435
+ fig.update_yaxes(title_text="Lambda Value", row=1, col=1)
436
+ fig.update_yaxes(title_text="Violation Rate (%)", row=1, col=2)
437
+
438
+ fig.update_layout(
439
+ **PLOTLY_LAYOUT_DEFAULTS,
440
+ height=450,
441
+ showlegend=True,
442
+ legend=dict(orientation="h", yanchor="bottom", y=1.08, xanchor="center", x=0.5),
443
+ )
444
+ return fig
445
+
446
+
447
+ def plot_component_status(season: str = "Day 1 (Winter)") -> go.Figure:
448
+ """Create grouped bar/line chart showing 24-hour component operation.
449
+
450
+ Args:
451
+ season: one of the seasonal day selections.
452
+
453
+ Returns:
454
+ Plotly Figure with 2x2 subplots for each component type.
455
+ """
456
+ day_map = {
457
+ "Day 1 (Winter)": 1,
458
+ "Day 91 (Spring)": 91,
459
+ "Day 182 (Summer)": 182,
460
+ "Day 273 (Autumn)": 273,
461
+ }
462
+ day = day_map.get(season, 1)
463
+ data = generate_component_data(day)
464
+ hours = data["hours"]
465
+
466
+ fig = make_subplots(
467
+ rows=2, cols=2,
468
+ subplot_titles=(
469
+ "Capacitor Reactive Power",
470
+ "Regulator Tap Position",
471
+ "Battery State of Charge",
472
+ "PV Output & Curtailment",
473
+ ),
474
+ vertical_spacing=0.15,
475
+ horizontal_spacing=0.10,
476
+ )
477
+
478
+ # Capacitor kVar
479
+ fig.add_trace(
480
+ go.Bar(
481
+ x=hours, y=data["cap_kvar"],
482
+ name="Cap kVar",
483
+ marker_color=COLORS["primary"],
484
+ opacity=0.85,
485
+ ),
486
+ row=1, col=1,
487
+ )
488
+
489
+ # Regulator tap
490
+ fig.add_trace(
491
+ go.Scatter(
492
+ x=hours, y=data["reg_tap"],
493
+ mode="lines+markers",
494
+ name="Reg Tap",
495
+ line=dict(color=COLORS["accent"], width=2),
496
+ marker=dict(size=6),
497
+ ),
498
+ row=1, col=2,
499
+ )
500
+ fig.add_hline(y=0, line_dash="dot", line_color=COLORS["text_dim"], row=1, col=2)
501
+
502
+ # Battery SOC
503
+ fig.add_trace(
504
+ go.Scatter(
505
+ x=hours, y=data["battery_soc"],
506
+ mode="lines+markers",
507
+ name="SOC %",
508
+ line=dict(color=COLORS["warning"], width=2.5),
509
+ marker=dict(size=5),
510
+ fill="tozeroy",
511
+ fillcolor="rgba(245, 158, 11, 0.1)",
512
+ ),
513
+ row=2, col=1,
514
+ )
515
+ # SOC bounds
516
+ fig.add_hline(y=20, line_dash="dot", line_color=COLORS["danger"], row=2, col=1)
517
+ fig.add_hline(y=90, line_dash="dot", line_color=COLORS["danger"], row=2, col=1)
518
+
519
+ # PV output + curtailment stacked
520
+ fig.add_trace(
521
+ go.Bar(
522
+ x=hours, y=data["pv_kw"] - data["pv_curtail"],
523
+ name="PV Delivered (kW)",
524
+ marker_color=COLORS["secondary"],
525
+ ),
526
+ row=2, col=2,
527
+ )
528
+ fig.add_trace(
529
+ go.Bar(
530
+ x=hours, y=data["pv_curtail"],
531
+ name="PV Curtailed (kW)",
532
+ marker_color=COLORS["danger"],
533
+ opacity=0.7,
534
+ ),
535
+ row=2, col=2,
536
+ )
537
+
538
+ # Axis labels
539
+ for row, col in [(1, 1), (1, 2), (2, 1), (2, 2)]:
540
+ fig.update_xaxes(title_text="Hour", row=row, col=col)
541
+ fig.update_yaxes(title_text="kVar", row=1, col=1)
542
+ fig.update_yaxes(title_text="Tap Position", row=1, col=2)
543
+ fig.update_yaxes(title_text="SOC (%)", row=2, col=1)
544
+ fig.update_yaxes(title_text="Power (kW)", row=2, col=2)
545
+
546
+ fig.update_layout(
547
+ **PLOTLY_LAYOUT_DEFAULTS,
548
+ height=600,
549
+ barmode="stack",
550
+ title=f"Component Operation - {season} (Day {day})",
551
+ showlegend=True,
552
+ legend=dict(orientation="h", yanchor="bottom", y=1.06, xanchor="center", x=0.5),
553
+ )
554
+ return fig
555
+
556
+
557
+ def plot_training_rewards() -> go.Figure:
558
+ """Plot episode reward curve over CMDP training.
559
+
560
+ Returns:
561
+ Plotly Figure with raw + smoothed reward curves.
562
+ """
563
+ data = TRAINING_DATA
564
+ ep = data["episodes"]
565
+ window = 20
566
+
567
+ fig = go.Figure()
568
+
569
+ # Raw rewards
570
+ fig.add_trace(go.Scatter(
571
+ x=ep, y=data["rewards"],
572
+ mode="lines",
573
+ name="Episode Reward (raw)",
574
+ line=dict(color=COLORS["primary"], width=1),
575
+ opacity=0.4,
576
+ ))
577
+
578
+ # Smoothed rewards
579
+ smooth = np.convolve(data["rewards"], np.ones(window) / window, mode="valid")
580
+ fig.add_trace(go.Scatter(
581
+ x=ep[window - 1:], y=smooth,
582
+ mode="lines",
583
+ name="Episode Reward (smoothed)",
584
+ line=dict(color=COLORS["primary"], width=2.5),
585
+ ))
586
+
587
+ fig.update_layout(
588
+ **PLOTLY_LAYOUT_DEFAULTS,
589
+ height=400,
590
+ title="CMDP Episode Reward (SmartGrid 34-Bus PV)",
591
+ xaxis_title="Training Episode",
592
+ yaxis_title="Episode Reward",
593
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
594
+ )
595
+ return fig
596
+
597
+
598
+ def plot_lagrangian_decomposition() -> go.Figure:
599
+ """Plot Lagrangian objective decomposition: J(pi) - lambda*g(pi).
600
+
601
+ Shows primal objective, dual penalty, and combined Lagrangian objective.
602
+
603
+ Returns:
604
+ Plotly Figure with three overlaid curves.
605
+ """
606
+ data = TRAINING_DATA
607
+ ep = data["episodes"]
608
+ window = 25
609
+
610
+ fig = go.Figure()
611
+
612
+ # Helper: smooth curve
613
+ def _smooth(arr: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
614
+ s = np.convolve(arr, np.ones(window) / window, mode="valid")
615
+ return ep[window - 1:], s
616
+
617
+ # Primal objective J(pi)
618
+ x, y = _smooth(data["primal_obj"])
619
+ fig.add_trace(go.Scatter(
620
+ x=x, y=y,
621
+ mode="lines",
622
+ name="J(pi) Primal Objective",
623
+ line=dict(color=COLORS["accent"], width=2.2),
624
+ ))
625
+
626
+ # Dual penalty lambda*g(pi)
627
+ x, y = _smooth(data["dual_penalty"])
628
+ fig.add_trace(go.Scatter(
629
+ x=x, y=y,
630
+ mode="lines",
631
+ name="lambda * g(pi) Dual Penalty",
632
+ line=dict(color=COLORS["danger"], width=2.2, dash="dash"),
633
+ ))
634
+
635
+ # Lagrangian objective L = J(pi) - lambda*g(pi)
636
+ x, y = _smooth(data["lagrangian_obj"])
637
+ fig.add_trace(go.Scatter(
638
+ x=x, y=y,
639
+ mode="lines",
640
+ name="L(pi, lambda) Lagrangian",
641
+ line=dict(color=COLORS["warning"], width=2.8),
642
+ ))
643
+
644
+ fig.update_layout(
645
+ **PLOTLY_LAYOUT_DEFAULTS,
646
+ height=400,
647
+ title="Lagrangian Objective Decomposition: L = J(pi) - lambda * g(pi)",
648
+ xaxis_title="Training Episode",
649
+ yaxis_title="Objective Value",
650
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="center", x=0.5),
651
+ )
652
+ return fig
653
+
654
+
655
+ def plot_constraint_satisfaction() -> go.Figure:
656
+ """Plot constraint satisfaction rate over training.
657
+
658
+ Returns:
659
+ Plotly Figure with satisfaction rate and 95% target line.
660
+ """
661
+ data = TRAINING_DATA
662
+ ep = data["episodes"]
663
+ window = 20
664
+
665
+ fig = go.Figure()
666
+
667
+ # Raw
668
+ fig.add_trace(go.Scatter(
669
+ x=ep, y=data["constraint_satisfaction"],
670
+ mode="lines",
671
+ name="Constraint Satisfaction (raw)",
672
+ line=dict(color=COLORS["secondary"], width=1),
673
+ opacity=0.35,
674
+ ))
675
+
676
+ # Smoothed
677
+ smooth = np.convolve(data["constraint_satisfaction"], np.ones(window) / window, mode="valid")
678
+ fig.add_trace(go.Scatter(
679
+ x=ep[window - 1:], y=smooth,
680
+ mode="lines",
681
+ name="Constraint Satisfaction (smoothed)",
682
+ line=dict(color=COLORS["secondary"], width=2.5),
683
+ ))
684
+
685
+ # 95% target
686
+ fig.add_hline(
687
+ y=95, line_dash="dot", line_color=COLORS["warning"],
688
+ annotation_text="95% target",
689
+ annotation_position="bottom right",
690
+ )
691
+ # 98% excellent
692
+ fig.add_hline(
693
+ y=98, line_dash="dot", line_color=COLORS["primary"],
694
+ annotation_text="98% excellent",
695
+ annotation_position="top right",
696
+ )
697
+
698
+ layout_overrides = {**PLOTLY_LAYOUT_DEFAULTS}
699
+ layout_overrides["yaxis"] = dict(
700
+ range=[50, 102],
701
+ gridcolor=COLORS["grid"],
702
+ zerolinecolor=COLORS["grid"],
703
+ title_text="Satisfaction (%)",
704
+ )
705
+ fig.update_layout(
706
+ **layout_overrides,
707
+ height=350,
708
+ title="Voltage Constraint Satisfaction Rate",
709
+ xaxis_title="Training Episode",
710
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
711
+ )
712
+ return fig
713
+
714
+
715
+ # ============================================================
716
+ # Build Gradio App
717
+ # ============================================================
718
+
719
+ def build_app() -> gr.Blocks:
720
+ """Construct the Gradio Blocks application with 5 tabs."""
721
+ with gr.Blocks(
722
+ title="PowerZoo SmartGrid: CMDP Environment Demo",
723
+ theme=gr.themes.Soft(primary_hue="emerald"),
724
+ ) as app:
725
+ # Header
726
+ gr.Markdown(
727
+ """
728
+ # PowerZoo SmartGrid: Modular PV Integration with CMDP
729
+ **Constrained MDP** | **Lagrangian Relaxation** | **360-Step Annual Episodes** | **Homogeneous Agents**
730
+ """
731
+ )
732
+
733
+ with gr.Tabs():
734
+ # --------------------------------------------------------
735
+ # Tab 1: Overview
736
+ # --------------------------------------------------------
737
+ with gr.Tab("Overview"):
738
+ gr.Markdown(
739
+ """
740
+ ## SmartGrid Environment
741
+
742
+ SmartGrid is a **modular PV integration environment** built on the Constrained Markov
743
+ Decision Process (CMDP) framework with Lagrangian relaxation. Unlike standard MDP
744
+ formulations that fold voltage constraints into the reward, SmartGrid separates the
745
+ **economic objective** (power loss minimization, control smoothness) from the
746
+ **safety constraint** (voltage regulation within 0.95-1.05 p.u.).
747
+
748
+ ### Key Specifications
749
+
750
+ | Property | Value |
751
+ |----------|-------|
752
+ | **Agent Type** | Homogeneous (shared policy) |
753
+ | **Episode Length** | 360 steps (1 step = 1 day, annual cycle) |
754
+ | **Framework** | CMDP with Lagrangian multiplier |
755
+ | **Controlled Devices** | Capacitors, Regulators, Batteries, PV Systems |
756
+ | **Voltage Limits** | 0.95 - 1.05 p.u. (ANSI C84.1) |
757
+ | **Reward** | Power loss + control cost + PV utilization |
758
+ | **Constraint** | Voltage violation rate (squared hinge loss) |
759
+
760
+ ### What Makes SmartGrid Unique
761
+
762
+ - **CMDP Formulation**: The Lagrangian multiplier `lambda` automatically balances
763
+ economic performance against voltage safety. No manual penalty tuning required.
764
+ - **OOP Circuit Modeling**: Component-based architecture (Capacitor, Regulator,
765
+ Battery, PV) with SmartGrid's own `Circuit` class wrapping OpenDSS.
766
+ - **Annual Episodes**: 360-step episodes capture seasonal load/PV variation,
767
+ enabling agents to learn long-horizon strategies.
768
+ - **Curriculum Learning**: Three-phase training (exploration -> optimization ->
769
+ refinement) with progressive constraint tightening.
770
+
771
+ ### Supported IEEE Test Systems
772
+
773
+ | System | Buses | Agents | Complexity |
774
+ |--------|-------|--------|------------|
775
+ | **13-Bus** | 13 | 2-4 | Rapid prototyping |
776
+ | **34-Bus_PV** | 34 | 6-9 | PV integration studies |
777
+ | **123-Bus** | 123 | 12-20 | Medium-scale validation |
778
+ | **8500-Node** | 8500 | 50+ | Large-scale stress test |
779
+
780
+ PV variants available: Conservative, Optimized, Aggressive penetration levels.
781
+
782
+ ### CMDP Objective
783
+
784
+ The agent optimizes the Lagrangian:
785
+
786
+ **L(pi, lambda) = J(pi) - lambda * g(pi)**
787
+
788
+ where `J(pi)` is the primal reward (economic), `g(pi)` is the constraint cost
789
+ (voltage violations), and `lambda` is the dual variable updated via gradient ascent.
790
+ """
791
+ )
792
+
793
+ # --------------------------------------------------------
794
+ # Tab 2: Voltage Heatmap
795
+ # --------------------------------------------------------
796
+ with gr.Tab("Voltage Heatmap"):
797
+ gr.Markdown(
798
+ """
799
+ ## Bus Voltage Heatmap (IEEE 13-Bus Demo)
800
+ Visualize voltage profiles across all buses over the year.
801
+ **Blue** = undervoltage (<0.95), **Green** = normal (0.95-1.05), **Red** = overvoltage (>1.05).
802
+ Summer PV injection causes overvoltage on downstream buses; winter loads pull voltage down.
803
+ """
804
+ )
805
+ time_dropdown = gr.Dropdown(
806
+ choices=["Full Year", "Q1 (Jan-Mar)", "Q2 (Apr-Jun)", "Q3 (Jul-Sep)", "Q4 (Oct-Dec)"],
807
+ value="Full Year",
808
+ label="Time Window",
809
+ )
810
+ heatmap_plot = gr.Plot(value=plot_voltage_heatmap("Full Year"))
811
+
812
+ time_dropdown.change(
813
+ fn=plot_voltage_heatmap,
814
+ inputs=time_dropdown,
815
+ outputs=heatmap_plot,
816
+ )
817
+
818
+ # --------------------------------------------------------
819
+ # Tab 3: Lagrangian Trajectory
820
+ # --------------------------------------------------------
821
+ with gr.Tab("Lagrangian Trajectory"):
822
+ gr.Markdown(
823
+ """
824
+ ## CMDP Lagrangian Convergence
825
+ The Lagrangian multiplier `lambda` starts high (~10) to enforce strict voltage constraints,
826
+ then converges to ~2 as the policy learns to satisfy constraints naturally.
827
+ The constraint violation rate drops from ~15% to below 2%.
828
+
829
+ This dual convergence is the signature behavior of CMDP training with Lagrangian relaxation.
830
+ """
831
+ )
832
+ lagrangian_plot = gr.Plot(value=plot_lagrangian_trajectory())
833
+
834
+ # --------------------------------------------------------
835
+ # Tab 4: Component Status
836
+ # --------------------------------------------------------
837
+ with gr.Tab("Component Status"):
838
+ gr.Markdown(
839
+ """
840
+ ## 24-Hour Component Operation
841
+ View how capacitors, regulators, batteries, and PV systems operate across a full day.
842
+ Select different seasons to see how operation patterns change with load and PV availability.
843
+ """
844
+ )
845
+ season_radio = gr.Radio(
846
+ choices=["Day 1 (Winter)", "Day 91 (Spring)", "Day 182 (Summer)", "Day 273 (Autumn)"],
847
+ value="Day 182 (Summer)",
848
+ label="Select Day of Year",
849
+ )
850
+ component_plot = gr.Plot(value=plot_component_status("Day 182 (Summer)"))
851
+
852
+ season_radio.change(
853
+ fn=plot_component_status,
854
+ inputs=season_radio,
855
+ outputs=component_plot,
856
+ )
857
+
858
+ # --------------------------------------------------------
859
+ # Tab 5: Training Dashboard
860
+ # --------------------------------------------------------
861
+ with gr.Tab("Training Dashboard"):
862
+ gr.Markdown(
863
+ """
864
+ ## CMDP Training Dashboard (SmartGrid 34-Bus PV)
865
+ Training curves showing both primal (reward) and dual (constraint) convergence.
866
+ The Lagrangian objective decomposes into J(pi) and the penalty term lambda * g(pi).
867
+ """
868
+ )
869
+
870
+ gr.Markdown("### Episode Reward")
871
+ reward_plot = gr.Plot(value=plot_training_rewards())
872
+
873
+ gr.Markdown("### Lagrangian Objective Decomposition")
874
+ decomp_plot = gr.Plot(value=plot_lagrangian_decomposition())
875
+
876
+ gr.Markdown("### Constraint Satisfaction Rate")
877
+ constraint_plot = gr.Plot(value=plot_constraint_satisfaction())
878
+
879
+ # Footer
880
+ gr.Markdown(
881
+ """
882
+ ---
883
+ **PowerZoo** · MIT License · [XJTU-RL](https://github.com/XJTU-RL) · IEEE TSG 2025
884
+ """
885
+ )
886
+
887
+ return app
888
+
889
+
890
+ # ============================================================
891
+ # Launch
892
+ # ============================================================
893
+ if __name__ == "__main__":
894
+ app = build_app()
895
+ app.launch(server_name="0.0.0.0", server_port=7860, share=False)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio==4.44.1
2
+ plotly>=5.18.0
3
+ pandas>=2.0.0
4
+ numpy>=1.24.0