github-actions[bot] commited on
Commit
2e4d1df
·
1 Parent(s): b598e06

deploy: sync from GitHub 2026-09-14T03:36:15Z

Browse files
data/specs/specs/2e756e9e0a2d417e/logistic_fit.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "latency": {
3
+ "L": 0.672333808351321,
4
+ "b0": 0.025327222690596844,
5
+ "k": 0.9249147277217336,
6
+ "x0": 10.0
7
+ },
8
+ "model_label": "Qwen3-235B-A22B",
9
+ "power": {
10
+ "L": 1130.4391558531665,
11
+ "b0": 2551.5754923889053,
12
+ "k": 1.2638482029342986,
13
+ "x0": 3.846153846153846
14
+ },
15
+ "schema": "logistic_v1",
16
+ "throughput": {
17
+ "L": 1400.9050260569725,
18
+ "b0": 102.09644698524227,
19
+ "k": 0.9249147277217336,
20
+ "x0": 4.461538461538462
21
+ }
22
+ }
data/specs/specs/4925acb216d43131/logistic_fit.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "latency": {
3
+ "L": 0.11093873569263059,
4
+ "b0": 0.034847828201926605,
5
+ "k": 1.2638482029342986,
6
+ "x0": 7.743589743589744
7
+ },
8
+ "model_label": "Llama-3.1-405B",
9
+ "power": {
10
+ "L": 1824.2431634291524,
11
+ "b0": 3147.2107548161166,
12
+ "k": 1.0811807510766078,
13
+ "x0": 5.692307692307692
14
+ },
15
+ "schema": "logistic_v1",
16
+ "throughput": {
17
+ "L": 2118.1101549783216,
18
+ "b0": 231.54554435357053,
19
+ "k": 1.2638482029342986,
20
+ "x0": 5.897435897435898
21
+ }
22
+ }
data/specs/specs/73691f793f6be469/logistic_fit.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "latency": {
3
+ "L": 0.3337169923867402,
4
+ "b0": 0.01581299670243065,
5
+ "k": 0.6768750009458534,
6
+ "x0": 11.23076923076923
7
+ },
8
+ "model_label": "Llama-3.1-70B",
9
+ "power": {
10
+ "L": 1154.0021476589798,
11
+ "b0": 1646.7205316820875,
12
+ "k": 0.5790443980602487,
13
+ "x0": 7.384615384615384
14
+ },
15
+ "schema": "logistic_v1",
16
+ "throughput": {
17
+ "L": 7487.842476643308,
18
+ "b0": -115.86209784705026,
19
+ "k": 0.6768750009458534,
20
+ "x0": 6.871794871794871
21
+ }
22
+ }
data/specs/specs/860654eabd3dfebf/logistic_fit.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "latency": {
3
+ "L": 0.22314992755762236,
4
+ "b0": 0.011393094262367034,
5
+ "k": 0.9249147277217336,
6
+ "x0": 9.58974358974359
7
+ },
8
+ "model_label": "Qwen3-30B-A3B",
9
+ "power": {
10
+ "L": 443.2622940538781,
11
+ "b0": 521.77175156347,
12
+ "k": 0.6768750009458534,
13
+ "x0": 3.435897435897436
14
+ },
15
+ "schema": "logistic_v1",
16
+ "throughput": {
17
+ "L": 16272.619545718631,
18
+ "b0": -798.0377199891329,
19
+ "k": 0.3101168926574778,
20
+ "x0": 10.0
21
+ }
22
+ }
data/specs/specs/bc0bde304544a603/logistic_fit.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "latency": {
3
+ "L": 0.08748363159637874,
4
+ "b0": 0.008524586220155353,
5
+ "k": 1.0811807510766078,
6
+ "x0": 9.153846153846153
7
+ },
8
+ "model_label": "Llama-3.1-8B",
9
+ "power": {
10
+ "L": 210.91957273648842,
11
+ "b0": 480.39716041942955,
12
+ "k": 1.2638482029342986,
13
+ "x0": 5.461538461538462
14
+ },
15
+ "schema": "logistic_v1",
16
+ "throughput": {
17
+ "L": 9383.467277123862,
18
+ "b0": 771.2518469119908,
19
+ "k": 0.9249147277217336,
20
+ "x0": 6.153846153846154
21
+ }
22
+ }
data/training_trace.csv ADDED
The diff for this file is too large to render. See raw diff
 
examples/ieee123/IEEE123Bus.dss ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ! IEEE 123-Bus Test Feeder (with modifications)
2
+ ! Original source: https://github.com/tshort/OpenDSS/tree/master/Distrib/IEEETestCases/123Bus
3
+ ! Modified for studying datacenter loads connected to distribution grids.
4
+ !
5
+ ! Modifications from the original:
6
+ ! - This file combines the original master, regulators, and loads definitions
7
+ ! into a single self-contained file.
8
+
9
+ Clear
10
+
11
+ ! INSTANTIATE A NEW CIRCUIT AND DEFINE A STIFF 4160V SOURCE
12
+ ! The new circuit is called "ieee123"
13
+ ! This creates a Vsource object connected to "sourcebus". This is now the active circuit element, so
14
+ ! you can simply continue to edit its property value.
15
+ ! The basekV is redefined to 4.16 kV. The bus name is changed to "150" to match one of the buses in the test feeder.
16
+ ! The source is set for 1.0 per unit and the Short circuit impedance is set to a small value (0.0001 ohms)
17
+ ! The ~ is just shorthad for "more" for the New or Edit commands
18
+
19
+ New object=circuit.ieee123
20
+ ~ basekv=4.16 Bus1=150 pu=1.00 R1=0 X1=0.0001 R0=0 X0=0.0001
21
+
22
+ ! 3-PHASE GANGED REGULATOR AT HEAD OF FEEDER (KERSTING ASSUMES NO IMPEDANCE IN THE REGULATOR)
23
+ ! the first line defines the 3-phase transformer to be controlled by the regulator control.
24
+ ! The 2nd line defines the properties of the regulator control according to the test case
25
+
26
+ new transformer.reg1a phases=3 windings=2 buses=[150.1.2.3 150r.1.2.3] conns=[wye wye] kvs=[4.16 4.16] kvas=[5000 5000] XHL=.001 %LoadLoss=0.00001 ppm=0.0
27
+ new regcontrol.creg1a transformer=reg1a winding=2 vreg=120 band=2 ptratio=20 ctprim=700 R=3 X=7.5
28
+
29
+ ! REDIRECT INPUT STREAM TO FILE CONTAINING DEFINITIONS OF LINECODES
30
+ ! This file defines the line impedances is a similar manner to the description in the test case.
31
+
32
+ Redirect IEEELineCodes.dss
33
+
34
+ ! LINE DEFINITIONS
35
+ ! Lines are defined by referring to a "linecode" that contains the impedances per unit length
36
+ ! So the only properties required are the LineCode name and Length. Units are assumed to match the definition
37
+ ! since no units property is defined in either the Linecodes file or this file.
38
+ ! Note that it is not necessary to explicitly specify the node connections for the 3-phase lines
39
+ ! unless they are not ".1.2.3". However, they are spelled out here for clarity.
40
+ ! The DSS assumes .1.2.3.0.0 ... for connections of 3 or more phases.
41
+ ! Likewise, .1 is not necessary for 1-phase lines connected to phase 1. However, if it is connected
42
+ ! to any other phase, it must be specified. For completeness, everything is spelled out here.
43
+ !
44
+ ! Note that it is recommended that the "units=" property be used here and in the Linecode definition as well
45
+ ! to avoid confusion in the future
46
+
47
+ ! *** Original *** New Line.L115 Phases=3 Bus1=149.1.2.3 Bus2=1.1.2.3 LineCode=1 Length=0.4
48
+ ! Since the default is 3-phase, the definition of this line can be simpler:
49
+
50
+ New Line.L115 Bus1=149 Bus2=1 LineCode=1 Length=0.4
51
+
52
+ New Line.L1 Phases=1 Bus1=1.2 Bus2=2.2 LineCode=10 Length=0.175
53
+ New Line.L2 Phases=1 Bus1=1.3 Bus2=3.3 LineCode=11 Length=0.25
54
+ New Line.L3 Phases=3 Bus1=1.1.2.3 Bus2=7.1.2.3 LineCode=1 Length=0.3
55
+ New Line.L4 Phases=1 Bus1=3.3 Bus2=4.3 LineCode=11 Length=0.2
56
+ New Line.L5 Phases=1 Bus1=3.3 Bus2=5.3 LineCode=11 Length=0.325
57
+ New Line.L6 Phases=1 Bus1=5.3 Bus2=6.3 LineCode=11 Length=0.25
58
+ New Line.L7 Phases=3 Bus1=7.1.2.3 Bus2=8.1.2.3 LineCode=1 Length=0.2
59
+ New Line.L8 Phases=1 Bus1=8.2 Bus2=12.2 LineCode=10 Length=0.225
60
+ New Line.L9 Phases=1 Bus1=8.1 Bus2=9.1 LineCode=9 Length=0.225
61
+ New Line.L10 Phases=3 Bus1=8.1.2.3 Bus2=13.1.2.3 LineCode=1 Length=0.3
62
+ New Line.L11 Phases=1 Bus1=9r.1 Bus2=14.1 LineCode=9 Length=0.425
63
+ New Line.L12 Phases=1 Bus1=13.3 Bus2=34.3 LineCode=11 Length=0.15
64
+ New Line.L13 Phases=3 Bus1=13.1.2.3 Bus2=18.1.2.3 LineCode=2 Length=0.825
65
+ New Line.L14 Phases=1 Bus1=14.1 Bus2=11.1 LineCode=9 Length=0.25
66
+ New Line.L15 Phases=1 Bus1=14.1 Bus2=10.1 LineCode=9 Length=0.25
67
+ New Line.L16 Phases=1 Bus1=15.3 Bus2=16.3 LineCode=11 Length=0.375
68
+ New Line.L17 Phases=1 Bus1=15.3 Bus2=17.3 LineCode=11 Length=0.35
69
+ New Line.L18 Phases=1 Bus1=18.1 Bus2=19.1 LineCode=9 Length=0.25
70
+ New Line.L19 Phases=3 Bus1=18.1.2.3 Bus2=21.1.2.3 LineCode=2 Length=0.3
71
+ New Line.L20 Phases=1 Bus1=19.1 Bus2=20.1 LineCode=9 Length=0.325
72
+ New Line.L21 Phases=1 Bus1=21.2 Bus2=22.2 LineCode=10 Length=0.525
73
+ New Line.L22 Phases=3 Bus1=21.1.2.3 Bus2=23.1.2.3 LineCode=2 Length=0.25
74
+ New Line.L23 Phases=1 Bus1=23.3 Bus2=24.3 LineCode=11 Length=0.55
75
+ New Line.L24 Phases=3 Bus1=23.1.2.3 Bus2=25.1.2.3 LineCode=2 Length=0.275
76
+ New Line.L25 Phases=2 Bus1=25r.1.3 Bus2=26.1.3 LineCode=7 Length=0.35
77
+ New Line.L26 Phases=3 Bus1=25.1.2.3 Bus2=28.1.2.3 LineCode=2 Length=0.2
78
+ New Line.L27 Phases=2 Bus1=26.1.3 Bus2=27.1.3 LineCode=7 Length=0.275
79
+ New Line.L28 Phases=1 Bus1=26.3 Bus2=31.3 LineCode=11 Length=0.225
80
+ New Line.L29 Phases=1 Bus1=27.1 Bus2=33.1 LineCode=9 Length=0.5
81
+ New Line.L30 Phases=3 Bus1=28.1.2.3 Bus2=29.1.2.3 LineCode=2 Length=0.3
82
+ New Line.L31 Phases=3 Bus1=29.1.2.3 Bus2=30.1.2.3 LineCode=2 Length=0.35
83
+ New Line.L32 Phases=3 Bus1=30.1.2.3 Bus2=250.1.2.3 LineCode=2 Length=0.2
84
+ New Line.L33 Phases=1 Bus1=31.3 Bus2=32.3 LineCode=11 Length=0.3
85
+ New Line.L34 Phases=1 Bus1=34.3 Bus2=15.3 LineCode=11 Length=0.1
86
+ New Line.L35 Phases=2 Bus1=35.1.2 Bus2=36.1.2 LineCode=8 Length=0.65
87
+ New Line.L36 Phases=3 Bus1=35.1.2.3 Bus2=40.1.2.3 LineCode=1 Length=0.25
88
+ New Line.L37 Phases=1 Bus1=36.1 Bus2=37.1 LineCode=9 Length=0.3
89
+ New Line.L38 Phases=1 Bus1=36.2 Bus2=38.2 LineCode=10 Length=0.25
90
+ New Line.L39 Phases=1 Bus1=38.2 Bus2=39.2 LineCode=10 Length=0.325
91
+ New Line.L40 Phases=1 Bus1=40.3 Bus2=41.3 LineCode=11 Length=0.325
92
+ New Line.L41 Phases=3 Bus1=40.1.2.3 Bus2=42.1.2.3 LineCode=1 Length=0.25
93
+ New Line.L42 Phases=1 Bus1=42.2 Bus2=43.2 LineCode=10 Length=0.5
94
+ New Line.L43 Phases=3 Bus1=42.1.2.3 Bus2=44.1.2.3 LineCode=1 Length=0.2
95
+ New Line.L44 Phases=1 Bus1=44.1 Bus2=45.1 LineCode=9 Length=0.2
96
+ New Line.L45 Phases=3 Bus1=44.1.2.3 Bus2=47.1.2.3 LineCode=1 Length=0.25
97
+ New Line.L46 Phases=1 Bus1=45.1 Bus2=46.1 LineCode=9 Length=0.3
98
+ New Line.L47 Phases=3 Bus1=47.1.2.3 Bus2=48.1.2.3 LineCode=4 Length=0.15
99
+ New Line.L48 Phases=3 Bus1=47.1.2.3 Bus2=49.1.2.3 LineCode=4 Length=0.25
100
+ New Line.L49 Phases=3 Bus1=49.1.2.3 Bus2=50.1.2.3 LineCode=4 Length=0.25
101
+ New Line.L50 Phases=3 Bus1=50.1.2.3 Bus2=51.1.2.3 LineCode=4 Length=0.25
102
+ New Line.L51 Phases=3 Bus1=51.1.2.3 Bus2=151.1.2.3 LineCode=4 Length=0.5
103
+ New Line.L52 Phases=3 Bus1=52.1.2.3 Bus2=53.1.2.3 LineCode=1 Length=0.2
104
+ New Line.L53 Phases=3 Bus1=53.1.2.3 Bus2=54.1.2.3 LineCode=1 Length=0.125
105
+ New Line.L54 Phases=3 Bus1=54.1.2.3 Bus2=55.1.2.3 LineCode=1 Length=0.275
106
+ New Line.L55 Phases=3 Bus1=54.1.2.3 Bus2=57.1.2.3 LineCode=3 Length=0.35
107
+ New Line.L56 Phases=3 Bus1=55.1.2.3 Bus2=56.1.2.3 LineCode=1 Length=0.275
108
+ New Line.L57 Phases=1 Bus1=57.2 Bus2=58.2 LineCode=10 Length=0.25
109
+ New Line.L58 Phases=3 Bus1=57.1.2.3 Bus2=60.1.2.3 LineCode=3 Length=0.75
110
+ New Line.L59 Phases=1 Bus1=58.2 Bus2=59.2 LineCode=10 Length=0.25
111
+ New Line.L60 Phases=3 Bus1=60.1.2.3 Bus2=61.1.2.3 LineCode=5 Length=0.55
112
+ New Line.L61 Phases=3 Bus1=60.1.2.3 Bus2=62.1.2.3 LineCode=12 Length=0.25
113
+ New Line.L62 Phases=3 Bus1=62.1.2.3 Bus2=63.1.2.3 LineCode=12 Length=0.175
114
+ New Line.L63 Phases=3 Bus1=63.1.2.3 Bus2=64.1.2.3 LineCode=12 Length=0.35
115
+ New Line.L64 Phases=3 Bus1=64.1.2.3 Bus2=65.1.2.3 LineCode=12 Length=0.425
116
+ New Line.L65 Phases=3 Bus1=65.1.2.3 Bus2=66.1.2.3 LineCode=12 Length=0.325
117
+ New Line.L66 Phases=1 Bus1=67.1 Bus2=68.1 LineCode=9 Length=0.2
118
+ New Line.L67 Phases=3 Bus1=67.1.2.3 Bus2=72.1.2.3 LineCode=3 Length=0.275
119
+ New Line.L68 Phases=3 Bus1=67.1.2.3 Bus2=97.1.2.3 LineCode=3 Length=0.25
120
+ New Line.L69 Phases=1 Bus1=68.1 Bus2=69.1 LineCode=9 Length=0.275
121
+ New Line.L70 Phases=1 Bus1=69.1 Bus2=70.1 LineCode=9 Length=0.325
122
+ New Line.L71 Phases=1 Bus1=70.1 Bus2=71.1 LineCode=9 Length=0.275
123
+ New Line.L72 Phases=1 Bus1=72.3 Bus2=73.3 LineCode=11 Length=0.275
124
+ New Line.L73 Phases=3 Bus1=72.1.2.3 Bus2=76.1.2.3 LineCode=3 Length=0.2
125
+ New Line.L74 Phases=1 Bus1=73.3 Bus2=74.3 LineCode=11 Length=0.35
126
+ New Line.L75 Phases=1 Bus1=74.3 Bus2=75.3 LineCode=11 Length=0.4
127
+ New Line.L76 Phases=3 Bus1=76.1.2.3 Bus2=77.1.2.3 LineCode=6 Length=0.4
128
+ New Line.L77 Phases=3 Bus1=76.1.2.3 Bus2=86.1.2.3 LineCode=3 Length=0.7
129
+ New Line.L78 Phases=3 Bus1=77.1.2.3 Bus2=78.1.2.3 LineCode=6 Length=0.1
130
+ New Line.L79 Phases=3 Bus1=78.1.2.3 Bus2=79.1.2.3 LineCode=6 Length=0.225
131
+ New Line.L80 Phases=3 Bus1=78.1.2.3 Bus2=80.1.2.3 LineCode=6 Length=0.475
132
+ New Line.L81 Phases=3 Bus1=80.1.2.3 Bus2=81.1.2.3 LineCode=6 Length=0.175
133
+ New Line.L82 Phases=3 Bus1=81.1.2.3 Bus2=82.1.2.3 LineCode=6 Length=0.25
134
+ New Line.L83 Phases=1 Bus1=81.3 Bus2=84.3 LineCode=11 Length=0.675
135
+ New Line.L84 Phases=3 Bus1=82.1.2.3 Bus2=83.1.2.3 LineCode=6 Length=0.25
136
+ New Line.L85 Phases=1 Bus1=84.3 Bus2=85.3 LineCode=11 Length=0.475
137
+ New Line.L86 Phases=3 Bus1=86.1.2.3 Bus2=87.1.2.3 LineCode=6 Length=0.45
138
+ New Line.L87 Phases=1 Bus1=87.1 Bus2=88.1 LineCode=9 Length=0.175
139
+ New Line.L88 Phases=3 Bus1=87.1.2.3 Bus2=89.1.2.3 LineCode=6 Length=0.275
140
+ New Line.L89 Phases=1 Bus1=89.2 Bus2=90.2 LineCode=10 Length=0.25
141
+ New Line.L90 Phases=3 Bus1=89.1.2.3 Bus2=91.1.2.3 LineCode=6 Length=0.225
142
+ New Line.L91 Phases=1 Bus1=91.3 Bus2=92.3 LineCode=11 Length=0.3
143
+ New Line.L92 Phases=3 Bus1=91.1.2.3 Bus2=93.1.2.3 LineCode=6 Length=0.225
144
+ New Line.L93 Phases=1 Bus1=93.1 Bus2=94.1 LineCode=9 Length=0.275
145
+ New Line.L94 Phases=3 Bus1=93.1.2.3 Bus2=95.1.2.3 LineCode=6 Length=0.3
146
+ New Line.L95 Phases=1 Bus1=95.2 Bus2=96.2 LineCode=10 Length=0.2
147
+ New Line.L96 Phases=3 Bus1=97.1.2.3 Bus2=98.1.2.3 LineCode=3 Length=0.275
148
+ New Line.L97 Phases=3 Bus1=98.1.2.3 Bus2=99.1.2.3 LineCode=3 Length=0.55
149
+ New Line.L98 Phases=3 Bus1=99.1.2.3 Bus2=100.1.2.3 LineCode=3 Length=0.3
150
+ New Line.L99 Phases=3 Bus1=100.1.2.3 Bus2=450.1.2.3 LineCode=3 Length=0.8
151
+ New Line.L118 Phases=3 Bus1=197.1.2.3 Bus2=101.1.2.3 LineCode=3 Length=0.25
152
+ New Line.L100 Phases=1 Bus1=101.3 Bus2=102.3 LineCode=11 Length=0.225
153
+ New Line.L101 Phases=3 Bus1=101.1.2.3 Bus2=105.1.2.3 LineCode=3 Length=0.275
154
+ New Line.L102 Phases=1 Bus1=102.3 Bus2=103.3 LineCode=11 Length=0.325
155
+ New Line.L103 Phases=1 Bus1=103.3 Bus2=104.3 LineCode=11 Length=0.7
156
+ New Line.L104 Phases=1 Bus1=105.2 Bus2=106.2 LineCode=10 Length=0.225
157
+ New Line.L105 Phases=3 Bus1=105.1.2.3 Bus2=108.1.2.3 LineCode=3 Length=0.325
158
+ New Line.L106 Phases=1 Bus1=106.2 Bus2=107.2 LineCode=10 Length=0.575
159
+ New Line.L107 Phases=1 Bus1=108.1 Bus2=109.1 LineCode=9 Length=0.45
160
+ New Line.L108 Phases=3 Bus1=108.1.2.3 Bus2=300.1.2.3 LineCode=3 Length=1
161
+ New Line.L109 Phases=1 Bus1=109.1 Bus2=110.1 LineCode=9 Length=0.3
162
+ New Line.L110 Phases=1 Bus1=110.1 Bus2=111.1 LineCode=9 Length=0.575
163
+ New Line.L111 Phases=1 Bus1=110.1 Bus2=112.1 LineCode=9 Length=0.125
164
+ New Line.L112 Phases=1 Bus1=112.1 Bus2=113.1 LineCode=9 Length=0.525
165
+ New Line.L113 Phases=1 Bus1=113.1 Bus2=114.1 LineCode=9 Length=0.325
166
+ New Line.L114 Phases=3 Bus1=135.1.2.3 Bus2=35.1.2.3 LineCode=4 Length=0.375
167
+ New Line.L116 Phases=3 Bus1=152.1.2.3 Bus2=52.1.2.3 LineCode=1 Length=0.4
168
+ New Line.L117 Phases=3 Bus1=160r.1.2.3 Bus2=67.1.2.3 LineCode=6 Length=0.35
169
+
170
+
171
+ ! NORMALLY CLOSED SWITCHES ARE DEFINED AS SHORT LINES
172
+ ! Could also be defned by setting the Switch=Yes property
173
+
174
+ New Line.Sw1 phases=3 Bus1=150r Bus2=149 r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001
175
+ New Line.Sw2 phases=3 Bus1=13 Bus2=152 r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001
176
+ New Line.Sw3 phases=3 Bus1=18 Bus2=135 r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001
177
+ New Line.Sw4 phases=3 Bus1=60 Bus2=160 r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001
178
+ New Line.Sw5 phases=3 Bus1=97 Bus2=197 r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001
179
+ New Line.Sw6 phases=3 Bus1=61 Bus2=61s r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001
180
+
181
+ ! NORMALLY OPEN SWITCHES; DEFINED AS SHORT LINE TO OPEN BUS SO WE CAN SEE OPEN POINT VOLTAGES.
182
+ ! COULD ALSO BE DEFINED AS DISABLED OR THE TERMINCAL COULD BE OPENED AFTER BEING DEFINED
183
+
184
+ New Line.Sw7 phases=3 Bus1=151 Bus2=300_OPEN r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001
185
+ New Line.Sw8 phases=1 Bus1=54.1 Bus2=94_OPEN.1 r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001
186
+
187
+ ! LOAD TRANSFORMER AT 61s/610
188
+ ! This is a 150 kVA Delta-Delta stepdown from 4160V to 480V.
189
+
190
+ New Transformer.XFM1 Phases=3 Windings=2 Xhl=2.72
191
+ ~ wdg=1 bus=61s conn=Delta kv=4.16 kva=150 %r=0.635
192
+ ~ wdg=2 bus=610 conn=Delta kv=0.48 kva=150 %r=0.635
193
+
194
+ ! CAPACITORS
195
+ ! Capacitors are 2-terminal devices. The 2nd terminal (Bus2=...) defaults to all phases
196
+ ! connected to ground (Node 0). Thus, it need not be specified if a Y-connected or L-N connected
197
+ ! capacitor is desired
198
+
199
+ New Capacitor.C83 Bus1=83 Phases=3 kVAR=600 kV=4.16
200
+ New Capacitor.C88a Bus1=88.1 Phases=1 kVAR=50 kV=2.402
201
+ New Capacitor.C90b Bus1=90.2 Phases=1 kVAR=50 kV=2.402
202
+ New Capacitor.C92c Bus1=92.3 Phases=1 kVAR=50 kV=2.402
203
+
204
+
205
+ !REGULATORS
206
+ ! Definitions for the remainder of regulators on the feeder:
207
+
208
+ !DEFINE TRANSFORMERS FOR REGULATORS
209
+ ! Have to assume basically zero impedance regulators to match the test case
210
+ new transformer.reg2a phases=1 windings=2 bank=reg2 buses=[9.1 9r.1] conns=[wye wye] kvs=[2.402 2.402] kvas=[2000 2000] XHL=.01 %LoadLoss=0.00001 ppm=0.0
211
+ new transformer.reg3a phases=1 windings=2 bank=reg3 buses=[25.1 25r.1] conns=[wye wye] kvs=[2.402 2.402] kvas=[2000 2000] XHL=.01 %LoadLoss=0.00001 ppm=0.0
212
+ new transformer.reg4a phases=1 windings=2 bank=reg4 buses=[160.1 160r.1] conns=[wye wye] kvs=[2.402 2.402] kvas=[2000 2000] XHL=.01 %LoadLoss=0.00001 ppm=0.0
213
+ new transformer.reg3c like=reg3a bank=reg3 buses=[25.3 25r.3] ppm=0.0
214
+ new transformer.reg4b like=reg4a bank=reg4 buses=[160.2 160r.2] ppm=0.0
215
+ new transformer.reg4c like=reg4a bank=reg4 buses=[160.3 160r.3] ppm=0.0
216
+
217
+ ! POINT REGULATOR CONTROLS TO REGULATOR TRANSFORMER AND SET PARAMETERS
218
+ new regcontrol.creg2a transformer=reg2a winding=2 vreg=120 band=2 ptratio=20 ctprim=50 R=0.4 X=0.4
219
+ new regcontrol.creg3a transformer=reg3a winding=2 vreg=120 band=1 ptratio=20 ctprim=50 R=0.4 X=0.4
220
+ new regcontrol.creg3c like=creg3a transformer=reg3c
221
+ new regcontrol.creg4a transformer=reg4a winding=2 vreg=124 band=2 ptratio=20 ctprim=300 R=0.6 X=1.3
222
+ new regcontrol.creg4b like=creg4a transformer=reg4b R=1.4 X=2.6
223
+ new regcontrol.creg4c like=creg4a transformer=reg4c R=0.2 X=1.4
224
+
225
+ ! NOTE: WHEN LIKE= IS USED, IT IS NECESSARY TO SPECIFY ONLY THOSE PROPERTIES THAT ARE DIFFERENT
226
+
227
+ ! SPOT LOADS
228
+ !
229
+ ! Note that 1-phase loads have a voltage rating = to actual voltage across terminals
230
+ ! This could be either 2.4kV for Wye connection or 4.16 kV for Delta or Line-Line connection.
231
+ ! 3-phase loads are rated Line-Line (as are 2-phase loads, but there are none in this case).
232
+ ! Only the balanced 3-phase loads are declared as 3-phase; unbalanced 3-phase loads are declared
233
+ ! as three 1-phase loads.
234
+
235
+ New Load.S1a Bus1=1.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
236
+ New Load.S2b Bus1=2.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
237
+ New Load.S4c Bus1=4.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
238
+ New Load.S5c Bus1=5.3 Phases=1 Conn=Wye Model=5 kV=2.4 kW=20.0 kvar=10.0
239
+ New Load.S6c Bus1=6.3 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0
240
+ New Load.S7a Bus1=7.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
241
+ New Load.S9a Bus1=9.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
242
+ New Load.S10a Bus1=10.1 Phases=1 Conn=Wye Model=5 kV=2.4 kW=20.0 kvar=10.0
243
+ New Load.S11a Bus1=11.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0
244
+ New Load.S12b Bus1=12.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
245
+ New Load.S16c Bus1=16.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
246
+ New Load.S17c Bus1=17.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
247
+ New Load.S19a Bus1=19.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
248
+ New Load.S20a Bus1=20.1 Phases=1 Conn=Wye Model=5 kV=2.4 kW=40.0 kvar=20.0
249
+ New Load.S22b Bus1=22.2 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0
250
+ New Load.S24c Bus1=24.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
251
+ New Load.S28a Bus1=28.1 Phases=1 Conn=Wye Model=5 kV=2.4 kW=40.0 kvar=20.0
252
+ New Load.S29a Bus1=29.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0
253
+ New Load.S30c Bus1=30.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
254
+ New Load.S31c Bus1=31.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
255
+ New Load.S32c Bus1=32.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
256
+ New Load.S33a Bus1=33.1 Phases=1 Conn=Wye Model=5 kV=2.4 kW=40.0 kvar=20.0
257
+ New Load.S34c Bus1=34.3 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0
258
+ New Load.S35a Bus1=35.1.2 Phases=1 Conn=Delta Model=1 kV=4.160 kW=40.0 kvar=20.0
259
+ New Load.S37a Bus1=37.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0
260
+ New Load.S38b Bus1=38.2 Phases=1 Conn=Wye Model=5 kV=2.4 kW=20.0 kvar=10.0
261
+ New Load.S39b Bus1=39.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
262
+ New Load.S41c Bus1=41.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
263
+ New Load.S42a Bus1=42.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
264
+ New Load.S43b Bus1=43.2 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0
265
+ New Load.S45a Bus1=45.1 Phases=1 Conn=Wye Model=5 kV=2.4 kW=20.0 kvar=10.0
266
+ New Load.S46a Bus1=46.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
267
+ New Load.S47 Bus1=47 Phases=3 Conn=Wye Model=5 kV=4.160 kW=105.0 kvar=75.0
268
+ New Load.S48 Bus1=48 Phases=3 Conn=Wye Model=2 kV=4.160 kW=210.0 kVAR=150.0
269
+ New Load.S49a Bus1=49.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=35.0 kvar=25.0
270
+ New Load.S49b Bus1=49.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=70.0 kvar=50.0
271
+ New Load.S49c Bus1=49.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=35.0 kvar=20.0 ! used to be 25 in on-line document
272
+ New Load.S50c Bus1=50.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
273
+ New Load.S51a Bus1=51.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
274
+ New Load.S52a Bus1=52.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
275
+ New Load.S53a Bus1=53.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
276
+ New Load.S55a Bus1=55.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=20.0 kvar=10.0
277
+ New Load.S56b Bus1=56.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
278
+ New Load.S58b Bus1=58.2 Phases=1 Conn=Wye Model=5 kV=2.4 kW=20.0 kvar=10.0
279
+ New Load.S59b Bus1=59.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
280
+ New Load.S60a Bus1=60.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
281
+ New Load.S62c Bus1=62.3 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0
282
+ New Load.S63a Bus1=63.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
283
+ New Load.S64b Bus1=64.2 Phases=1 Conn=Wye Model=5 kV=2.4 kW=75.0 kvar=35.0
284
+ New Load.S65a Bus1=65.1.2 Phases=1 Conn=Delta Model=2 kV=4.160 kW=35.0 kvar=25.0
285
+ New Load.S65b Bus1=65.2.3 Phases=1 Conn=Delta Model=2 kV=4.160 kW=35.0 kvar=25.0
286
+ New Load.S65c Bus1=65.3.1 Phases=1 Conn=Delta Model=2 kV=4.160 kW=70.0 kvar=50.0
287
+ New Load.S66c Bus1=66.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=75.0 kvar=35.0
288
+ New Load.S68a Bus1=68.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
289
+ New Load.S69a Bus1=69.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
290
+ New Load.S70a Bus1=70.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
291
+ New Load.S71a Bus1=71.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
292
+ New Load.S73c Bus1=73.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
293
+ New Load.S74c Bus1=74.3 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0
294
+ New Load.S75c Bus1=75.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
295
+ New Load.S76a Bus1=76.1.2 Phases=1 Conn=Delta Model=5 kV=4.160 kW=105.0 kvar=80.0
296
+ New Load.S76b Bus1=76.2.3 Phases=1 Conn=Delta Model=5 kV=4.160 kW=70.0 kvar=50.0
297
+ New Load.S76c Bus1=76.3.1 Phases=1 Conn=Delta Model=5 kV=4.160 kW=70.0 kvar=50.0
298
+ New Load.S77b Bus1=77.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
299
+ New Load.S79a Bus1=79.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0
300
+ New Load.S80b Bus1=80.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
301
+ New Load.S82a Bus1=82.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
302
+ New Load.S83c Bus1=83.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
303
+ New Load.S84c Bus1=84.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
304
+ New Load.S85c Bus1=85.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
305
+ New Load.S86b Bus1=86.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
306
+ New Load.S87b Bus1=87.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
307
+ New Load.S88a Bus1=88.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
308
+ New Load.S90b Bus1=90.2 Phases=1 Conn=Wye Model=5 kV=2.4 kW=40.0 kvar=20.0
309
+ New Load.S92c Bus1=92.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
310
+ New Load.S94a Bus1=94.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
311
+ New Load.S95b Bus1=95.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
312
+ New Load.S96b Bus1=96.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
313
+ New Load.S98a Bus1=98.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
314
+ New Load.S99b Bus1=99.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
315
+ New Load.S100c Bus1=100.3 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0
316
+ New Load.S102c Bus1=102.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
317
+ New Load.S103c Bus1=103.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
318
+ New Load.S104c Bus1=104.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
319
+ New Load.S106b Bus1=106.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
320
+ New Load.S107b Bus1=107.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
321
+ New Load.S109a Bus1=109.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0
322
+ New Load.S111a Bus1=111.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
323
+ New Load.S112a Bus1=112.1 Phases=1 Conn=Wye Model=5 kV=2.4 kW=20.0 kvar=10.0
324
+ New Load.S113a Bus1=113.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0
325
+ New Load.S114a Bus1=114.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0
326
+
327
+ ! All devices in the test feeder are now defined.
328
+ !
329
+ ! Many of the voltages are reported in per unit, so it is important to establish the base voltages at each bus so
330
+ ! that we can compare with the result with greater ease.
331
+ ! We will let the DSS compute the voltage bases by doing a zero-load power flow.
332
+ ! There are only two voltage bases in the problem: 4160V and 480V. These must be expressed in kV
333
+
334
+ Set VoltageBases = [4.16, 0.48] ! ARRAY OF VOLTAGES IN KV
335
+ CalcVoltageBases ! PERFORMS ZERO LOAD POWER FLOW TO ESTIMATE VOLTAGE BASES
336
+
337
+ ! Bus coordinates (inlined from BusCoords.dat)
338
+ SetBusXY bus=1 X=700 Y=1500
339
+ SetBusXY bus=2 X=700 Y=1675
340
+ SetBusXY bus=3 X=700 Y=1250
341
+ SetBusXY bus=4 X=700 Y=1050
342
+ SetBusXY bus=5 X=1025 Y=1250
343
+ SetBusXY bus=6 X=1275 Y=1150
344
+ SetBusXY bus=7 X=1000 Y=1500
345
+ SetBusXY bus=8 X=1200 Y=1500
346
+ SetBusXY bus=9 X=1200 Y=1725
347
+ SetBusXY bus=9r X=1200 Y=1725
348
+ SetBusXY bus=10 X=1450 Y=2150
349
+ SetBusXY bus=11 X=950 Y=2150
350
+ SetBusXY bus=12 X=1200 Y=1275
351
+ SetBusXY bus=13 X=1500 Y=1500
352
+ SetBusXY bus=14 X=1200 Y=2150
353
+ SetBusXY bus=15 X=1500 Y=1250
354
+ SetBusXY bus=16 X=1500 Y=875
355
+ SetBusXY bus=17 X=1850 Y=1250
356
+ SetBusXY bus=18 X=1500 Y=2325
357
+ SetBusXY bus=19 X=1250 Y=2325
358
+ SetBusXY bus=20 X=925 Y=2325
359
+ SetBusXY bus=21 X=1500 Y=2625
360
+ SetBusXY bus=22 X=975 Y=2625
361
+ SetBusXY bus=23 X=1500 Y=2875
362
+ SetBusXY bus=24 X=950 Y=2875
363
+ SetBusXY bus=25 X=1500 Y=3150
364
+ SetBusXY bus=25r X=1500 Y=3150
365
+ SetBusXY bus=26 X=1150 Y=3150
366
+ SetBusXY bus=27 X=875 Y=3150
367
+ SetBusXY bus=28 X=1500 Y=3350
368
+ SetBusXY bus=29 X=1500 Y=3650
369
+ SetBusXY bus=30 X=1850 Y=3650
370
+ SetBusXY bus=31 X=1150 Y=3375
371
+ SetBusXY bus=32 X=1150 Y=3675
372
+ SetBusXY bus=33 X=875 Y=3650
373
+ SetBusXY bus=34 X=1500 Y=1350
374
+ SetBusXY bus=35 X=1975 Y=2325
375
+ SetBusXY bus=36 X=2075 Y=2000
376
+ SetBusXY bus=37 X=1775 Y=2000
377
+ SetBusXY bus=38 X=2325 Y=2000
378
+ SetBusXY bus=39 X=2650 Y=2000
379
+ SetBusXY bus=40 X=1975 Y=2575
380
+ SetBusXY bus=41 X=2300 Y=2575
381
+ SetBusXY bus=42 X=1975 Y=2825
382
+ SetBusXY bus=43 X=2475 Y=2825
383
+ SetBusXY bus=44 X=1975 Y=3025
384
+ SetBusXY bus=45 X=2175 Y=3025
385
+ SetBusXY bus=46 X=2475 Y=3025
386
+ SetBusXY bus=47 X=1975 Y=3275
387
+ SetBusXY bus=48 X=1825 Y=3275
388
+ SetBusXY bus=49 X=2225 Y=3275
389
+ SetBusXY bus=50 X=2475 Y=3275
390
+ SetBusXY bus=51 X=2725 Y=3275
391
+ SetBusXY bus=52 X=2000 Y=1500
392
+ SetBusXY bus=53 X=2200 Y=1500
393
+ SetBusXY bus=54 X=2325 Y=1500
394
+ SetBusXY bus=55 X=2600 Y=1500
395
+ SetBusXY bus=56 X=2875 Y=1500
396
+ SetBusXY bus=57 X=2325 Y=1850
397
+ SetBusXY bus=58 X=2075 Y=1850
398
+ SetBusXY bus=59 X=1825 Y=1850
399
+ SetBusXY bus=60 X=3075 Y=1850
400
+ SetBusXY bus=61 X=3075 Y=1300
401
+ SetBusXY bus=61s X=3175 Y=1300
402
+ SetBusXY bus=610 X=3275 Y=1300
403
+ SetBusXY bus=62 X=3075 Y=2100
404
+ SetBusXY bus=63 X=3075 Y=2275
405
+ SetBusXY bus=64 X=3075 Y=2625
406
+ SetBusXY bus=65 X=2650 Y=2625
407
+ SetBusXY bus=66 X=2650 Y=2300
408
+ SetBusXY bus=67 X=3525 Y=1850
409
+ SetBusXY bus=68 X=3725 Y=1850
410
+ SetBusXY bus=69 X=4000 Y=1850
411
+ SetBusXY bus=70 X=4325 Y=1850
412
+ SetBusXY bus=71 X=4600 Y=1850
413
+ SetBusXY bus=72 X=3525 Y=1575
414
+ SetBusXY bus=73 X=3800 Y=1575
415
+ SetBusXY bus=74 X=4150 Y=1575
416
+ SetBusXY bus=75 X=4550 Y=1575
417
+ SetBusXY bus=76 X=3525 Y=1375
418
+ SetBusXY bus=77 X=3925 Y=1375
419
+ SetBusXY bus=78 X=4025 Y=1375
420
+ SetBusXY bus=79 X=4250 Y=1375
421
+ SetBusXY bus=80 X=4025 Y=900
422
+ SetBusXY bus=81 X=4025 Y=425
423
+ SetBusXY bus=82 X=4025 Y=175
424
+ SetBusXY bus=83 X=4275 Y=175
425
+ SetBusXY bus=84 X=4700 Y=425
426
+ SetBusXY bus=85 X=4700 Y=900
427
+ SetBusXY bus=86 X=3255 Y=725
428
+ SetBusXY bus=87 X=3050 Y=1125
429
+ SetBusXY bus=88 X=2950 Y=1300
430
+ SetBusXY bus=89 X=2775 Y=1125
431
+ SetBusXY bus=90 X=2775 Y=900
432
+ SetBusXY bus=91 X=2550 Y=1125
433
+ SetBusXY bus=92 X=2550 Y=825
434
+ SetBusXY bus=93 X=2325 Y=1125
435
+ SetBusXY bus=94 X=2325 Y=850
436
+ SetBusXY bus=95 X=2025 Y=1125
437
+ SetBusXY bus=96 X=2025 Y=925
438
+ SetBusXY bus=97 X=3525 Y=2100
439
+ SetBusXY bus=98 X=3800 Y=2100
440
+ SetBusXY bus=99 X=4350 Y=2100
441
+ SetBusXY bus=100 X=4650 Y=2100
442
+ SetBusXY bus=101 X=3525 Y=2450
443
+ SetBusXY bus=102 X=3750 Y=2450
444
+ SetBusXY bus=103 X=4075 Y=2450
445
+ SetBusXY bus=104 X=4775 Y=2450
446
+ SetBusXY bus=105 X=3525 Y=2725
447
+ SetBusXY bus=106 X=3750 Y=2725
448
+ SetBusXY bus=107 X=4325 Y=2725
449
+ SetBusXY bus=108 X=3525 Y=3050
450
+ SetBusXY bus=109 X=3975 Y=3050
451
+ SetBusXY bus=110 X=4275 Y=3050
452
+ SetBusXY bus=111 X=4275 Y=3625
453
+ SetBusXY bus=112 X=4275 Y=2925
454
+ SetBusXY bus=113 X=4800 Y=2925
455
+ SetBusXY bus=114 X=5125 Y=2925
456
+ SetBusXY bus=135 X=1600 Y=2325
457
+ SetBusXY bus=149 X=300 Y=1500
458
+ SetBusXY bus=150 X=100 Y=1500
459
+ SetBusXY bus=150r X=100 Y=1500
460
+ SetBusXY bus=151 X=3187 Y=3928
461
+ SetBusXY bus=152 X=1600 Y=1500
462
+ SetBusXY bus=160 X=3175 Y=1850
463
+ SetBusXY bus=160r X=3175 Y=1850
464
+ SetBusXY bus=197 X=3525 Y=2200
465
+ SetBusXY bus=250 X=2050 Y=3650
466
+ SetBusXY bus=300 X=3245 Y=4010
467
+ SetBusXY bus=450 X=5450 Y=2100
examples/ieee123/IEEELineCodes.dss ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ! this file was corrected 9/16/2010 to match the values in Kersting's files
2
+
3
+
4
+
5
+ ! These line codes are used in the 123-bus circuit
6
+
7
+ New linecode.1 nphases=3 BaseFreq=60
8
+ !!!~ rmatrix = (0.088205 | 0.0312137 0.0901946 | 0.0306264 0.0316143 0.0889665 )
9
+ !!!~ xmatrix = (0.20744 | 0.0935314 0.200783 | 0.0760312 0.0855879 0.204877 )
10
+ !!!~ cmatrix = (2.90301 | -0.679335 3.15896 | -0.22313 -0.481416 2.8965 )
11
+ ~ rmatrix = [0.086666667 | 0.029545455 0.088371212 | 0.02907197 0.029924242 0.087405303]
12
+ ~ xmatrix = [0.204166667 | 0.095018939 0.198522727 | 0.072897727 0.080227273 0.201723485]
13
+ ~ cmatrix = [2.851710072 | -0.920293787 3.004631862 | -0.350755566 -0.585011253 2.71134756]
14
+
15
+ New linecode.2 nphases=3 BaseFreq=60
16
+ !!!~ rmatrix = (0.0901946 | 0.0316143 0.0889665 | 0.0312137 0.0306264 0.088205 )
17
+ !!!~ xmatrix = (0.200783 | 0.0855879 0.204877 | 0.0935314 0.0760312 0.20744 )
18
+ !!!~ cmatrix = (3.15896 | -0.481416 2.8965 | -0.679335 -0.22313 2.90301 )
19
+ ~ rmatrix = [0.088371212 | 0.02992424 0.087405303 | 0.029545455 0.02907197 0.086666667]
20
+ ~ xmatrix = [0.198522727 | 0.080227273 0.201723485 | 0.095018939 0.072897727 0.204166667]
21
+ ~ cmatrix = [3.004631862 | -0.585011253 2.71134756 | -0.920293787 -0.350755566 2.851710072]
22
+
23
+ New linecode.3 nphases=3 BaseFreq=60
24
+ !!!~ rmatrix = (0.0889665 | 0.0306264 0.088205 | 0.0316143 0.0312137 0.0901946 )
25
+ !!!~ xmatrix = (0.204877 | 0.0760312 0.20744 | 0.0855879 0.0935314 0.200783 )
26
+ !!!~ cmatrix = (2.8965 | -0.22313 2.90301 | -0.481416 -0.679335 3.15896 )
27
+
28
+ ~ rmatrix = [0.087405303 | 0.02907197 0.086666667 | 0.029924242 0.029545455 0.088371212]
29
+ ~ xmatrix = [0.201723485 | 0.072897727 0.204166667 | 0.080227273 0.095018939 0.198522727]
30
+ ~ cmatrix = [2.71134756 | -0.350755566 2.851710072 | -0.585011253 -0.920293787 3.004631862]
31
+
32
+ New linecode.4 nphases=3 BaseFreq=60
33
+ !!!~ rmatrix = (0.0889665 | 0.0316143 0.0901946 | 0.0306264 0.0312137 0.088205 )
34
+ !!!~ xmatrix = (0.204877 | 0.0855879 0.200783 | 0.0760312 0.0935314 0.20744 )
35
+ !!!~ cmatrix = (2.8965 | -0.481416 3.15896 | -0.22313 -0.679335 2.90301 )
36
+ ~ rmatrix = [0.087405303 | 0.029924242 0.088371212 | 0.02907197 0.029545455 0.086666667]
37
+ ~ xmatrix = [0.201723485 | 0.080227273 0.198522727 | 0.072897727 0.095018939 0.204166667]
38
+ ~ cmatrix = [2.71134756 | -0.585011253 3.004631862 | -0.350755566 -0.920293787 2.851710072]
39
+
40
+ New linecode.5 nphases=3 BaseFreq=60
41
+ !!!~ rmatrix = (0.0901946 | 0.0312137 0.088205 | 0.0316143 0.0306264 0.0889665 )
42
+ !!!~ xmatrix = (0.200783 | 0.0935314 0.20744 | 0.0855879 0.0760312 0.204877 )
43
+ !!!~ cmatrix = (3.15896 | -0.679335 2.90301 | -0.481416 -0.22313 2.8965 )
44
+
45
+ ~ rmatrix = [0.088371212 | 0.029545455 0.086666667 | 0.029924242 0.02907197 0.087405303]
46
+ ~ xmatrix = [0.198522727 | 0.095018939 0.204166667 | 0.080227273 0.072897727 0.201723485]
47
+ ~ cmatrix = [3.004631862 | -0.920293787 2.851710072 | -0.585011253 -0.350755566 2.71134756]
48
+
49
+ New linecode.6 nphases=3 BaseFreq=60
50
+ !!!~ rmatrix = (0.088205 | 0.0306264 0.0889665 | 0.0312137 0.0316143 0.0901946 )
51
+ !!!~ xmatrix = (0.20744 | 0.0760312 0.204877 | 0.0935314 0.0855879 0.200783 )
52
+ !!!~ cmatrix = (2.90301 | -0.22313 2.8965 | -0.679335 -0.481416 3.15896 )
53
+ ~ rmatrix = [0.086666667 | 0.02907197 0.087405303 | 0.029545455 0.029924242 0.088371212]
54
+ ~ xmatrix = [0.204166667 | 0.072897727 0.201723485 | 0.095018939 0.080227273 0.198522727]
55
+ ~ cmatrix = [2.851710072 | -0.350755566 2.71134756 | -0.920293787 -0.585011253 3.004631862]
56
+ New linecode.7 nphases=2 BaseFreq=60
57
+ !!!~ rmatrix = (0.088205 | 0.0306264 0.0889665 )
58
+ !!!~ xmatrix = (0.20744 | 0.0760312 0.204877 )
59
+ !!!~ cmatrix = (2.75692 | -0.326659 2.82313 )
60
+ ~ rmatrix = [0.086666667 | 0.02907197 0.087405303]
61
+ ~ xmatrix = [0.204166667 | 0.072897727 0.201723485]
62
+ ~ cmatrix = [2.569829596 | -0.52995137 2.597460011]
63
+ New linecode.8 nphases=2 BaseFreq=60
64
+ !!!~ rmatrix = (0.088205 | 0.0306264 0.0889665 )
65
+ !!!~ xmatrix = (0.20744 | 0.0760312 0.204877 )
66
+ !!!~ cmatrix = (2.75692 | -0.326659 2.82313 )
67
+ ~ rmatrix = [0.086666667 | 0.02907197 0.087405303]
68
+ ~ xmatrix = [0.204166667 | 0.072897727 0.201723485]
69
+ ~ cmatrix = [2.569829596 | -0.52995137 2.597460011]
70
+ New linecode.9 nphases=1 BaseFreq=60
71
+ !!!~ rmatrix = (0.254428 )
72
+ !!!~ xmatrix = (0.259546 )
73
+ !!!~ cmatrix = (2.50575 )
74
+ ~ rmatrix = [0.251742424]
75
+ ~ xmatrix = [0.255208333]
76
+ ~ cmatrix = [2.270366128]
77
+ New linecode.10 nphases=1 BaseFreq=60
78
+ !!!~ rmatrix = (0.254428 )
79
+ !!!~ xmatrix = (0.259546 )
80
+ !!!~ cmatrix = (2.50575 )
81
+ ~ rmatrix = [0.251742424]
82
+ ~ xmatrix = [0.255208333]
83
+ ~ cmatrix = [2.270366128]
84
+ New linecode.11 nphases=1 BaseFreq=60
85
+ !!!~ rmatrix = (0.254428 )
86
+ !!!~ xmatrix = (0.259546 )
87
+ !!!~ cmatrix = (2.50575 )
88
+ ~ rmatrix = [0.251742424]
89
+ ~ xmatrix = [0.255208333]
90
+ ~ cmatrix = [2.270366128]
91
+ New linecode.12 nphases=3 BaseFreq=60
92
+ !!!~ rmatrix = (0.291814 | 0.101656 0.294012 | 0.096494 0.101656 0.291814 )
93
+ !!!~ xmatrix = (0.141848 | 0.0517936 0.13483 | 0.0401881 0.0517936 0.141848 )
94
+ !!!~ cmatrix = (53.4924 | 0 53.4924 | 0 0 53.4924 )
95
+ ~ rmatrix = [0.288049242 | 0.09844697 0.29032197 | 0.093257576 0.09844697 0.288049242]
96
+ ~ xmatrix = [0.142443182 | 0.052556818 0.135643939 | 0.040852273 0.052556818 0.142443182]
97
+ ~ cmatrix = [33.77150149 | 0 33.77150149 | 0 0 33.77150149]
98
+
99
+ ! These line codes are used in the 34-node test feeder
100
+
101
+ New linecode.300 nphases=3 basefreq=60 ! ohms per 1000ft Corrected 11/30/05
102
+ ~ rmatrix = [0.253181818 | 0.039791667 0.250719697 | 0.040340909 0.039128788 0.251780303] !ABC ORDER
103
+ ~ xmatrix = [0.252708333 | 0.109450758 0.256988636 | 0.094981061 0.086950758 0.255132576]
104
+ ~ CMATRIX = [2.680150309 | -0.769281006 2.5610381 | -0.499507676 -0.312072984 2.455590387]
105
+ New linecode.301 nphases=3 basefreq=60
106
+ ~ rmatrix = [0.365530303 | 0.04407197 0.36282197 | 0.04467803 0.043333333 0.363996212]
107
+ ~ xmatrix = [0.267329545 | 0.122007576 0.270473485 | 0.107784091 0.099204545 0.269109848]
108
+ ~ cmatrix = [2.572492163 | -0.72160598 2.464381882 | -0.472329395 -0.298961096 2.368881119]
109
+ New linecode.302 nphases=1 basefreq=60
110
+ ~ rmatrix = (0.530208 )
111
+ ~ xmatrix = (0.281345 )
112
+ ~ cmatrix = (2.12257 )
113
+ New linecode.303 nphases=1 basefreq=60
114
+ ~ rmatrix = (0.530208 )
115
+ ~ xmatrix = (0.281345 )
116
+ ~ cmatrix = (2.12257 )
117
+ New linecode.304 nphases=1 basefreq=60
118
+ ~ rmatrix = (0.363958 )
119
+ ~ xmatrix = (0.269167 )
120
+ ~ cmatrix = (2.1922 )
121
+
122
+
123
+ ! This may be for the 4-node test feeder, but is not actually referenced.
124
+ ! instead, the 4Bus*.dss files all use the wiredata and linegeometry inputs
125
+ ! to calculate these matrices from physical data.
126
+
127
+ New linecode.400 nphases=3 BaseFreq=60
128
+ ~ rmatrix = (0.088205 | 0.0312137 0.0901946 | 0.0306264 0.0316143 0.0889665 )
129
+ ~ xmatrix = (0.20744 | 0.0935314 0.200783 | 0.0760312 0.0855879 0.204877 )
130
+ ~ cmatrix = (2.90301 | -0.679335 3.15896 | -0.22313 -0.481416 2.8965 )
131
+
132
+ ! These are for the 13-node test feeder
133
+
134
+ New linecode.601 nphases=3 BaseFreq=60
135
+ !!!~ rmatrix = (0.0674673 | 0.0312137 0.0654777 | 0.0316143 0.0306264 0.0662392 )
136
+ !!!~ xmatrix = (0.195204 | 0.0935314 0.201861 | 0.0855879 0.0760312 0.199298 )
137
+ !!!~ cmatrix = (3.32591 | -0.743055 3.04217 | -0.525237 -0.238111 3.03116 )
138
+ ~ rmatrix = [0.065625 | 0.029545455 0.063920455 | 0.029924242 0.02907197 0.064659091]
139
+ ~ xmatrix = [0.192784091 | 0.095018939 0.19844697 | 0.080227273 0.072897727 0.195984848]
140
+ ~ cmatrix = [3.164838036 | -1.002632425 2.993981593 | -0.632736516 -0.372608713 2.832670203]
141
+ New linecode.602 nphases=3 BaseFreq=60
142
+ !!!~ rmatrix = (0.144361 | 0.0316143 0.143133 | 0.0312137 0.0306264 0.142372 )
143
+ !!!~ xmatrix = (0.226028 | 0.0855879 0.230122 | 0.0935314 0.0760312 0.232686 )
144
+ !!!~ cmatrix = (3.01091 | -0.443561 2.77543 | -0.624494 -0.209615 2.77847 )
145
+ ~ rmatrix = [0.142537879 | 0.029924242 0.14157197 | 0.029545455 0.02907197 0.140833333]
146
+ ~ xmatrix = [0.22375 | 0.080227273 0.226950758 | 0.095018939 0.072897727 0.229393939]
147
+ ~ cmatrix = [2.863013423 | -0.543414918 2.602031589 | -0.8492585 -0.330962141 2.725162768]
148
+ New linecode.603 nphases=2 BaseFreq=60
149
+ !!!~ rmatrix = (0.254472 | 0.0417943 0.253371 )
150
+ !!!~ xmatrix = (0.259467 | 0.0912376 0.261431 )
151
+ !!!~ cmatrix = (2.54676 | -0.28882 2.49502 )
152
+ ~ rmatrix = [0.251780303 | 0.039128788 0.250719697]
153
+ ~ xmatrix = [0.255132576 | 0.086950758 0.256988636]
154
+ ~ cmatrix = [2.366017603 | -0.452083836 2.343963508]
155
+ New linecode.604 nphases=2 BaseFreq=60
156
+ !!!~ rmatrix = (0.253371 | 0.0417943 0.254472 )
157
+ !!!~ xmatrix = (0.261431 | 0.0912376 0.259467 )
158
+ !!!~ cmatrix = (2.49502 | -0.28882 2.54676 )
159
+ ~ rmatrix = [0.250719697 | 0.039128788 0.251780303]
160
+ ~ xmatrix = [0.256988636 | 0.086950758 0.255132576]
161
+ ~ cmatrix = [2.343963508 | -0.452083836 2.366017603]
162
+ New linecode.605 nphases=1 BaseFreq=60
163
+ !!!~ rmatrix = (0.254428 )
164
+ !!!~ xmatrix = (0.259546 )
165
+ !!!~ cmatrix = (2.50575 )
166
+ ~ rmatrix = [0.251742424]
167
+ ~ xmatrix = [0.255208333]
168
+ ~ cmatrix = [2.270366128]
169
+ New linecode.606 nphases=3 BaseFreq=60
170
+ !!!~ rmatrix = (0.152193 | 0.0611362 0.15035 | 0.0546992 0.0611362 0.152193 )
171
+ !!!~ xmatrix = (0.0825685 | 0.00548281 0.0745027 | -0.00339824 0.00548281 0.0825685 )
172
+ !!!~ cmatrix = (72.7203 | 0 72.7203 | 0 0 72.7203 )
173
+ ~ rmatrix = [0.151174242 | 0.060454545 0.149450758 | 0.053958333 0.060454545 0.151174242]
174
+ ~ xmatrix = [0.084526515 | 0.006212121 0.076534091 | -0.002708333 0.006212121 0.084526515]
175
+ ~ cmatrix = [48.67459408 | 0 48.67459408 | 0 0 48.67459408]
176
+ New linecode.607 nphases=1 BaseFreq=60
177
+ !!!~ rmatrix = (0.255799 )
178
+ !!!~ xmatrix = (0.092284 )
179
+ !!!~ cmatrix = (50.7067 )
180
+ ~ rmatrix = [0.254261364]
181
+ ~ xmatrix = [0.097045455]
182
+ ~ cmatrix = [44.70661522]
183
+
184
+ ! These are for the 37-node test feeder, all underground
185
+
186
+ New linecode.721 nphases=3 BaseFreq=60
187
+ !!!~ rmatrix = (0.0554906 | 0.0127467 0.0501597 | 0.00640446 0.0127467 0.0554906 )
188
+ !!!~ xmatrix = (0.0372331 | -0.00704588 0.0358645 | -0.00796424 -0.00704588 0.0372331 )
189
+ !!!~ cmatrix = (124.851 | 0 124.851 | 0 0 124.851 )
190
+ ~ rmatrix = [0.055416667 | 0.012746212 0.050113636 | 0.006382576 0.012746212 0.055416667]
191
+ ~ xmatrix = [0.037367424 | -0.006969697 0.035984848 | -0.007897727 -0.006969697 0.037367424]
192
+ ~ cmatrix = [80.27484728 | 0 80.27484728 | 0 0 80.27484728]
193
+ New linecode.722 nphases=3 BaseFreq=60
194
+ !!!~ rmatrix = (0.0902251 | 0.0309584 0.0851482 | 0.0234946 0.0309584 0.0902251 )
195
+ !!!~ xmatrix = (0.055991 | -0.00646552 0.0504025 | -0.0117669 -0.00646552 0.055991 )
196
+ !!!~ cmatrix = (93.4896 | 0 93.4896 | 0 0 93.4896 )
197
+ ~ rmatrix = [0.089981061 | 0.030852273 0.085 | 0.023371212 0.030852273 0.089981061]
198
+ ~ xmatrix = [0.056306818 | -0.006174242 0.050719697 | -0.011496212 -0.006174242 0.056306818]
199
+ ~ cmatrix = [64.2184109 | 0 64.2184109 | 0 0 64.2184109]
200
+ New linecode.723 nphases=3 BaseFreq=60
201
+ !!!~ rmatrix = (0.247572 | 0.0947678 0.249104 | 0.0893782 0.0947678 0.247572 )
202
+ !!!~ xmatrix = (0.126339 | 0.0390337 0.118816 | 0.0279344 0.0390337 0.126339 )
203
+ !!!~ cmatrix = (58.108 | 0 58.108 | 0 0 58.108 )
204
+ ~ rmatrix = [0.245 | 0.092253788 0.246628788 | 0.086837121 0.092253788 0.245]
205
+ ~ xmatrix = [0.127140152 | 0.039981061 0.119810606 | 0.028806818 0.039981061 0.127140152]
206
+ ~ cmatrix = [37.5977112 | 0 37.5977112 | 0 0 37.5977112]
207
+ New linecode.724 nphases=3 BaseFreq=60
208
+ !!!~ rmatrix = (0.399883 | 0.101765 0.402011 | 0.0965199 0.101765 0.399883 )
209
+ !!!~ xmatrix = (0.146325 | 0.0510963 0.139305 | 0.0395402 0.0510963 0.146325 )
210
+ !!!~ cmatrix = (46.9685 | 0 46.9685 | 0 0 46.9685 )
211
+ ~ rmatrix = [0.396818182 | 0.098560606 0.399015152 | 0.093295455 0.098560606 0.396818182]
212
+ ~ xmatrix = [0.146931818 | 0.051856061 0.140113636 | 0.040208333 0.051856061 0.146931818]
213
+ ~ cmatrix = [30.26701029 | 0 30.26701029 | 0 0 30.26701029]
examples/ieee13/IEEE13Bus.dss ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ! IEEE 13-Bus Test Feeder (with modifications)
2
+ ! Original source: https://github.com/tshort/OpenDSS/tree/master/Distrib/IEEETestCases/13Bus
3
+ ! Modified for studying datacenter loads connected to distribution grids.
4
+ !
5
+ ! Modifications from the original:
6
+ ! - The original load at bus 671 is removed (datacenter load at this bus is injected by the simulator)
7
+ ! - Loads at non-datacenter buses are reduced to create a lighter feeder
8
+ ! - Capacitor banks are disabled to highlight the voltage regulation effect of GPU flexibility
9
+
10
+ Clear
11
+ Set DefaultBaseFrequency=60
12
+
13
+ new circuit.IEEE13Nodeckt
14
+ ~ basekv=115 pu=1.0001 phases=3 bus1=SourceBus
15
+ ~ Angle=30 ! advance angle 30 deg so result agree with published angle
16
+ ~ MVAsc3=20000 MVASC1=21000 ! stiffen the source to approximate inf source
17
+
18
+
19
+ !SUB TRANSFORMER DEFINITION
20
+ ! Although this data was given, it does not appear to be used in the test case results
21
+ ! The published test case starts at 1.0 per unit at Bus 650. To make this happen, we will change the impedance
22
+ ! on the transformer to something tiny by dividing by 1000 using the DSS in-line RPN math
23
+ New Transformer.Sub Phases=3 Windings=2 XHL=(8 1000 /)
24
+ ~ wdg=1 bus=SourceBus conn=delta kv=115 kva=5000 %r=(.5 1000 /)
25
+ ~ wdg=2 bus=650 conn=wye kv=4.16 kva=5000 %r=(.5 1000 /)
26
+
27
+ ! FEEDER 1-PHASE VOLTAGE REGULATORS
28
+ ! Define low-impedance 2-wdg transformer
29
+
30
+ New Transformer.reg1a phases=1 bank=reg1 XHL=0.01 kVAs=[1666 1666]
31
+ ~ Buses=[650.1 RG60.1] kVs=[2.4 2.4] %LoadLoss=0.01
32
+ new regcontrol.creg1a transformer=reg1a winding=2 vreg=122 band=2 ptratio=20 ctprim=700 R=3 X=9
33
+
34
+ New Transformer.reg1b phases=1 bank=reg1 XHL=0.01 kVAs=[1666 1666]
35
+ ~ Buses=[650.2 RG60.2] kVs=[2.4 2.4] %LoadLoss=0.01
36
+ new regcontrol.creg1b transformer=reg1b winding=2 vreg=122 band=2 ptratio=20 ctprim=700 R=3 X=9
37
+
38
+ New Transformer.reg1c phases=1 bank=reg1 XHL=0.01 kVAs=[1666 1666]
39
+ ~ Buses=[650.3 RG60.3] kVs=[2.4 2.4] %LoadLoss=0.01
40
+ new regcontrol.creg1c transformer=reg1c winding=2 vreg=122 band=2 ptratio=20 ctprim=700 R=3 X=9
41
+
42
+
43
+ !TRANSFORMER DEFINITION
44
+ New Transformer.XFM1 Phases=3 Windings=2 XHL=2
45
+ ~ wdg=1 bus=633 conn=Wye kv=4.16 kva=500 %r=.55
46
+ ~ wdg=2 bus=634 conn=Wye kv=0.480 kva=500 %r=.55
47
+
48
+
49
+ !LINE CODES
50
+ redirect IEEELineCodes.dss
51
+
52
+ // these are local matrix line codes
53
+ // corrected 9-14-2011
54
+ New linecode.mtx601 nphases=3 BaseFreq=60
55
+ ~ rmatrix = (0.3465 | 0.1560 0.3375 | 0.1580 0.1535 0.3414 )
56
+ ~ xmatrix = (1.0179 | 0.5017 1.0478 | 0.4236 0.3849 1.0348 )
57
+ ~ units=mi
58
+ New linecode.mtx602 nphases=3 BaseFreq=60
59
+ ~ rmatrix = (0.7526 | 0.1580 0.7475 | 0.1560 0.1535 0.7436 )
60
+ ~ xmatrix = (1.1814 | 0.4236 1.1983 | 0.5017 0.3849 1.2112 )
61
+ ~ units=mi
62
+ New linecode.mtx603 nphases=2 BaseFreq=60
63
+ ~ rmatrix = (1.3238 | 0.2066 1.3294 )
64
+ ~ xmatrix = (1.3569 | 0.4591 1.3471 )
65
+ ~ units=mi
66
+ New linecode.mtx604 nphases=2 BaseFreq=60
67
+ ~ rmatrix = (1.3238 | 0.2066 1.3294 )
68
+ ~ xmatrix = (1.3569 | 0.4591 1.3471 )
69
+ ~ units=mi
70
+ New linecode.mtx605 nphases=1 BaseFreq=60
71
+ ~ rmatrix = (1.3292 )
72
+ ~ xmatrix = (1.3475 )
73
+ ~ units=mi
74
+
75
+ /*********** Original 606 Linecode *********************
76
+
77
+ You have to use this to match Kersting's results:
78
+
79
+ New linecode.mtx606 nphases=3 BaseFreq=60
80
+ ~ rmatrix = (0.7982 | 0.3192 0.7891 | 0.2849 0.3192 0.7982 )
81
+ ~ xmatrix = (0.4463 | 0.0328 0.4041 | -0.0143 0.0328 0.4463 )
82
+ ~ Cmatrix = [257 | 0 257 | 0 0 257] ! <--- This is too low by 1.5
83
+ ~ units=mi
84
+
85
+ Corrected mtx606 Feb 3 2016 by RDugan
86
+
87
+ The new LineCode.606 is computed using the following CN cable definition and
88
+ LineGeometry definition:
89
+
90
+ New CNDATA.250_1/3 k=13 DiaStrand=0.064 Rstrand=2.816666667 epsR=2.3
91
+ ~ InsLayer=0.220 DiaIns=1.06 DiaCable=1.16 Rac=0.076705 GMRac=0.20568 diam=0.573
92
+ ~ Runits=kft Radunits=in GMRunits=in
93
+
94
+ New LineGeometry.606 nconds=3 nphases=3 units=ft
95
+ ~ cond=1 cncable=250_1/3 x=-0.5 h= -4
96
+ ~ cond=2 cncable=250_1/3 x=0 h= -4
97
+ ~ cond=3 cncable=250_1/3 x=0.5 h= -4
98
+
99
+ ****End Comment******/
100
+
101
+ New Linecode.mtx606 nphases=3 Units=mi
102
+ ~ Rmatrix=[0.791721 |0.318476 0.781649 |0.28345 0.318476 0.791721 ]
103
+ ~ Xmatrix=[0.438352 |0.0276838 0.396697 |-0.0184204 0.0276838 0.438352 ]
104
+ ~ Cmatrix=[383.948 |0 383.948 |0 0 383.948 ]
105
+ New linecode.mtx607 nphases=1 BaseFreq=60
106
+ ~ rmatrix = (1.3425 )
107
+ ~ xmatrix = (0.5124 )
108
+ ~ cmatrix = [236]
109
+ ~ units=mi
110
+
111
+
112
+ !LOAD DEFINITIONS
113
+ !New Load.671 Bus1=671.1.2.3 Phases=3 Conn=Delta Model=1 kV=4.16 kW=1155 kvar=660
114
+ New Load.634a Bus1=634.1 Phases=1 Conn=Wye Model=1 kV=0.277 kW=80 kvar=50
115
+ New Load.634b Bus1=634.2 Phases=1 Conn=Wye Model=1 kV=0.277 kW=80 kvar=40
116
+ New Load.634c Bus1=634.3 Phases=1 Conn=Wye Model=1 kV=0.277 kW=60 kvar=40
117
+ New Load.645 Bus1=645.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=170 kvar=125
118
+ New Load.646 Bus1=646.2.3 Phases=1 Conn=Delta Model=1 kV=4.16 kW=230 kvar=132
119
+ New Load.692 Bus1=692.3.1 Phases=1 Conn=Delta Model=1 kV=4.16 kW=170 kvar=151
120
+ New Load.675a Bus1=675.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=85 kvar=30
121
+ New Load.675b Bus1=675.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=68 kvar=20
122
+ New Load.675c Bus1=675.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=90 kvar=65
123
+ New Load.611 Bus1=611.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=140 kvar=80
124
+ New Load.652 Bus1=652.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=128 kvar=86
125
+ New Load.670a Bus1=670.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=117 kvar=68
126
+ New Load.670b Bus1=670.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=86 kvar=38
127
+ New Load.670c Bus1=670.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=57 kvar=30
128
+
129
+ !CAPACITOR DEFINITIONS
130
+ !New Capacitor.Cap1 Bus1=675 phases=3 kVAR=600 kV=4.16
131
+ !New Capacitor.Cap2 Bus1=611.3 phases=1 kVAR=100 kV=2.4
132
+
133
+ !Bus 670 is the concentrated point load of the distributed load on line 632 to 671 located at 1/3 the distance from node 632
134
+
135
+ !LINE DEFINITIONS
136
+ New Line.650632 Phases=3 Bus1=RG60.1.2.3 Bus2=632.1.2.3 LineCode=mtx601 Length=2000 units=ft
137
+ New Line.632670 Phases=3 Bus1=632.1.2.3 Bus2=670.1.2.3 LineCode=mtx601 Length=667 units=ft
138
+ New Line.670671 Phases=3 Bus1=670.1.2.3 Bus2=671.1.2.3 LineCode=mtx601 Length=1333 units=ft
139
+ New Line.671680 Phases=3 Bus1=671.1.2.3 Bus2=680.1.2.3 LineCode=mtx601 Length=1000 units=ft
140
+ New Line.632633 Phases=3 Bus1=632.1.2.3 Bus2=633.1.2.3 LineCode=mtx602 Length=500 units=ft
141
+ New Line.632645 Phases=2 Bus1=632.3.2 Bus2=645.3.2 LineCode=mtx603 Length=500 units=ft
142
+ New Line.645646 Phases=2 Bus1=645.3.2 Bus2=646.3.2 LineCode=mtx603 Length=300 units=ft
143
+ New Line.692675 Phases=3 Bus1=692.1.2.3 Bus2=675.1.2.3 LineCode=mtx606 Length=500 units=ft
144
+ New Line.671684 Phases=2 Bus1=671.1.3 Bus2=684.1.3 LineCode=mtx604 Length=300 units=ft
145
+ New Line.684611 Phases=1 Bus1=684.3 Bus2=611.3 LineCode=mtx605 Length=300 units=ft
146
+ New Line.684652 Phases=1 Bus1=684.1 Bus2=652.1 LineCode=mtx607 Length=800 units=ft
147
+
148
+
149
+ !SWITCH DEFINITIONS
150
+ New Line.671692 Phases=3 Bus1=671 Bus2=692 Switch=y r1=1e-4 r0=1e-4 x1=0.000 x0=0.000 c1=0.000 c0=0.000
151
+
152
+ Set Voltagebases=[115, 4.16, .48]
153
+ calcv
154
+ Solve
155
+ ! Bus coordinates (inlined from IEEE13Node_BusXY.csv)
156
+ SetBusXY bus=SourceBus X=200 Y=400
157
+ SetBusXY bus=650 X=200 Y=350
158
+ SetBusXY bus=RG60 X=200 Y=300
159
+ SetBusXY bus=646 X=0 Y=250
160
+ SetBusXY bus=645 X=100 Y=250
161
+ SetBusXY bus=632 X=200 Y=250
162
+ SetBusXY bus=633 X=350 Y=250
163
+ SetBusXY bus=634 X=400 Y=250
164
+ SetBusXY bus=670 X=200 Y=200
165
+ SetBusXY bus=611 X=0 Y=100
166
+ SetBusXY bus=684 X=100 Y=100
167
+ SetBusXY bus=671 X=200 Y=100
168
+ SetBusXY bus=692 X=250 Y=100
169
+ SetBusXY bus=675 X=400 Y=100
170
+ SetBusXY bus=652 X=100 Y=0
171
+ SetBusXY bus=680 X=200 Y=0
172
+
173
+ !---------------------------------------------------------------------------------------------------------------------------------------------------
174
+ !----------------Show some Results -----------------------------------------------------------------------------------------------------------------
175
+ !---------------------------------------------------------------------------------------------------------------------------------------------------
176
+
177
+
178
+ // Show Voltages LN Nodes
179
+ // Show Currents Elem
180
+ // Show Powers kVA Elem
181
+ // Show Losses
182
+ // Show Taps
183
+
184
+ !---------------------------------------------------------------------------------------------------------------------------------------------------
185
+ !---------------------------------------------------------------------------------------------------------------------------------------------------
186
+ ! Alternate Solution Script
187
+ ! To force the taps to be same as published results, set the transformer taps manually and disable the controls
188
+ !---------------------------------------------------------------------------------------------------------------------------------------------------
189
+ /*
190
+ Transformer.reg1a.Taps=[1.0 1.0625]
191
+ Transformer.reg1b.Taps=[1.0 1.0500]
192
+ Transformer.reg1c.Taps=[1.0 1.06875]
193
+ Set Controlmode=OFF
194
+
195
+ Solve
196
+ */
examples/ieee34/IEEE34Bus.dss ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ! IEEE 34-Bus Test Feeder (with modifications)
2
+ ! Original source: https://github.com/tshort/OpenDSS/tree/master/Distrib/IEEETestCases/34Bus
3
+ ! Modified for studying datacenter loads connected to distribution grids.
4
+ !
5
+ ! Modifications from the original:
6
+ ! - All line lengths halved from the original to create a shorter/stiffer feeder
7
+ ! The original IEEE 34-bus is an unusually long rural feeder (~60 miles);
8
+ ! halving the lengths produces more realistic voltage drops for datacenter-scale load studies.
9
+
10
+ Clear
11
+
12
+ New object=circuit.ieee34-1
13
+ ~ basekv=69 pu=1.05 angle=30 mvasc3=200000 !stiffen up a bit over DSS default
14
+
15
+ ! Substation Transformer -- Modification: Make source very stiff by defining a tiny leakage Z
16
+ New Transformer.SubXF Phases=3 Windings=2 Xhl=0.01 ! normally 8
17
+ ~ wdg=1 bus=sourcebus conn=Delta kv=69 kva=25000 %r=0.0005 !reduce %r, too
18
+ ~ wdg=2 bus=800 conn=wye kv=24.9 kva=25000 %r=0.0005
19
+
20
+ ! import line codes with phase impedance matrices
21
+ Redirect IEEELineCodes.dss ! revised according to Later test feeder doc
22
+
23
+ ! Lines (all lengths halved from original ieee34Mod1.dss)
24
+ New Line.L1 Phases=3 Bus1=800.1.2.3 Bus2=802.1.2.3 LineCode=300 Length=1.29 units=kft
25
+ New Line.L2 Phases=3 Bus1=802.1.2.3 Bus2=806.1.2.3 LineCode=300 Length=0.865 units=kft
26
+ New Line.L3 Phases=3 Bus1=806.1.2.3 Bus2=808.1.2.3 LineCode=300 Length=16.115 units=kft
27
+ New Line.L4 Phases=1 Bus1=808.2 Bus2=810.2 LineCode=303 Length=2.902 units=kft
28
+ New Line.L5 Phases=3 Bus1=808.1.2.3 Bus2=812.1.2.3 LineCode=300 Length=18.75 units=kft
29
+ New Line.L6 Phases=3 Bus1=812.1.2.3 Bus2=814.1.2.3 LineCode=300 Length=14.865 units=kft
30
+ New Line.L7 Phases=3 Bus1=814r.1.2.3 Bus2=850.1.2.3 LineCode=301 Length=0.01 units=kft
31
+ New Line.L8 Phases=1 Bus1=816.1 Bus2=818.1 LineCode=302 Length=0.855 units=kft
32
+ New Line.L9 Phases=3 Bus1=816.1.2.3 Bus2=824.1.2.3 LineCode=301 Length=5.105 units=kft
33
+ New Line.L10 Phases=1 Bus1=818.1 Bus2=820.1 LineCode=302 Length=24.075 units=kft
34
+ New Line.L11 Phases=1 Bus1=820.1 Bus2=822.1 LineCode=302 Length=6.87 units=kft
35
+ New Line.L12 Phases=1 Bus1=824.2 Bus2=826.2 LineCode=303 Length=1.515 units=kft
36
+ New Line.L13 Phases=3 Bus1=824.1.2.3 Bus2=828.1.2.3 LineCode=301 Length=0.42 units=kft
37
+ New Line.L14 Phases=3 Bus1=828.1.2.3 Bus2=830.1.2.3 LineCode=301 Length=10.22 units=kft
38
+ New Line.L15 Phases=3 Bus1=830.1.2.3 Bus2=854.1.2.3 LineCode=301 Length=0.26 units=kft
39
+ New Line.L16 Phases=3 Bus1=832.1.2.3 Bus2=858.1.2.3 LineCode=301 Length=2.45 units=kft
40
+ New Line.L17 Phases=3 Bus1=834.1.2.3 Bus2=860.1.2.3 LineCode=301 Length=1.01 units=kft
41
+ New Line.L18 Phases=3 Bus1=834.1.2.3 Bus2=842.1.2.3 LineCode=301 Length=0.14 units=kft
42
+ New Line.L19 Phases=3 Bus1=836.1.2.3 Bus2=840.1.2.3 LineCode=301 Length=0.43 units=kft
43
+ New Line.L20 Phases=3 Bus1=836.1.2.3 Bus2=862.1.2.3 LineCode=301 Length=0.14 units=kft
44
+ New Line.L21 Phases=3 Bus1=842.1.2.3 Bus2=844.1.2.3 LineCode=301 Length=0.675 units=kft
45
+ New Line.L22 Phases=3 Bus1=844.1.2.3 Bus2=846.1.2.3 LineCode=301 Length=1.82 units=kft
46
+ New Line.L23 Phases=3 Bus1=846.1.2.3 Bus2=848.1.2.3 LineCode=301 Length=0.265 units=kft
47
+ New Line.L24 Phases=3 Bus1=850.1.2.3 Bus2=816.1.2.3 LineCode=301 Length=0.155 units=kft
48
+ New Line.L25 Phases=3 Bus1=852r.1.2.3 Bus2=832.1.2.3 LineCode=301 Length=0.01 units=kft
49
+
50
+ ! 24.9/4.16 kV Transformer
51
+ New Transformer.XFM1 Phases=3 Windings=2 Xhl=4.08
52
+ ~ wdg=1 bus=832 conn=wye kv=24.9 kva=500 %r=0.95
53
+ ~ wdg=2 bus=888 conn=Wye kv=4.16 kva=500 %r=0.95
54
+
55
+ New Line.L26 Phases=1 Bus1=854.2 Bus2=856.2 LineCode=303 Length=11.665 units=kft
56
+ New Line.L27 Phases=3 Bus1=854.1.2.3 Bus2=852.1.2.3 LineCode=301 Length=18.415 units=kft
57
+ ! 9-17-10 858-864 changed to phase A per error report
58
+ New Line.L28 Phases=1 Bus1=858.1 Bus2=864.1 LineCode=303 Length=0.81 units=kft
59
+ New Line.L29 Phases=3 Bus1=858.1.2.3 Bus2=834.1.2.3 LineCode=301 Length=2.915 units=kft
60
+ New Line.L30 Phases=3 Bus1=860.1.2.3 Bus2=836.1.2.3 LineCode=301 Length=1.34 units=kft
61
+ New Line.L31 Phases=1 Bus1=862.2 Bus2=838.2 LineCode=304 Length=2.43 units=kft
62
+ New Line.L32 Phases=3 Bus1=888.1.2.3 Bus2=890.1.2.3 LineCode=300 Length=5.28 units=kft
63
+
64
+ ! Capacitors
65
+ New Capacitor.C844 Bus1=844 Phases=3 kVAR=300 kV=24.9
66
+ New Capacitor.C848 Bus1=848 Phases=3 kVAR=450 kV=24.9
67
+
68
+ ! Regulators - three independent phases
69
+ ! Regulator 1
70
+ new transformer.reg1a phases=1 windings=2 bank=reg1 buses=(814.1 814r.1) conns='wye wye' kvs="14.376 14.376" kvas="20000 20000" XHL=1
71
+ new regcontrol.creg1a transformer=reg1a winding=2 vreg=122 band=2 ptratio=120 ctprim=100 R=2.7 X=1.6
72
+ new transformer.reg1b phases=1 windings=2 bank=reg1 buses=(814.2 814r.2) conns='wye wye' kvs="14.376 14.376" kvas="20000 20000" XHL=1
73
+ new regcontrol.creg1b transformer=reg1b winding=2 vreg=122 band=2 ptratio=120 ctprim=100 R=2.7 X=1.6
74
+ new transformer.reg1c phases=1 windings=2 bank=reg1 buses=(814.3 814r.3) conns='wye wye' kvs="14.376 14.376" kvas="20000 20000" XHL=1
75
+ new regcontrol.creg1c transformer=reg1c winding=2 vreg=122 band=2 ptratio=120 ctprim=100 R=2.7 X=1.6
76
+
77
+ ! Regulator 2
78
+ new transformer.reg2a phases=1 windings=2 bank=reg2 buses=(852.1 852r.1) conns='wye wye' kvs="14.376 14.376" kvas="20000 20000" XHL=1
79
+ new regcontrol.creg2a transformer=reg2a winding=2 vreg=124 band=2 ptratio=120 ctprim=100 R=2.5 X=1.5
80
+ new transformer.reg2b phases=1 windings=2 bank=reg2 buses=(852.2 852r.2) conns='wye wye' kvs="14.376 14.376" kvas="20000 20000" XHL=1
81
+ new regcontrol.creg2b transformer=reg2b winding=2 vreg=124 band=2 ptratio=120 ctprim=100 R=2.5 X=1.5
82
+ new transformer.reg2c phases=1 windings=2 bank=reg2 buses=(852.3 852r.3) conns='wye wye' kvs="14.376 14.376" kvas="20000 20000" XHL=1
83
+ new regcontrol.creg2c transformer=reg2c winding=2 vreg=124 band=2 ptratio=120 ctprim=100 R=2.5 X=1.5
84
+
85
+ ! spot loads
86
+ New Load.S860 Bus1=860 Phases=3 Conn=Wye Model=1 kV= 24.900 kW= 60.0 kVAR= 48.0
87
+ New Load.S840 Bus1=840 Phases=3 Conn=Wye Model=5 kV= 24.900 kW= 27.0 kVAR= 21.0
88
+ New Load.S844 Bus1=844 Phases=3 Conn=Wye Model=2 kV= 24.900 kW= 405.0 kVAR= 315.0
89
+
90
+ New Load.S848 Bus1=848 Phases=3 Conn=Delta Model=1 kV= 24.900 kW= 60.0 kVAR= 48.0
91
+ New Load.S830a Bus1=830.1.2 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 10.0 kVAR= 5.0
92
+ New Load.S830b Bus1=830.2.3 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 10.0 kVAR= 5.0
93
+ New Load.S830c Bus1=830.3.1 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 25.0 kVAR= 10.0
94
+ New Load.S890 Bus1=890 Phases=3 Conn=Delta Model=5 kV= 4.160 kW= 450.0 kVAR= 225.0
95
+
96
+ ! distributed loads
97
+ New Load.D802_806sb Bus1=802.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 15.0 kVAR= 7.5
98
+ New Load.D802_806rb Bus1=806.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 15.0 kVAR= 7.5
99
+ New Load.D802_806sc Bus1=802.3 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 12.5 kVAR= 7.0
100
+ New Load.D802_806rc Bus1=806.3 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 12.5 kVAR= 7.0
101
+
102
+ New Load.D808_810sb Bus1=808.2 Phases=1 Conn=Wye Model=4 kV= 14.376 kW= 8.0 kVAR= 4.0
103
+ New Load.D808_810rb Bus1=810.2 Phases=1 Conn=Wye Model=4 kV= 14.376 kW= 8.0 kVAR= 4.0
104
+
105
+ New Load.D818_820sa Bus1=818.1 Phases=1 Conn=Wye Model=2 kV= 14.376 kW= 17.0 kVAR= 8.5
106
+ New Load.D818_820ra Bus1=820.1 Phases=1 Conn=Wye Model=2 kV= 14.376 kW= 17.0 kVAR= 8.5
107
+
108
+ New Load.D820_822sa Bus1=820.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 67.5 kVAR= 35.0
109
+ New Load.D820_822ra Bus1=822.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 67.5 kVAR= 35.0
110
+
111
+ New Load.D816_824sb Bus1=816.2.3 Phases=1 Conn=Delta Model=5 kV= 24.900 kW= 2.5 kVAR= 1.0
112
+ New Load.D816_824rb Bus1=824.2.3 Phases=1 Conn=Delta Model=5 kV= 24.900 kW= 2.5 kVAR= 1.0
113
+
114
+ New Load.D824_826sb Bus1=824.2 Phases=1 Conn=Wye Model=5 kV= 14.376 kW= 20.0 kVAR= 10.0
115
+ New Load.D824_826rb Bus1=826.2 Phases=1 Conn=Wye Model=5 kV= 14.376 kW= 20.0 kVAR= 10.0
116
+ New Load.D824_828sc Bus1=824.3 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 2.0 kVAR= 1.0
117
+ New Load.D824_828rc Bus1=828.3 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 2.0 kVAR= 1.0
118
+
119
+ New Load.D828_830sa Bus1=828.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 3.5 kVAR= 1.5
120
+ New Load.D828_830ra Bus1=830.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 3.5 kVAR= 1.5
121
+
122
+ New Load.D854_856sb Bus1=854.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 2.0 kVAR= 1.0
123
+ New Load.D854_856rb Bus1=856.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 2.0 kVAR= 1.0
124
+
125
+ New Load.D832_858sa Bus1=832.1 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 3.5 kVAR= 1.5
126
+ New Load.D832_858ra Bus1=858.1 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 3.5 kVAR= 1.5
127
+ New Load.D832_858sb Bus1=832.2 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 1.0 kVAR= 0.5
128
+ New Load.D832_858rb Bus1=858.2 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 1.0 kVAR= 0.5
129
+ New Load.D832_858sc Bus1=832.3 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 3.0 kVAR= 1.5
130
+ New Load.D832_858rc Bus1=858.3 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 3.0 kVAR= 1.5
131
+
132
+ ! 9-17-10 858-864 changed to phase A per error report
133
+ New Load.D858_864sb Bus1=858.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 1.0 kVAR= 0.5
134
+ New Load.D858_864rb Bus1=864.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 1.0 kVAR= 0.5
135
+
136
+ New Load.D858_834sa Bus1=858.1.2 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 2.0 kVAR= 1.0
137
+ New Load.D858_834ra Bus1=834.1.2 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 2.0 kVAR= 1.0
138
+ New Load.D858_834sb Bus1=858.2.3 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 7.5 kVAR= 4.0
139
+ New Load.D858_834rb Bus1=834.2.3 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 7.5 kVAR= 4.0
140
+ New Load.D858_834sc Bus1=858.3.1 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 6.5 kVAR= 3.5
141
+ New Load.D858_834rc Bus1=834.3.1 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 6.5 kVAR= 3.5
142
+
143
+ New Load.D834_860sa Bus1=834.1.2 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 8.0 kVAR= 4.0
144
+ New Load.D834_860ra Bus1=860.1.2 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 8.0 kVAR= 4.0
145
+ New Load.D834_860sb Bus1=834.2.3 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 10.0 kVAR= 5.0
146
+ New Load.D834_860rb Bus1=860.2.3 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 10.0 kVAR= 5.0
147
+ New Load.D834_860sc Bus1=834.3.1 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 55.0 kVAR= 27.5
148
+ New Load.D834_860rc Bus1=860.3.1 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 55.0 kVAR= 27.5
149
+
150
+ New Load.D860_836sa Bus1=860.1.2 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 15.0 kVAR= 7.5
151
+ New Load.D860_836ra Bus1=836.1.2 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 15.0 kVAR= 7.5
152
+ New Load.D860_836sb Bus1=860.2.3 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 5.0 kVAR= 3.0
153
+ New Load.D860_836rb Bus1=836.2.3 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 5.0 kVAR= 3.0
154
+ New Load.D860_836sc Bus1=860.3.1 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 21.0 kVAR= 11.0
155
+ New Load.D860_836rc Bus1=836.3.1 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 21.0 kVAR= 11.0
156
+
157
+ New Load.D836_840sa Bus1=836.1.2 Phases=1 Conn=Delta Model=5 kV= 24.900 kW= 9.0 kVAR= 4.5
158
+ New Load.D836_840ra Bus1=840.1.2 Phases=1 Conn=Delta Model=5 kV= 24.900 kW= 9.0 kVAR= 4.5
159
+ New Load.D836_840sb Bus1=836.2.3 Phases=1 Conn=Delta Model=5 kV= 24.900 kW= 11.0 kVAR= 5.5
160
+ New Load.D836_840rb Bus1=840.2.3 Phases=1 Conn=Delta Model=5 kV= 24.900 kW= 11.0 kVAR= 5.5
161
+
162
+ New Load.D862_838sb Bus1=862.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 14.0 kVAR= 7.0
163
+ New Load.D862_838rb Bus1=838.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 14.0 kVAR= 7.0
164
+
165
+ New Load.D842_844sa Bus1=842.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 4.5 kVAR= 2.5
166
+ New Load.D842_844ra Bus1=844.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 4.5 kVAR= 2.5
167
+
168
+ New Load.D844_846sb Bus1=844.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 12.5 kVAR= 6.0
169
+ New Load.D844_846rb Bus1=846.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 12.5 kVAR= 6.0
170
+ New Load.D844_846sc Bus1=844.3 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 10.0 kVAR= 5.5
171
+ New Load.D844_846rc Bus1=846.3 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 10.0 kVAR= 5.5
172
+
173
+ New Load.D846_848sb Bus1=846.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 11.5 kVAR= 5.5
174
+ New Load.D846_848rb Bus1=848.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 11.5 kVAR= 5.5
175
+
176
+ ! Script to revise Vminpu property on all loads to allow voltage to sag to 85% without switching
177
+ ! to constant Z model
178
+ Load.s860.vminpu=.85
179
+ Load.s840.vminpu=.85
180
+ Load.s844.vminpu=.85
181
+ Load.s848.vminpu=.85
182
+ Load.s830a.vminpu=.85
183
+ Load.s830b.vminpu=.85
184
+ Load.s830c.vminpu=.85
185
+ Load.s890.vminpu=.85
186
+ Load.d802_806sb.vminpu=.85
187
+ Load.d802_806rb.vminpu=.85
188
+ Load.d802_806sc.vminpu=.85
189
+ Load.d802_806rc.vminpu=.85
190
+ Load.d808_810sb.vminpu=.85
191
+ Load.d808_810rb.vminpu=.85
192
+ Load.d818_820sa.vminpu=.85
193
+ Load.d818_820ra.vminpu=.85
194
+ Load.d820_822sa.vminpu=.85
195
+ Load.d820_822ra.vminpu=.85
196
+ Load.d816_824sb.vminpu=.85
197
+ Load.d816_824rb.vminpu=.85
198
+ Load.d824_826sb.vminpu=.85
199
+ Load.d824_826rb.vminpu=.85
200
+ Load.d824_828sc.vminpu=.85
201
+ Load.d824_828rc.vminpu=.85
202
+ Load.d828_830sa.vminpu=.85
203
+ Load.d828_830ra.vminpu=.85
204
+ Load.d854_856sb.vminpu=.85
205
+ Load.d854_856rb.vminpu=.85
206
+ Load.d832_858sa.vminpu=.85
207
+ Load.d832_858ra.vminpu=.85
208
+ Load.d832_858sb.vminpu=.85
209
+ Load.d832_858rb.vminpu=.85
210
+ Load.d832_858sc.vminpu=.85
211
+ Load.d832_858rc.vminpu=.85
212
+ Load.d858_864sb.vminpu=.85
213
+ Load.d858_864rb.vminpu=.85
214
+ Load.d858_834sa.vminpu=.85
215
+ Load.d858_834ra.vminpu=.85
216
+ Load.d858_834sb.vminpu=.85
217
+ Load.d858_834rb.vminpu=.85
218
+ Load.d858_834sc.vminpu=.85
219
+ Load.d858_834rc.vminpu=.85
220
+ Load.d834_860sa.vminpu=.85
221
+ Load.d834_860ra.vminpu=.85
222
+ Load.d834_860sb.vminpu=.85
223
+ Load.d834_860rb.vminpu=.85
224
+ Load.d834_860sc.vminpu=.85
225
+ Load.d834_860rc.vminpu=.85
226
+ Load.d860_836sa.vminpu=.85
227
+ Load.d860_836ra.vminpu=.85
228
+ Load.d860_836sb.vminpu=.85
229
+ Load.d860_836rb.vminpu=.85
230
+ Load.d860_836sc.vminpu=.85
231
+ Load.d860_836rc.vminpu=.85
232
+ Load.d836_840sa.vminpu=.85
233
+ Load.d836_840ra.vminpu=.85
234
+ Load.d836_840sb.vminpu=.85
235
+ Load.d836_840rb.vminpu=.85
236
+ Load.d862_838sb.vminpu=.85
237
+ Load.d862_838rb.vminpu=.85
238
+ Load.d842_844sa.vminpu=.85
239
+ Load.d842_844ra.vminpu=.85
240
+ Load.d844_846sb.vminpu=.85
241
+ Load.d844_846rb.vminpu=.85
242
+ Load.d844_846sc.vminpu=.85
243
+ Load.d844_846rc.vminpu=.85
244
+ Load.d846_848sb.vminpu=.85
245
+ Load.d846_848rb.vminpu=.85
246
+
247
+
248
+ ! let the DSS estimate voltage bases automatically
249
+ Set VoltageBases = "69,24.9,4.16, .48"
250
+ CalcVoltageBases
251
+
252
+ ! Bus coordinates (inlined from IEEE34_BusXY.csv)
253
+ SetBusXY bus=SourceBus X=-300 Y=0
254
+ SetBusXY bus=800 X=0 Y=0
255
+ SetBusXY bus=802 X=400 Y=0
256
+ SetBusXY bus=806 X=700 Y=0
257
+ SetBusXY bus=808 X=1000 Y=0
258
+ SetBusXY bus=810 X=1000 Y=-800
259
+ SetBusXY bus=812 X=1300 Y=0
260
+ SetBusXY bus=814 X=1600 Y=0
261
+ SetBusXY bus=850 X=2500 Y=0
262
+ SetBusXY bus=816 X=2900 Y=0
263
+ SetBusXY bus=818 X=2900 Y=400
264
+ SetBusXY bus=820 X=2900 Y=800
265
+ SetBusXY bus=822 X=2900 Y=1100
266
+ SetBusXY bus=824 X=3200 Y=0
267
+ SetBusXY bus=826 X=3500 Y=0
268
+ SetBusXY bus=828 X=3200 Y=-1200
269
+ SetBusXY bus=830 X=3700 Y=-1200
270
+ SetBusXY bus=854 X=4000 Y=-1200
271
+ SetBusXY bus=856 X=4400 Y=-1200
272
+ SetBusXY bus=852 X=4000 Y=-900
273
+ SetBusXY bus=832 X=4000 Y=-400
274
+ SetBusXY bus=888 X=4700 Y=-400
275
+ SetBusXY bus=890 X=5000 Y=-400
276
+ SetBusXY bus=858 X=4000 Y=0
277
+ SetBusXY bus=864 X=4000 Y=400
278
+ SetBusXY bus=834 X=4400 Y=0
279
+ SetBusXY bus=842 X=4400 Y=400
280
+ SetBusXY bus=844 X=4400 Y=700
281
+ SetBusXY bus=846 X=4400 Y=1100
282
+ SetBusXY bus=848 X=4400 Y=1400
283
+ SetBusXY bus=860 X=5000 Y=0
284
+ SetBusXY bus=836 X=5600 Y=0
285
+ SetBusXY bus=840 X=6000 Y=0
286
+ SetBusXY bus=862 X=5600 Y=-400
287
+ SetBusXY bus=838 X=5600 Y=-800
288
+ SetBusXY bus=814r X=2100 Y=0
289
+ SetBusXY bus=852r X=4000 Y=-600
examples/ieee34/IEEELineCodes.dss ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ! this file was corrected 9/16/2010 to match the values in Kersting's files
2
+
3
+
4
+
5
+ ! These line codes are used in the 123-bus circuit
6
+
7
+ New linecode.1 nphases=3 BaseFreq=60
8
+ !!!~ rmatrix = (0.088205 | 0.0312137 0.0901946 | 0.0306264 0.0316143 0.0889665 )
9
+ !!!~ xmatrix = (0.20744 | 0.0935314 0.200783 | 0.0760312 0.0855879 0.204877 )
10
+ !!!~ cmatrix = (2.90301 | -0.679335 3.15896 | -0.22313 -0.481416 2.8965 )
11
+ ~ rmatrix = [0.086666667 | 0.029545455 0.088371212 | 0.02907197 0.029924242 0.087405303]
12
+ ~ xmatrix = [0.204166667 | 0.095018939 0.198522727 | 0.072897727 0.080227273 0.201723485]
13
+ ~ cmatrix = [2.851710072 | -0.920293787 3.004631862 | -0.350755566 -0.585011253 2.71134756]
14
+
15
+ New linecode.2 nphases=3 BaseFreq=60
16
+ !!!~ rmatrix = (0.0901946 | 0.0316143 0.0889665 | 0.0312137 0.0306264 0.088205 )
17
+ !!!~ xmatrix = (0.200783 | 0.0855879 0.204877 | 0.0935314 0.0760312 0.20744 )
18
+ !!!~ cmatrix = (3.15896 | -0.481416 2.8965 | -0.679335 -0.22313 2.90301 )
19
+ ~ rmatrix = [0.088371212 | 0.02992424 0.087405303 | 0.029545455 0.02907197 0.086666667]
20
+ ~ xmatrix = [0.198522727 | 0.080227273 0.201723485 | 0.095018939 0.072897727 0.204166667]
21
+ ~ cmatrix = [3.004631862 | -0.585011253 2.71134756 | -0.920293787 -0.350755566 2.851710072]
22
+
23
+ New linecode.3 nphases=3 BaseFreq=60
24
+ !!!~ rmatrix = (0.0889665 | 0.0306264 0.088205 | 0.0316143 0.0312137 0.0901946 )
25
+ !!!~ xmatrix = (0.204877 | 0.0760312 0.20744 | 0.0855879 0.0935314 0.200783 )
26
+ !!!~ cmatrix = (2.8965 | -0.22313 2.90301 | -0.481416 -0.679335 3.15896 )
27
+
28
+ ~ rmatrix = [0.087405303 | 0.02907197 0.086666667 | 0.029924242 0.029545455 0.088371212]
29
+ ~ xmatrix = [0.201723485 | 0.072897727 0.204166667 | 0.080227273 0.095018939 0.198522727]
30
+ ~ cmatrix = [2.71134756 | -0.350755566 2.851710072 | -0.585011253 -0.920293787 3.004631862]
31
+
32
+ New linecode.4 nphases=3 BaseFreq=60
33
+ !!!~ rmatrix = (0.0889665 | 0.0316143 0.0901946 | 0.0306264 0.0312137 0.088205 )
34
+ !!!~ xmatrix = (0.204877 | 0.0855879 0.200783 | 0.0760312 0.0935314 0.20744 )
35
+ !!!~ cmatrix = (2.8965 | -0.481416 3.15896 | -0.22313 -0.679335 2.90301 )
36
+ ~ rmatrix = [0.087405303 | 0.029924242 0.088371212 | 0.02907197 0.029545455 0.086666667]
37
+ ~ xmatrix = [0.201723485 | 0.080227273 0.198522727 | 0.072897727 0.095018939 0.204166667]
38
+ ~ cmatrix = [2.71134756 | -0.585011253 3.004631862 | -0.350755566 -0.920293787 2.851710072]
39
+
40
+ New linecode.5 nphases=3 BaseFreq=60
41
+ !!!~ rmatrix = (0.0901946 | 0.0312137 0.088205 | 0.0316143 0.0306264 0.0889665 )
42
+ !!!~ xmatrix = (0.200783 | 0.0935314 0.20744 | 0.0855879 0.0760312 0.204877 )
43
+ !!!~ cmatrix = (3.15896 | -0.679335 2.90301 | -0.481416 -0.22313 2.8965 )
44
+
45
+ ~ rmatrix = [0.088371212 | 0.029545455 0.086666667 | 0.029924242 0.02907197 0.087405303]
46
+ ~ xmatrix = [0.198522727 | 0.095018939 0.204166667 | 0.080227273 0.072897727 0.201723485]
47
+ ~ cmatrix = [3.004631862 | -0.920293787 2.851710072 | -0.585011253 -0.350755566 2.71134756]
48
+
49
+ New linecode.6 nphases=3 BaseFreq=60
50
+ !!!~ rmatrix = (0.088205 | 0.0306264 0.0889665 | 0.0312137 0.0316143 0.0901946 )
51
+ !!!~ xmatrix = (0.20744 | 0.0760312 0.204877 | 0.0935314 0.0855879 0.200783 )
52
+ !!!~ cmatrix = (2.90301 | -0.22313 2.8965 | -0.679335 -0.481416 3.15896 )
53
+ ~ rmatrix = [0.086666667 | 0.02907197 0.087405303 | 0.029545455 0.029924242 0.088371212]
54
+ ~ xmatrix = [0.204166667 | 0.072897727 0.201723485 | 0.095018939 0.080227273 0.198522727]
55
+ ~ cmatrix = [2.851710072 | -0.350755566 2.71134756 | -0.920293787 -0.585011253 3.004631862]
56
+ New linecode.7 nphases=2 BaseFreq=60
57
+ !!!~ rmatrix = (0.088205 | 0.0306264 0.0889665 )
58
+ !!!~ xmatrix = (0.20744 | 0.0760312 0.204877 )
59
+ !!!~ cmatrix = (2.75692 | -0.326659 2.82313 )
60
+ ~ rmatrix = [0.086666667 | 0.02907197 0.087405303]
61
+ ~ xmatrix = [0.204166667 | 0.072897727 0.201723485]
62
+ ~ cmatrix = [2.569829596 | -0.52995137 2.597460011]
63
+ New linecode.8 nphases=2 BaseFreq=60
64
+ !!!~ rmatrix = (0.088205 | 0.0306264 0.0889665 )
65
+ !!!~ xmatrix = (0.20744 | 0.0760312 0.204877 )
66
+ !!!~ cmatrix = (2.75692 | -0.326659 2.82313 )
67
+ ~ rmatrix = [0.086666667 | 0.02907197 0.087405303]
68
+ ~ xmatrix = [0.204166667 | 0.072897727 0.201723485]
69
+ ~ cmatrix = [2.569829596 | -0.52995137 2.597460011]
70
+ New linecode.9 nphases=1 BaseFreq=60
71
+ !!!~ rmatrix = (0.254428 )
72
+ !!!~ xmatrix = (0.259546 )
73
+ !!!~ cmatrix = (2.50575 )
74
+ ~ rmatrix = [0.251742424]
75
+ ~ xmatrix = [0.255208333]
76
+ ~ cmatrix = [2.270366128]
77
+ New linecode.10 nphases=1 BaseFreq=60
78
+ !!!~ rmatrix = (0.254428 )
79
+ !!!~ xmatrix = (0.259546 )
80
+ !!!~ cmatrix = (2.50575 )
81
+ ~ rmatrix = [0.251742424]
82
+ ~ xmatrix = [0.255208333]
83
+ ~ cmatrix = [2.270366128]
84
+ New linecode.11 nphases=1 BaseFreq=60
85
+ !!!~ rmatrix = (0.254428 )
86
+ !!!~ xmatrix = (0.259546 )
87
+ !!!~ cmatrix = (2.50575 )
88
+ ~ rmatrix = [0.251742424]
89
+ ~ xmatrix = [0.255208333]
90
+ ~ cmatrix = [2.270366128]
91
+ New linecode.12 nphases=3 BaseFreq=60
92
+ !!!~ rmatrix = (0.291814 | 0.101656 0.294012 | 0.096494 0.101656 0.291814 )
93
+ !!!~ xmatrix = (0.141848 | 0.0517936 0.13483 | 0.0401881 0.0517936 0.141848 )
94
+ !!!~ cmatrix = (53.4924 | 0 53.4924 | 0 0 53.4924 )
95
+ ~ rmatrix = [0.288049242 | 0.09844697 0.29032197 | 0.093257576 0.09844697 0.288049242]
96
+ ~ xmatrix = [0.142443182 | 0.052556818 0.135643939 | 0.040852273 0.052556818 0.142443182]
97
+ ~ cmatrix = [33.77150149 | 0 33.77150149 | 0 0 33.77150149]
98
+
99
+ ! These line codes are used in the 34-node test feeder
100
+
101
+ New linecode.300 nphases=3 basefreq=60 ! ohms per 1000ft Corrected 11/30/05
102
+ ~ rmatrix = [0.253181818 | 0.039791667 0.250719697 | 0.040340909 0.039128788 0.251780303] !ABC ORDER
103
+ ~ xmatrix = [0.252708333 | 0.109450758 0.256988636 | 0.094981061 0.086950758 0.255132576]
104
+ ~ CMATRIX = [2.680150309 | -0.769281006 2.5610381 | -0.499507676 -0.312072984 2.455590387]
105
+ New linecode.301 nphases=3 basefreq=60
106
+ ~ rmatrix = [0.365530303 | 0.04407197 0.36282197 | 0.04467803 0.043333333 0.363996212]
107
+ ~ xmatrix = [0.267329545 | 0.122007576 0.270473485 | 0.107784091 0.099204545 0.269109848]
108
+ ~ cmatrix = [2.572492163 | -0.72160598 2.464381882 | -0.472329395 -0.298961096 2.368881119]
109
+ New linecode.302 nphases=1 basefreq=60
110
+ ~ rmatrix = (0.530208 )
111
+ ~ xmatrix = (0.281345 )
112
+ ~ cmatrix = (2.12257 )
113
+ New linecode.303 nphases=1 basefreq=60
114
+ ~ rmatrix = (0.530208 )
115
+ ~ xmatrix = (0.281345 )
116
+ ~ cmatrix = (2.12257 )
117
+ New linecode.304 nphases=1 basefreq=60
118
+ ~ rmatrix = (0.363958 )
119
+ ~ xmatrix = (0.269167 )
120
+ ~ cmatrix = (2.1922 )
121
+
122
+
123
+ ! This may be for the 4-node test feeder, but is not actually referenced.
124
+ ! instead, the 4Bus*.dss files all use the wiredata and linegeometry inputs
125
+ ! to calculate these matrices from physical data.
126
+
127
+ New linecode.400 nphases=3 BaseFreq=60
128
+ ~ rmatrix = (0.088205 | 0.0312137 0.0901946 | 0.0306264 0.0316143 0.0889665 )
129
+ ~ xmatrix = (0.20744 | 0.0935314 0.200783 | 0.0760312 0.0855879 0.204877 )
130
+ ~ cmatrix = (2.90301 | -0.679335 3.15896 | -0.22313 -0.481416 2.8965 )
131
+
132
+ ! These are for the 13-node test feeder
133
+
134
+ New linecode.601 nphases=3 BaseFreq=60
135
+ !!!~ rmatrix = (0.0674673 | 0.0312137 0.0654777 | 0.0316143 0.0306264 0.0662392 )
136
+ !!!~ xmatrix = (0.195204 | 0.0935314 0.201861 | 0.0855879 0.0760312 0.199298 )
137
+ !!!~ cmatrix = (3.32591 | -0.743055 3.04217 | -0.525237 -0.238111 3.03116 )
138
+ ~ rmatrix = [0.065625 | 0.029545455 0.063920455 | 0.029924242 0.02907197 0.064659091]
139
+ ~ xmatrix = [0.192784091 | 0.095018939 0.19844697 | 0.080227273 0.072897727 0.195984848]
140
+ ~ cmatrix = [3.164838036 | -1.002632425 2.993981593 | -0.632736516 -0.372608713 2.832670203]
141
+ New linecode.602 nphases=3 BaseFreq=60
142
+ !!!~ rmatrix = (0.144361 | 0.0316143 0.143133 | 0.0312137 0.0306264 0.142372 )
143
+ !!!~ xmatrix = (0.226028 | 0.0855879 0.230122 | 0.0935314 0.0760312 0.232686 )
144
+ !!!~ cmatrix = (3.01091 | -0.443561 2.77543 | -0.624494 -0.209615 2.77847 )
145
+ ~ rmatrix = [0.142537879 | 0.029924242 0.14157197 | 0.029545455 0.02907197 0.140833333]
146
+ ~ xmatrix = [0.22375 | 0.080227273 0.226950758 | 0.095018939 0.072897727 0.229393939]
147
+ ~ cmatrix = [2.863013423 | -0.543414918 2.602031589 | -0.8492585 -0.330962141 2.725162768]
148
+ New linecode.603 nphases=2 BaseFreq=60
149
+ !!!~ rmatrix = (0.254472 | 0.0417943 0.253371 )
150
+ !!!~ xmatrix = (0.259467 | 0.0912376 0.261431 )
151
+ !!!~ cmatrix = (2.54676 | -0.28882 2.49502 )
152
+ ~ rmatrix = [0.251780303 | 0.039128788 0.250719697]
153
+ ~ xmatrix = [0.255132576 | 0.086950758 0.256988636]
154
+ ~ cmatrix = [2.366017603 | -0.452083836 2.343963508]
155
+ New linecode.604 nphases=2 BaseFreq=60
156
+ !!!~ rmatrix = (0.253371 | 0.0417943 0.254472 )
157
+ !!!~ xmatrix = (0.261431 | 0.0912376 0.259467 )
158
+ !!!~ cmatrix = (2.49502 | -0.28882 2.54676 )
159
+ ~ rmatrix = [0.250719697 | 0.039128788 0.251780303]
160
+ ~ xmatrix = [0.256988636 | 0.086950758 0.255132576]
161
+ ~ cmatrix = [2.343963508 | -0.452083836 2.366017603]
162
+ New linecode.605 nphases=1 BaseFreq=60
163
+ !!!~ rmatrix = (0.254428 )
164
+ !!!~ xmatrix = (0.259546 )
165
+ !!!~ cmatrix = (2.50575 )
166
+ ~ rmatrix = [0.251742424]
167
+ ~ xmatrix = [0.255208333]
168
+ ~ cmatrix = [2.270366128]
169
+ New linecode.606 nphases=3 BaseFreq=60
170
+ !!!~ rmatrix = (0.152193 | 0.0611362 0.15035 | 0.0546992 0.0611362 0.152193 )
171
+ !!!~ xmatrix = (0.0825685 | 0.00548281 0.0745027 | -0.00339824 0.00548281 0.0825685 )
172
+ !!!~ cmatrix = (72.7203 | 0 72.7203 | 0 0 72.7203 )
173
+ ~ rmatrix = [0.151174242 | 0.060454545 0.149450758 | 0.053958333 0.060454545 0.151174242]
174
+ ~ xmatrix = [0.084526515 | 0.006212121 0.076534091 | -0.002708333 0.006212121 0.084526515]
175
+ ~ cmatrix = [48.67459408 | 0 48.67459408 | 0 0 48.67459408]
176
+ New linecode.607 nphases=1 BaseFreq=60
177
+ !!!~ rmatrix = (0.255799 )
178
+ !!!~ xmatrix = (0.092284 )
179
+ !!!~ cmatrix = (50.7067 )
180
+ ~ rmatrix = [0.254261364]
181
+ ~ xmatrix = [0.097045455]
182
+ ~ cmatrix = [44.70661522]
183
+
184
+ ! These are for the 37-node test feeder, all underground
185
+
186
+ New linecode.721 nphases=3 BaseFreq=60
187
+ !!!~ rmatrix = (0.0554906 | 0.0127467 0.0501597 | 0.00640446 0.0127467 0.0554906 )
188
+ !!!~ xmatrix = (0.0372331 | -0.00704588 0.0358645 | -0.00796424 -0.00704588 0.0372331 )
189
+ !!!~ cmatrix = (124.851 | 0 124.851 | 0 0 124.851 )
190
+ ~ rmatrix = [0.055416667 | 0.012746212 0.050113636 | 0.006382576 0.012746212 0.055416667]
191
+ ~ xmatrix = [0.037367424 | -0.006969697 0.035984848 | -0.007897727 -0.006969697 0.037367424]
192
+ ~ cmatrix = [80.27484728 | 0 80.27484728 | 0 0 80.27484728]
193
+ New linecode.722 nphases=3 BaseFreq=60
194
+ !!!~ rmatrix = (0.0902251 | 0.0309584 0.0851482 | 0.0234946 0.0309584 0.0902251 )
195
+ !!!~ xmatrix = (0.055991 | -0.00646552 0.0504025 | -0.0117669 -0.00646552 0.055991 )
196
+ !!!~ cmatrix = (93.4896 | 0 93.4896 | 0 0 93.4896 )
197
+ ~ rmatrix = [0.089981061 | 0.030852273 0.085 | 0.023371212 0.030852273 0.089981061]
198
+ ~ xmatrix = [0.056306818 | -0.006174242 0.050719697 | -0.011496212 -0.006174242 0.056306818]
199
+ ~ cmatrix = [64.2184109 | 0 64.2184109 | 0 0 64.2184109]
200
+ New linecode.723 nphases=3 BaseFreq=60
201
+ !!!~ rmatrix = (0.247572 | 0.0947678 0.249104 | 0.0893782 0.0947678 0.247572 )
202
+ !!!~ xmatrix = (0.126339 | 0.0390337 0.118816 | 0.0279344 0.0390337 0.126339 )
203
+ !!!~ cmatrix = (58.108 | 0 58.108 | 0 0 58.108 )
204
+ ~ rmatrix = [0.245 | 0.092253788 0.246628788 | 0.086837121 0.092253788 0.245]
205
+ ~ xmatrix = [0.127140152 | 0.039981061 0.119810606 | 0.028806818 0.039981061 0.127140152]
206
+ ~ cmatrix = [37.5977112 | 0 37.5977112 | 0 0 37.5977112]
207
+ New linecode.724 nphases=3 BaseFreq=60
208
+ !!!~ rmatrix = (0.399883 | 0.101765 0.402011 | 0.0965199 0.101765 0.399883 )
209
+ !!!~ xmatrix = (0.146325 | 0.0510963 0.139305 | 0.0395402 0.0510963 0.146325 )
210
+ !!!~ cmatrix = (46.9685 | 0 46.9685 | 0 0 46.9685 )
211
+ ~ rmatrix = [0.396818182 | 0.098560606 0.399015152 | 0.093295455 0.098560606 0.396818182]
212
+ ~ xmatrix = [0.146931818 | 0.051856061 0.140113636 | 0.040208333 0.051856061 0.146931818]
213
+ ~ cmatrix = [30.26701029 | 0 30.26701029 | 0 0 30.26701029]
examples/rl_controller/build_library.py ADDED
@@ -0,0 +1,1435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build a pre-screened scenario library for PPO training.
2
+
3
+ Generates randomized ieee13 scenarios using the same randomization logic as
4
+ `train_ppo.py:make_sim_factory`, runs both baseline (no controller) and OFO
5
+ on each, and accepts a scenario only if:
6
+
7
+ 1) The baseline episode has non-trivial voltage violation (so the policy
8
+ has something to learn), and
9
+ 2) OFO recovers at least `--min-recovery-frac` of the baseline integral
10
+ violation (so the violation is within the GPU-flexibility envelope).
11
+
12
+ For each accepted scenario the library stores the seed (so the env can
13
+ reproduce it) plus the OFO per-second voltage-penalty trace, which the env
14
+ will subtract from PPO's voltage cost during training to give a fair,
15
+ scenario-difficulty-normalized reward.
16
+
17
+ Usage:
18
+ python examples/rl_controller/build_library.py --system ieee13 \\
19
+ --n-candidates 10 --tag train_n10
20
+ python examples/rl_controller/build_library.py --system ieee13 \\
21
+ --n-candidates 50 --pv-base-kw 200 --tvl-base-kw 200 \\
22
+ --min-recovery-frac 0.8 --tag train_n50
23
+
24
+ Outputs (per `--tag`):
25
+ examples/rl_controller/outputs/<system>/scenario_library/<tag>/
26
+ metadata.json -- build config + per-scenario scalar fields
27
+ traces.npz -- per-scenario voltage penalty arrays
28
+ candidates.csv -- per-candidate stats (accepted + rejected)
29
+ scenario_envelopes.png -- voltage envelope per scenario, baseline vs OFO
30
+ scenario_summary.png -- bar chart of integral violation, baseline vs OFO
31
+
32
+ The library directory is the artifact `train_ppo.py` and `evaluate.py` consume
33
+ through their `--scenario-library` flag.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import csv
39
+ import logging
40
+ import math
41
+ from dataclasses import dataclass
42
+ from pathlib import Path
43
+
44
+ import numpy as np
45
+ from controller import PPOBatchSizeController, SharedPPOBatchSizeController
46
+ from env import ObservationConfig, compute_bus_phase_groups
47
+ from scenarios import (
48
+ EXPERIMENTS,
49
+ DCSite,
50
+ PVSystemSpec,
51
+ ScenarioOpenDSSGrid,
52
+ ScenarioRecord,
53
+ TimeVaryingLoadSpec,
54
+ load_library_data,
55
+ randomize_scenario,
56
+ save_library,
57
+ )
58
+
59
+ from openg2g.controller.ofo import LogisticModelStore, OFOConfig
60
+ from openg2g.controller.rule_based import RuleBasedBatchSizeController, RuleBasedConfig
61
+ from openg2g.controller.tap_schedule import TapScheduleController
62
+ from openg2g.coordinator import Coordinator
63
+ from openg2g.datacenter.config import DatacenterConfig, InferenceModelSpec, ReplicaSchedule, TrainingRun
64
+ from openg2g.datacenter.offline import OfflineDatacenter, OfflineWorkload
65
+ from openg2g.datacenter.workloads.inference import InferenceData
66
+ from openg2g.datacenter.workloads.training import TrainingTrace
67
+ from openg2g.grid.config import TapSchedule
68
+ from openg2g.metrics.voltage import VoltageStats, compute_allbus_voltage_stats
69
+
70
+ from plotting import (
71
+ _extract_batch_data,
72
+ _plot_batch_sizes,
73
+ _plot_envelopes,
74
+ _plot_summary,
75
+ _voltage_envelope,
76
+ _voltage_envelope_by_zone,
77
+ )
78
+ from systems import (
79
+ DT_CTRL,
80
+ DT_DC,
81
+ DT_GRID,
82
+ POWER_AUG,
83
+ SPECS_CACHE_DIR,
84
+ TOTAL_DURATION_S,
85
+ TRAINING_TRACE_PATH,
86
+ V_MAX,
87
+ V_MIN,
88
+ )
89
+
90
+ logger = logging.getLogger("scenario_library")
91
+
92
+
93
+ def run_simulation(
94
+ mode: str,
95
+ *,
96
+ sys: dict,
97
+ dc_sites: dict[str, DCSite],
98
+ ofo_config,
99
+ inference_data: InferenceData,
100
+ training_trace: TrainingTrace,
101
+ logistic_models,
102
+ pv_systems: list[PVSystemSpec] | None = None,
103
+ time_varying_loads: list[TimeVaryingLoadSpec] | None = None,
104
+ tap_schedule: TapSchedule | None = None,
105
+ rule_based_config: RuleBasedConfig | None = None,
106
+ rule_zone_local: bool = False,
107
+ ppo_model: str = "",
108
+ obs_mode: str = "full-voltage",
109
+ training_overlay: dict | None = None,
110
+ save_dir: Path,
111
+ ) -> tuple[VoltageStats, object]:
112
+ """Run a simulation with the specified controller mode.
113
+
114
+ Modes:
115
+ 'baseline': NoopController (no batch control)
116
+ 'rule_based': RuleBasedBatchSizeController
117
+ 'ofo': OFOBatchSizeController
118
+ 'ppo': PPOBatchSizeController / SharedPPOBatchSizeController
119
+
120
+ rule_zone_local: when True AND sys defines `zones` AND there is more than
121
+ one DC site, each rule-based controller observes only the buses in its
122
+ own zone (looked up via the site_id key). Decentralizes credit assignment
123
+ in multi-DC topologies (ieee123). No effect for single-site systems.
124
+
125
+ Returns (VoltageStats, SimulationLog).
126
+ """
127
+ pv_systems = pv_systems or []
128
+ time_varying_loads = time_varying_loads or []
129
+ exclude_buses = tuple(sys["exclude_buses"])
130
+ site_ids = list(dc_sites.keys())
131
+
132
+ training_run = None
133
+ if training_overlay is not None:
134
+ training_run = TrainingRun(
135
+ n_gpus=training_overlay["n_gpus"],
136
+ trace=training_trace,
137
+ target_peak_W_per_gpu=training_overlay["target_peak_W_per_gpu"],
138
+ ).at(t_start=training_overlay["t_start"], t_end=training_overlay["t_end"])
139
+
140
+ datacenters: dict[str, OfflineDatacenter] = {}
141
+ controllers: list = []
142
+ site_specs_map: dict[str, tuple[InferenceModelSpec, ...]] = {}
143
+ primary_bus = ""
144
+
145
+ for site_id, site in dc_sites.items():
146
+ site_specs = tuple(md.spec for md, _ in site.models)
147
+ site_specs_map[site_id] = site_specs
148
+ site_inference = inference_data.filter_models(site_specs)
149
+
150
+ replica_schedules: dict[str, ReplicaSchedule] = {md.spec.model_label: sched for md, sched in site.models}
151
+ initial_batch_sizes = {md.spec.model_label: md.initial_batch_size for md, _ in site.models}
152
+
153
+ dc_config = DatacenterConfig(
154
+ gpus_per_server=8,
155
+ base_kw_per_phase=site.base_kw_per_phase,
156
+ )
157
+ workload_kwargs: dict = {
158
+ "inference_data": site_inference,
159
+ "replica_schedules": replica_schedules,
160
+ "initial_batch_sizes": initial_batch_sizes,
161
+ }
162
+ if training_run is not None:
163
+ workload_kwargs["training"] = training_run
164
+ workload = OfflineWorkload(**workload_kwargs)
165
+
166
+ dc = OfflineDatacenter(
167
+ dc_config,
168
+ workload,
169
+ name=site_id,
170
+ dt_s=DT_DC,
171
+ seed=site.seed,
172
+ power_augmentation=POWER_AUG,
173
+ total_gpu_capacity=site.total_gpu_capacity,
174
+ )
175
+ datacenters[site_id] = dc
176
+ if not primary_bus:
177
+ primary_bus = site.bus
178
+
179
+ grid = ScenarioOpenDSSGrid(
180
+ pv_systems=pv_systems,
181
+ time_varying_loads=time_varying_loads,
182
+ source_pu=sys["source_pu"],
183
+ dss_case_dir=sys["dss_case_dir"],
184
+ dss_master_file=sys["dss_master_file"],
185
+ dt_s=DT_GRID,
186
+ initial_tap_position=sys["initial_taps"],
187
+ exclude_buses=exclude_buses,
188
+ )
189
+ dc_pf = DatacenterConfig(base_kw_per_phase=0).power_factor
190
+ for site_id, dc in datacenters.items():
191
+ site = dc_sites[site_id]
192
+ grid.attach_dc(dc, bus=site.bus, connection_type=site.connection_type, power_factor=dc_pf)
193
+
194
+ if mode == "baseline":
195
+ sched = tap_schedule if tap_schedule is not None else TapSchedule(())
196
+ else:
197
+ sched = TapSchedule(())
198
+ controllers.append(TapScheduleController(schedule=sched, dt_s=DT_CTRL))
199
+
200
+ if mode == "ofo":
201
+ from openg2g.controller.ofo import OFOBatchSizeController
202
+
203
+ for site_id in site_ids:
204
+ site_initial_bs = {md.spec.model_label: md.initial_batch_size for md, _ in dc_sites[site_id].models}
205
+ ofo_ctrl = OFOBatchSizeController(
206
+ site_specs_map[site_id],
207
+ datacenter=datacenters[site_id],
208
+ grid=grid,
209
+ models=logistic_models,
210
+ config=ofo_config,
211
+ dt_s=DT_CTRL,
212
+ initial_batch_sizes=site_initial_bs,
213
+ )
214
+ controllers.append(ofo_ctrl)
215
+
216
+ elif mode == "rule_based" or mode.startswith("rule_based_"):
217
+ rb_config = rule_based_config or RuleBasedConfig(v_min=V_MIN, v_max=V_MAX)
218
+ zones = sys.get("zones") if rule_zone_local else None
219
+ for site_id in site_ids:
220
+ site_initial_bs = {md.spec.model_label: md.initial_batch_size for md, _ in dc_sites[site_id].models}
221
+ zone_buses = None
222
+ if zones is not None and len(site_ids) > 1 and site_id in zones:
223
+ zone_buses = tuple(zones[site_id])
224
+ rb_ctrl = RuleBasedBatchSizeController(
225
+ site_specs_map[site_id],
226
+ datacenter=datacenters[site_id],
227
+ grid=grid,
228
+ config=rb_config,
229
+ dt_s=DT_CTRL,
230
+ exclude_buses=exclude_buses,
231
+ zone_buses=zone_buses,
232
+ initial_batch_sizes=site_initial_bs,
233
+ )
234
+ controllers.append(rb_ctrl)
235
+
236
+ elif mode == "ppo":
237
+ ppo_path = Path(ppo_model).resolve()
238
+
239
+ def _find_vecnormalize(model_file: Path) -> Path | None:
240
+ """Look for the VecNormalize sidecar next to a saved PPO model.
241
+
242
+ Two conventions:
243
+ 1. `<stem>_vecnormalize.pkl`: what `train_ppo.py` writes for the
244
+ final saved model (e.g. `ppo_model.zip` ↔ `ppo_model_vecnormalize.pkl`).
245
+ 2. `<prefix>_vecnormalize_<N>_steps.pkl`: SB3
246
+ `CheckpointCallback`'s sibling for an intermediate checkpoint
247
+ `<prefix>_<N>_steps.zip`.
248
+ """
249
+ mf = Path(model_file)
250
+ stem = mf.with_suffix("").name
251
+ candidates = [mf.parent / f"{stem}_vecnormalize.pkl"]
252
+ if stem.endswith("_steps"):
253
+ # SB3 CheckpointCallback: <prefix>_<N>_steps.zip ↔ <prefix>_vecnormalize_<N>_steps.pkl
254
+ parts = stem.rsplit("_", 2)
255
+ if len(parts) == 3:
256
+ prefix, n, _steps = parts
257
+ candidates.append(mf.parent / f"{prefix}_vecnormalize_{n}_steps.pkl")
258
+ for c in candidates:
259
+ if c.is_file():
260
+ return c
261
+ return None
262
+
263
+ shared_model = None
264
+ if ppo_path.is_dir():
265
+ cand = ppo_path / "ppo_model_shared.zip"
266
+ if cand.exists():
267
+ shared_model = cand
268
+ elif ppo_path.suffix == ".zip" and ppo_path.exists() and len(site_ids) > 1:
269
+ shared_model = ppo_path
270
+ if shared_model is not None and shared_model.exists():
271
+ from stable_baselines3 import PPO as SB3PPO
272
+
273
+ sb3 = SB3PPO.load(str(shared_model.with_suffix("")))
274
+ saved_obs_dim = sb3.observation_space.shape[0]
275
+ n_models_all = sum(len(dc_sites[sid].models) for sid in site_ids)
276
+ zones = sys.get("zones")
277
+
278
+ if obs_mode == "per-zone-summary":
279
+ n_bus_phases = 0
280
+ zone_summary = {zname: tuple(zbuses) for zname, zbuses in zones.items()} if zones else None
281
+ bus_phase_groups = None
282
+ elif obs_mode == "system-summary-only":
283
+ n_bus_phases = 0
284
+ zone_summary = None
285
+ bus_phase_groups = None
286
+ elif obs_mode == "per-bus-summary":
287
+ grid.do_reset()
288
+ grid.start()
289
+ _v_index = grid.v_index
290
+ grid.stop()
291
+ bus_phase_groups = compute_bus_phase_groups(_v_index)
292
+ n_bus_phases = 2 * len(bus_phase_groups)
293
+ zone_summary = {zname: tuple(zbuses) for zname, zbuses in zones.items()} if zones else None
294
+ else: # "full-voltage"
295
+ n_bus_phases = saved_obs_dim - 3 - 5 * n_models_all
296
+ zone_summary = None
297
+ bus_phase_groups = None
298
+
299
+ site_model_mapping = {sid: [md.spec.model_label for md, _ in dc_sites[sid].models] for sid in site_ids}
300
+ all_init_bs = {
301
+ md.spec.model_label: md.initial_batch_size for sid in site_ids for md, _ in dc_sites[sid].models
302
+ }
303
+ obs_config = ObservationConfig.from_multi_site(
304
+ site_specs_map,
305
+ {sid: {md.spec.model_label: sched.initial for md, sched in dc_sites[sid].models} for sid in site_ids},
306
+ n_bus_phases=n_bus_phases,
307
+ initial_batch_sizes=all_init_bs,
308
+ zone_summary=zone_summary,
309
+ bus_phase_groups=bus_phase_groups,
310
+ v_min=V_MIN,
311
+ v_max=V_MAX,
312
+ )
313
+ vn_path = _find_vecnormalize(shared_model)
314
+ if vn_path is not None:
315
+ logger.info("PPO: loading VecNormalize stats from %s", vn_path)
316
+ else:
317
+ logger.warning(
318
+ "PPO: no VecNormalize stats found next to %s; policy will see UNNORMALIZED obs", shared_model
319
+ )
320
+ ppo_ctrl = SharedPPOBatchSizeController(
321
+ datacenter=next(iter(datacenters.values())),
322
+ grid=grid,
323
+ model_path=str(shared_model),
324
+ obs_config=obs_config,
325
+ site_model_mapping=site_model_mapping,
326
+ dt_s=DT_CTRL,
327
+ vecnormalize_path=str(vn_path) if vn_path is not None else None,
328
+ )
329
+ controllers.append(ppo_ctrl)
330
+ else:
331
+ zones = sys.get("zones")
332
+
333
+ for site_id in site_ids:
334
+ if ppo_path.is_dir():
335
+ site_model = str(ppo_path / f"ppo_model_{site_id}.zip")
336
+ elif ppo_path.suffix == ".zip" and len(site_ids) == 1:
337
+ site_model = str(ppo_path)
338
+ else:
339
+ site_model = str(ppo_path.parent / f"ppo_model_{site_id}.zip")
340
+
341
+ from stable_baselines3 import PPO as SB3PPO
342
+
343
+ sb3 = SB3PPO.load(str(Path(site_model).with_suffix("")))
344
+ saved_obs_dim = sb3.observation_space.shape[0]
345
+ n_models_site = len(dc_sites[site_id].models)
346
+ n_bus_phases = saved_obs_dim - 3 - 5 * n_models_site
347
+
348
+ zone_buses = None
349
+ if zones is not None and site_id in zones:
350
+ zone_buses = tuple(zones[site_id])
351
+
352
+ specs = site_specs_map[site_id]
353
+ replica_counts = {md.spec.model_label: sched.initial for md, sched in dc_sites[site_id].models}
354
+ site_init_bs = {md.spec.model_label: md.initial_batch_size for md, _ in dc_sites[site_id].models}
355
+ obs_config = ObservationConfig.from_model_specs(
356
+ specs,
357
+ replica_counts,
358
+ n_bus_phases=n_bus_phases,
359
+ initial_batch_sizes=site_init_bs,
360
+ zone_buses=zone_buses,
361
+ v_min=V_MIN,
362
+ v_max=V_MAX,
363
+ )
364
+ vn_path = _find_vecnormalize(Path(site_model))
365
+ if vn_path is not None:
366
+ logger.info("PPO[%s]: loading VecNormalize stats from %s", site_id, vn_path)
367
+ else:
368
+ logger.warning(
369
+ "PPO[%s]: no VecNormalize stats found next to %s; policy will see UNNORMALIZED obs",
370
+ site_id,
371
+ site_model,
372
+ )
373
+ ppo_ctrl = PPOBatchSizeController(
374
+ specs,
375
+ datacenter=datacenters[site_id],
376
+ grid=grid,
377
+ model_path=site_model,
378
+ obs_config=obs_config,
379
+ dt_s=DT_CTRL,
380
+ vecnormalize_path=str(vn_path) if vn_path is not None else None,
381
+ )
382
+ controllers.append(ppo_ctrl)
383
+
384
+ coord = Coordinator(
385
+ datacenters=list(datacenters.values()),
386
+ grid=grid,
387
+ controllers=controllers,
388
+ total_duration_s=TOTAL_DURATION_S,
389
+ )
390
+
391
+ for ctrl in controllers:
392
+ if isinstance(ctrl, SharedPPOBatchSizeController):
393
+ ctrl.attach_datacenters(datacenters)
394
+
395
+ logger.info("Running %s...", mode)
396
+ log = coord.run()
397
+
398
+ vstats = compute_allbus_voltage_stats(
399
+ log.grid_states,
400
+ v_min=V_MIN,
401
+ v_max=V_MAX,
402
+ exclude_buses=exclude_buses,
403
+ )
404
+ logger.info(
405
+ " %s: viol=%.1fs integral=%.4f vmin=%.4f vmax=%.4f",
406
+ mode,
407
+ vstats.violation_time_s,
408
+ vstats.integral_violation_pu_s,
409
+ vstats.worst_vmin,
410
+ vstats.worst_vmax,
411
+ )
412
+
413
+ return vstats, log
414
+
415
+
416
+ def _per_step_voltage_pen(grid_states, *, v_min: float, v_max: float, exclude_buses: tuple[str, ...]) -> np.ndarray:
417
+ """Compute the per-step voltage penalty (sum of squared violation magnitude).
418
+
419
+ This matches `compute_reward` in `env.py`: at each step the
420
+ penalty is `sum_max(v_min - v, 0)^2 + sum_max(v - v_max, 0)^2` over all
421
+ bus-phases (excluding the substation buses), with NaNs treated as
422
+ "not in violation".
423
+ """
424
+ drop = {b.lower() for b in exclude_buses}
425
+ out = np.zeros(len(grid_states), dtype=np.float64)
426
+ for i, gs in enumerate(grid_states):
427
+ s = 0.0
428
+ for bus in gs.voltages.buses():
429
+ if bus.lower() in drop:
430
+ continue
431
+ pv = gs.voltages[bus]
432
+ for v in (pv.a, pv.b, pv.c):
433
+ if math.isnan(v):
434
+ continue
435
+ if v < v_min:
436
+ s += (v_min - v) ** 2
437
+ elif v > v_max:
438
+ s += (v - v_max) ** 2
439
+ out[i] = s
440
+ return out
441
+
442
+
443
+ def _under_over_voltage_time(
444
+ grid_states, *, v_min: float, v_max: float, exclude_buses: tuple[str, ...]
445
+ ) -> tuple[float, float]:
446
+ """Return (undervoltage_time_s, overvoltage_time_s).
447
+
448
+ A step counts as 'undervoltage' if ANY non-excluded bus-phase is below
449
+ v_min, and as 'overvoltage' if ANY is above v_max. A step can count for
450
+ both (they are not mutually exclusive).
451
+ """
452
+ drop = {b.lower() for b in exclude_buses}
453
+ under_steps = 0
454
+ over_steps = 0
455
+ for gs in grid_states:
456
+ has_under = False
457
+ has_over = False
458
+ for bus in gs.voltages.buses():
459
+ if bus.lower() in drop:
460
+ continue
461
+ pv = gs.voltages[bus]
462
+ for v in (pv.a, pv.b, pv.c):
463
+ if math.isnan(v):
464
+ continue
465
+ if v < v_min:
466
+ has_under = True
467
+ if v > v_max:
468
+ has_over = True
469
+ if has_under and has_over:
470
+ break
471
+ if has_under:
472
+ under_steps += 1
473
+ if has_over:
474
+ over_steps += 1
475
+ return float(under_steps), float(over_steps)
476
+
477
+
478
+ def _zone_phase_integral(
479
+ grid_states,
480
+ *,
481
+ zones: dict[str, list[str]],
482
+ exclude_buses: tuple[str, ...],
483
+ v_min: float,
484
+ v_max: float,
485
+ ) -> dict[str, dict[str, float]]:
486
+ """Return {zone: {phase: integral_pu_s}} for violation breakdown.
487
+
488
+ Integral uses the same linear formula as compute_allbus_voltage_stats:
489
+ sum over t of (max(v_min-v,0) + max(v-v_max,0)) * dt.
490
+ """
491
+ drop = {b.lower() for b in exclude_buses}
492
+ zone_sets = {z: {b.lower() for b in buses} for z, buses in zones.items()}
493
+ acc: dict[str, dict[str, float]] = {z: {"a": 0.0, "b": 0.0, "c": 0.0} for z in zones}
494
+ times = [gs.time_s for gs in grid_states]
495
+ dt = (times[1] - times[0]) if len(times) > 1 else 1.0
496
+ for gs in grid_states:
497
+ for bus in gs.voltages.buses():
498
+ bl = bus.lower()
499
+ if bl in drop:
500
+ continue
501
+ pv = gs.voltages[bus]
502
+ for z, bset in zone_sets.items():
503
+ if bl not in bset:
504
+ continue
505
+ for ph in ("a", "b", "c"):
506
+ v = getattr(pv, ph, float("nan"))
507
+ if math.isnan(v):
508
+ continue
509
+ viol = max(v_min - v, 0.0) + max(v - v_max, 0.0)
510
+ acc[z][ph] += viol * dt
511
+ return acc
512
+
513
+
514
+ def _build_run_kwargs(
515
+ *,
516
+ base_exp: dict,
517
+ scenario: dict,
518
+ ofo_config: OFOConfig,
519
+ inference_data: InferenceData,
520
+ training_trace: TrainingTrace,
521
+ logistic_models: LogisticModelStore,
522
+ save_dir: Path,
523
+ ) -> dict:
524
+ """Pack arguments for simulation.run_simulation."""
525
+ return dict(
526
+ sys=base_exp["sys"],
527
+ dc_sites=scenario["dc_sites"],
528
+ ofo_config=ofo_config,
529
+ inference_data=inference_data,
530
+ training_trace=training_trace,
531
+ logistic_models=logistic_models,
532
+ pv_systems=scenario["pv_systems"],
533
+ time_varying_loads=scenario["tvl"],
534
+ tap_schedule=None, # baseline AND ofo run in 'no-tap' mode (matches run_baseline.py --mode no-tap)
535
+ rule_based_config=None,
536
+ ppo_model="",
537
+ training_overlay=scenario["params"]["training_overlay"],
538
+ save_dir=save_dir,
539
+ )
540
+
541
+
542
+ def _is_always_minimum_batch(ofo_log, min_batch_size: int = 8, warmup_s: float = 10.0) -> bool:
543
+ """Return True if every model stays at min_batch_size for the entire episode after warmup_s.
544
+
545
+ Skips the first warmup_s seconds to allow for initial transient drops before OFO
546
+ has had a chance to act (batch may be at minimum at t=0 before the first control step).
547
+ """
548
+ for states in ofo_log.dc_states_by_site.values():
549
+ for s in states:
550
+ if s.time_s <= warmup_s:
551
+ continue
552
+ for bs in s.batch_size_by_model.values():
553
+ if bs > min_batch_size:
554
+ return False
555
+ return True
556
+
557
+
558
+ def main(
559
+ *,
560
+ system: str,
561
+ n_candidates: int,
562
+ seeds: tuple[int, ...] = (),
563
+ min_recovery_frac: float,
564
+ max_recovery_frac: float = 1.0,
565
+ min_baseline_integral: float,
566
+ min_baseline_integral_over: float,
567
+ max_baseline_integral: float,
568
+ pv_base_kw: float | None,
569
+ tvl_base_kw: float | None,
570
+ sensitivity_update_interval: int,
571
+ ofo_w_throughput: float,
572
+ tag: str,
573
+ seed_start: int = 0,
574
+ use_training_overlay: bool = True,
575
+ randomize_ramps: bool = True,
576
+ randomization_profile: bool = True,
577
+ pv_scale_min: float = 0.5,
578
+ pv_scale_max: float = 2.0,
579
+ load_scale_min: float = 0.5,
580
+ load_scale_max: float = 2.0,
581
+ pv_t_shift_max_s: float = 0.0,
582
+ tvl_t_shift_max_s: float = 0.0,
583
+ pv_warp_min: float = 1.0,
584
+ pv_warp_max: float = 1.0,
585
+ tvl_warp_min: float = 1.0,
586
+ tvl_warp_max: float = 1.0,
587
+ overlay_prob: float = 1.0,
588
+ overlay_intensity_min: float = 1.0,
589
+ overlay_intensity_max: float = 1.0,
590
+ overlay_gpu_frac_min: float = 0.85,
591
+ overlay_gpu_frac_max: float = 1.0,
592
+ n_ramps_per_site_choices: tuple[int, ...] = (1,),
593
+ ramp_up_prob: float = 0.0,
594
+ ramp_down_frac_min: float = 0.5,
595
+ ramp_down_frac_max: float = 0.85,
596
+ ramp_up_frac_min: float = 1.05,
597
+ ramp_up_frac_max: float = 1.5,
598
+ ramp_start_min: float = 500.0,
599
+ ramp_start_max: float = 3000.0,
600
+ ramp_dur_min: float = 300.0,
601
+ ramp_dur_max: float = 800.0,
602
+ randomize_pv_profile: bool = False,
603
+ pv_shape_choices: tuple[str, ...] = (
604
+ "flat",
605
+ "rising_falling",
606
+ "morning_ramp",
607
+ "afternoon_decline",
608
+ "midday_dip",
609
+ ),
610
+ pv_baseline_min: float = 0.75,
611
+ pv_baseline_max: float = 0.95,
612
+ pv_cloud_count_max: int = 3,
613
+ pv_cloud_depth_min: float = 0.30,
614
+ pv_cloud_depth_max: float = 0.70,
615
+ pv_cloud_width_min: float = 60.0,
616
+ pv_cloud_width_max: float = 300.0,
617
+ randomize_tvl_profile: bool = False,
618
+ tvl_shape_choices: tuple[str, ...] = ("flat", "increasing", "decreasing", "peaked", "valley"),
619
+ max_always_min: int = 5,
620
+ t_control_start_buffer: int = 200,
621
+ t_control_end_buffer: int = 300,
622
+ log_level: str,
623
+ append_to: Path | None = None,
624
+ ) -> None:
625
+ logging.basicConfig(
626
+ level=getattr(logging, log_level),
627
+ format="%(levelname)s %(asctime)s [%(name)s:%(lineno)d] %(message)s",
628
+ datefmt="%H:%M:%S",
629
+ )
630
+ logging.getLogger("openg2g.coordinator").setLevel(logging.WARNING)
631
+ logging.getLogger("openg2g.datacenter").setLevel(logging.WARNING)
632
+ logging.getLogger("openg2g.grid").setLevel(logging.WARNING)
633
+ logging.getLogger("openg2g.controller.ofo").setLevel(logging.WARNING)
634
+ logging.getLogger("controller_comparison").setLevel(logging.WARNING)
635
+
636
+ if system not in EXPERIMENTS:
637
+ raise ValueError(f"Unknown system '{system}'. Available: {list(EXPERIMENTS)}")
638
+ out_dir = Path(__file__).resolve().parent / "outputs" / system / "scenario_library" / tag
639
+ out_dir.mkdir(parents=True, exist_ok=True)
640
+ logger.info("Writing scenario library to %s", out_dir)
641
+ logger.info(
642
+ "system=%s use_training_overlay=%s randomize_ramps=%s",
643
+ system,
644
+ use_training_overlay,
645
+ randomize_ramps,
646
+ )
647
+
648
+ # ── Load data once (per-spec content-addressed cache under SPECS_CACHE_DIR) ──
649
+ logger.info("Loading data...")
650
+ base_exp = EXPERIMENTS[system]()
651
+ sys_cfg = base_exp["sys"]
652
+ exclude_buses = tuple(sys_cfg["exclude_buses"])
653
+ zones: dict[str, list[str]] | None = sys_cfg.get("zones") or None
654
+
655
+ # Override OFO sensitivity_update_interval and w_throughput so the
656
+ # screening OFO matches the eval OFO exactly. 300 = re-estimate every
657
+ # 5 simulated minutes, keeping the H-matrix fresh through the violation
658
+ # window. w_throughput override ensures the screening OFO is laser-focused
659
+ # on voltage (matching the eval pipeline value of 0.0001).
660
+ base_ofo = base_exp["ofo_config"]
661
+ ofo_config = base_ofo.model_copy(
662
+ update={
663
+ "sensitivity_update_interval": sensitivity_update_interval,
664
+ "w_throughput": ofo_w_throughput,
665
+ }
666
+ )
667
+ logger.info(
668
+ "OFO config: sensitivity_update_interval=%d, w_throughput=%g (was %g)",
669
+ sensitivity_update_interval,
670
+ ofo_w_throughput,
671
+ base_ofo.w_throughput,
672
+ )
673
+
674
+ # Override the base PV/TVL with caller-provided values, then let
675
+ # randomize_scenario apply the [0.5, 2.0] scale factor on top.
676
+ # When pv_base_kw/tvl_base_kw is None, keep the per-system defaults
677
+ # from the experiment definition (this is the default for ieee34
678
+ # since each bus has a distinct peak kW that we want to preserve).
679
+ pv_systems_base = [
680
+ PVSystemSpec(bus=p.bus, bus_kv=p.bus_kv, peak_kw=(pv_base_kw if pv_base_kw is not None else p.peak_kw))
681
+ for p in base_exp["pv_systems"]
682
+ ]
683
+ tvl_base = [
684
+ TimeVaryingLoadSpec(bus=t.bus, bus_kv=t.bus_kv, peak_kw=(tvl_base_kw if tvl_base_kw is not None else t.peak_kw))
685
+ for t in base_exp["time_varying_loads"]
686
+ ]
687
+ logger.info(
688
+ "PV base total=%.0f kW across %d systems, TVL base total=%.0f kW across %d loads (× scale ∈ [0.5, 2.0] per scenario)", # noqa: E501
689
+ sum(p.peak_kw for p in pv_systems_base),
690
+ len(pv_systems_base),
691
+ sum(t.peak_kw for t in tvl_base),
692
+ len(tvl_base),
693
+ )
694
+
695
+ # All model specs across the single DC site
696
+ all_specs = []
697
+ for site in base_exp["dc_sites"].values():
698
+ for md, _ in site.models:
699
+ all_specs.append(md.spec)
700
+ all_specs_tuple = tuple(all_specs)
701
+
702
+ inference_data = InferenceData.ensure(
703
+ SPECS_CACHE_DIR,
704
+ all_specs_tuple,
705
+ plot=False,
706
+ dt_s=float(DT_DC),
707
+ )
708
+ training_trace = TrainingTrace.ensure(TRAINING_TRACE_PATH)
709
+ logistic_models = LogisticModelStore.ensure(
710
+ SPECS_CACHE_DIR,
711
+ all_specs_tuple,
712
+ plot=False,
713
+ )
714
+
715
+ # Training overlay base: same defaults as train_ppo._ieee13_experiment.
716
+ # Only used when use_training_overlay is True (ieee13 default). For ieee34
717
+ # the in-distribution scenarios deliberately skip the training overlay so
718
+ # PV/TVL scales are the sole source of randomness (overlay is reserved for
719
+ # OOD scenarios).
720
+ if use_training_overlay:
721
+ training_base = {
722
+ "trace": training_trace,
723
+ "n_gpus": 2400,
724
+ "target_peak_W_per_gpu": 400.0,
725
+ "t_start": 1000.0,
726
+ "t_end": 2000.0,
727
+ }
728
+ else:
729
+ training_base = None
730
+
731
+ accepted: list[ScenarioRecord] = []
732
+ envelopes: dict[int, dict] = {}
733
+ batch_data: dict[int, dict] = {}
734
+ all_stats: list[dict] = []
735
+ n_always_min_accepted: int = 0
736
+ MAX_ALWAYS_MIN = max_always_min
737
+
738
+ seed_list = list(seeds) if seeds else [(seed_start + i) * 1000 + 7 for i in range(n_candidates)]
739
+ n_total = len(seed_list)
740
+ for cand_idx, effective_seed in enumerate(seed_list):
741
+ logger.info("=" * 60)
742
+ logger.info("Candidate %d/%d (seed=%d)", cand_idx + 1, n_total, effective_seed)
743
+
744
+ scenario = randomize_scenario(
745
+ seed=effective_seed,
746
+ dc_sites_base=base_exp["dc_sites"],
747
+ pv_systems_base=pv_systems_base,
748
+ tvl_base=tvl_base,
749
+ training_base=training_base,
750
+ randomize_ramps=randomize_ramps,
751
+ randomization_profile=randomization_profile,
752
+ pv_scale_min=pv_scale_min,
753
+ pv_scale_max=pv_scale_max,
754
+ load_scale_min=load_scale_min,
755
+ load_scale_max=load_scale_max,
756
+ pv_t_shift_max_s=pv_t_shift_max_s,
757
+ tvl_t_shift_max_s=tvl_t_shift_max_s,
758
+ pv_warp_min=pv_warp_min,
759
+ pv_warp_max=pv_warp_max,
760
+ tvl_warp_min=tvl_warp_min,
761
+ tvl_warp_max=tvl_warp_max,
762
+ overlay_prob=overlay_prob,
763
+ overlay_intensity_min=overlay_intensity_min,
764
+ overlay_intensity_max=overlay_intensity_max,
765
+ overlay_gpu_frac_min=overlay_gpu_frac_min,
766
+ overlay_gpu_frac_max=overlay_gpu_frac_max,
767
+ n_ramps_per_site_choices=tuple(n_ramps_per_site_choices),
768
+ ramp_up_prob=ramp_up_prob,
769
+ ramp_down_frac_min=ramp_down_frac_min,
770
+ ramp_down_frac_max=ramp_down_frac_max,
771
+ ramp_up_frac_min=ramp_up_frac_min,
772
+ ramp_up_frac_max=ramp_up_frac_max,
773
+ ramp_start_min=ramp_start_min,
774
+ ramp_start_max=ramp_start_max,
775
+ ramp_dur_min=ramp_dur_min,
776
+ ramp_dur_max=ramp_dur_max,
777
+ randomize_pv_profile=randomize_pv_profile,
778
+ pv_shape_choices=tuple(pv_shape_choices),
779
+ pv_baseline_min=pv_baseline_min,
780
+ pv_baseline_max=pv_baseline_max,
781
+ pv_cloud_count_max=pv_cloud_count_max,
782
+ pv_cloud_depth_min=pv_cloud_depth_min,
783
+ pv_cloud_depth_max=pv_cloud_depth_max,
784
+ pv_cloud_width_min=pv_cloud_width_min,
785
+ pv_cloud_width_max=pv_cloud_width_max,
786
+ randomize_tvl_profile=randomize_tvl_profile,
787
+ tvl_shape_choices=tuple(tvl_shape_choices),
788
+ )
789
+
790
+ # Build run kwargs once
791
+ run_kwargs = _build_run_kwargs(
792
+ base_exp=base_exp,
793
+ scenario=scenario,
794
+ ofo_config=ofo_config,
795
+ inference_data=inference_data,
796
+ training_trace=training_trace,
797
+ logistic_models=logistic_models,
798
+ save_dir=out_dir,
799
+ )
800
+
801
+ # ── baseline ──
802
+ bl_stats, bl_log = run_simulation("baseline", **run_kwargs)
803
+ baseline_pen = _per_step_voltage_pen(bl_log.grid_states, v_min=V_MIN, v_max=V_MAX, exclude_buses=exclude_buses)
804
+ baseline_int = float(bl_stats.integral_violation_pu_s)
805
+ baseline_viol_t = float(bl_stats.violation_time_s)
806
+ bl_under_t, bl_over_t = _under_over_voltage_time(
807
+ bl_log.grid_states, v_min=V_MIN, v_max=V_MAX, exclude_buses=exclude_buses
808
+ )
809
+
810
+ # ── OFO ──
811
+ ofo_stats, ofo_log = run_simulation("ofo", **run_kwargs)
812
+ ofo_pen = _per_step_voltage_pen(ofo_log.grid_states, v_min=V_MIN, v_max=V_MAX, exclude_buses=exclude_buses)
813
+ ofo_int = float(ofo_stats.integral_violation_pu_s)
814
+ ofo_viol_t = float(ofo_stats.violation_time_s)
815
+ ofo_under_t, ofo_over_t = _under_over_voltage_time(
816
+ ofo_log.grid_states, v_min=V_MIN, v_max=V_MAX, exclude_buses=exclude_buses
817
+ )
818
+
819
+ recovery = (baseline_int - ofo_int) / baseline_int if baseline_int > 0 else 0.0
820
+ is_pure_overvoltage = bl_over_t > 0 and bl_under_t == 0
821
+ is_mixed = bl_over_t > 0 and bl_under_t > 0
822
+ effective_min_integral = (
823
+ min_baseline_integral_over if (is_pure_overvoltage or is_mixed) else min_baseline_integral
824
+ )
825
+ passes = (
826
+ baseline_int >= effective_min_integral
827
+ and baseline_int <= max_baseline_integral
828
+ and recovery >= min_recovery_frac
829
+ and recovery <= max_recovery_frac
830
+ )
831
+
832
+ logger.info(
833
+ " baseline: int=%.3f viol=%.0fs (under=%.0fs over=%.0fs) | "
834
+ "ofo: int=%.3f viol=%.0fs (under=%.0fs over=%.0fs) | recovery=%.0f%% | %s",
835
+ baseline_int,
836
+ baseline_viol_t,
837
+ bl_under_t,
838
+ bl_over_t,
839
+ ofo_int,
840
+ ofo_viol_t,
841
+ ofo_under_t,
842
+ ofo_over_t,
843
+ 100 * recovery,
844
+ "ACCEPT" if passes else "reject",
845
+ )
846
+
847
+ all_stats.append(
848
+ dict(
849
+ seed=effective_seed,
850
+ pv_scale=scenario["params"]["pv_scale"],
851
+ load_scale=scenario["params"]["load_scale"],
852
+ training_overlay=scenario["params"]["training_overlay"],
853
+ baseline_integral=baseline_int,
854
+ ofo_integral=ofo_int,
855
+ baseline_violation_time_s=baseline_viol_t,
856
+ ofo_violation_time_s=ofo_viol_t,
857
+ bl_undervoltage_time_s=bl_under_t,
858
+ bl_overvoltage_time_s=bl_over_t,
859
+ ofo_undervoltage_time_s=ofo_under_t,
860
+ ofo_overvoltage_time_s=ofo_over_t,
861
+ recovery_frac=recovery,
862
+ accepted=passes,
863
+ )
864
+ )
865
+
866
+ # Log per-zone, per-phase baseline integral breakdown for every candidate
867
+ # (especially useful for diagnosing rejected scenarios in multi-zone feeders).
868
+ if zones:
869
+ bl_zone_phase = _zone_phase_integral(
870
+ bl_log.grid_states,
871
+ zones=zones,
872
+ exclude_buses=exclude_buses,
873
+ v_min=V_MIN,
874
+ v_max=V_MAX,
875
+ )
876
+ zone_summary = " baseline zone/phase integral: " + " | ".join(
877
+ f"{z}: A={bl_zone_phase[z]['a']:.1f} B={bl_zone_phase[z]['b']:.1f} C={bl_zone_phase[z]['c']:.1f}"
878
+ for z in zones
879
+ )
880
+ logger.info(zone_summary)
881
+
882
+ if passes:
883
+ always_min = _is_always_minimum_batch(ofo_log)
884
+ if always_min:
885
+ if n_always_min_accepted >= MAX_ALWAYS_MIN:
886
+ logger.info(
887
+ " seed=%d: OFO always at min batch: cap reached (%d/%d), skipping",
888
+ effective_seed,
889
+ n_always_min_accepted,
890
+ MAX_ALWAYS_MIN,
891
+ )
892
+ passes = False
893
+ else:
894
+ n_always_min_accepted += 1
895
+ logger.info(
896
+ " seed=%d: OFO always at min batch: accepting (%d/%d)",
897
+ effective_seed,
898
+ n_always_min_accepted,
899
+ MAX_ALWAYS_MIN,
900
+ )
901
+ if passes:
902
+ nonzero_steps = np.nonzero(baseline_pen > 0)[0]
903
+ if len(nonzero_steps) > 0:
904
+ t_first = int(nonzero_steps[0])
905
+ t_last = int(nonzero_steps[-1])
906
+ else:
907
+ t_first = 0
908
+ t_last = len(baseline_pen)
909
+ t_ctrl_start = max(0, t_first - t_control_start_buffer)
910
+ t_ctrl_end = min(len(baseline_pen), t_last + t_control_end_buffer)
911
+ logger.info(
912
+ " control window: violation [%d, %d] → [%d, %d] (%d steps, saves %d%%)",
913
+ t_first,
914
+ t_last,
915
+ t_ctrl_start,
916
+ t_ctrl_end,
917
+ t_ctrl_end - t_ctrl_start,
918
+ 100 * (len(baseline_pen) - (t_ctrl_end - t_ctrl_start)) // len(baseline_pen),
919
+ )
920
+ rec = ScenarioRecord(
921
+ seed=effective_seed,
922
+ pv_scale=scenario["params"]["pv_scale"],
923
+ load_scale=scenario["params"]["load_scale"],
924
+ training_overlay=scenario["params"]["training_overlay"],
925
+ baseline_integral=baseline_int,
926
+ ofo_integral=ofo_int,
927
+ baseline_violation_time_s=baseline_viol_t,
928
+ ofo_violation_time_s=ofo_viol_t,
929
+ recovery_frac=recovery,
930
+ bl_undervoltage_time_s=bl_under_t,
931
+ bl_overvoltage_time_s=bl_over_t,
932
+ ofo_voltage_pen_per_step=ofo_pen,
933
+ baseline_voltage_pen_per_step=baseline_pen,
934
+ t_control_start=t_ctrl_start,
935
+ t_control_end=t_ctrl_end,
936
+ )
937
+ accepted.append(rec)
938
+ env_entry: dict = {
939
+ "baseline": _voltage_envelope(bl_log.grid_states, exclude_buses=exclude_buses),
940
+ "ofo": _voltage_envelope(ofo_log.grid_states, exclude_buses=exclude_buses),
941
+ }
942
+ if zones:
943
+ env_entry["baseline_zones"] = _voltage_envelope_by_zone(
944
+ bl_log.grid_states,
945
+ zones=zones,
946
+ exclude_buses=exclude_buses,
947
+ )
948
+ env_entry["ofo_zones"] = _voltage_envelope_by_zone(
949
+ ofo_log.grid_states,
950
+ zones=zones,
951
+ exclude_buses=exclude_buses,
952
+ )
953
+ envelopes[effective_seed] = env_entry
954
+ batch_data[effective_seed] = {
955
+ "baseline": _extract_batch_data(bl_log),
956
+ "ofo": _extract_batch_data(ofo_log),
957
+ }
958
+
959
+ # ── Save artifacts ──
960
+ randomize_kwargs = {
961
+ "randomize_ramps": randomize_ramps,
962
+ "randomization_profile": randomization_profile,
963
+ "pv_scale_min": pv_scale_min,
964
+ "pv_scale_max": pv_scale_max,
965
+ "load_scale_min": load_scale_min,
966
+ "load_scale_max": load_scale_max,
967
+ "pv_t_shift_max_s": pv_t_shift_max_s,
968
+ "tvl_t_shift_max_s": tvl_t_shift_max_s,
969
+ "pv_warp_min": pv_warp_min,
970
+ "pv_warp_max": pv_warp_max,
971
+ "tvl_warp_min": tvl_warp_min,
972
+ "tvl_warp_max": tvl_warp_max,
973
+ "overlay_prob": overlay_prob,
974
+ "overlay_intensity_min": overlay_intensity_min,
975
+ "overlay_intensity_max": overlay_intensity_max,
976
+ "overlay_gpu_frac_min": overlay_gpu_frac_min,
977
+ "overlay_gpu_frac_max": overlay_gpu_frac_max,
978
+ "n_ramps_per_site_choices": tuple(n_ramps_per_site_choices),
979
+ "ramp_up_prob": ramp_up_prob,
980
+ "ramp_down_frac_min": ramp_down_frac_min,
981
+ "ramp_down_frac_max": ramp_down_frac_max,
982
+ "ramp_up_frac_min": ramp_up_frac_min,
983
+ "ramp_up_frac_max": ramp_up_frac_max,
984
+ "ramp_start_min": ramp_start_min,
985
+ "ramp_start_max": ramp_start_max,
986
+ "ramp_dur_min": ramp_dur_min,
987
+ "ramp_dur_max": ramp_dur_max,
988
+ "randomize_pv_profile": randomize_pv_profile,
989
+ "pv_shape_choices": tuple(pv_shape_choices),
990
+ "pv_baseline_min": pv_baseline_min,
991
+ "pv_baseline_max": pv_baseline_max,
992
+ "pv_cloud_count_max": pv_cloud_count_max,
993
+ "pv_cloud_depth_min": pv_cloud_depth_min,
994
+ "pv_cloud_depth_max": pv_cloud_depth_max,
995
+ "pv_cloud_width_min": pv_cloud_width_min,
996
+ "pv_cloud_width_max": pv_cloud_width_max,
997
+ "randomize_tvl_profile": randomize_tvl_profile,
998
+ "tvl_shape_choices": tuple(tvl_shape_choices),
999
+ }
1000
+
1001
+ config = {
1002
+ "system": system,
1003
+ "n_candidates": n_total,
1004
+ "min_recovery_frac": min_recovery_frac,
1005
+ "max_recovery_frac": max_recovery_frac,
1006
+ "min_baseline_integral": min_baseline_integral,
1007
+ "pv_base_kw": pv_base_kw,
1008
+ "tvl_base_kw": tvl_base_kw,
1009
+ "use_training_overlay": use_training_overlay,
1010
+ "randomize_ramps": randomize_ramps,
1011
+ "randomize_kwargs": randomize_kwargs,
1012
+ "pv_systems_base": [{"bus": p.bus, "bus_kv": p.bus_kv, "peak_kw": p.peak_kw} for p in pv_systems_base],
1013
+ "tvl_base": [{"bus": t.bus, "bus_kv": t.bus_kv, "peak_kw": t.peak_kw} for t in tvl_base],
1014
+ "v_min": V_MIN,
1015
+ "v_max": V_MAX,
1016
+ # Build-time training-overlay parameters (the trace itself is loaded
1017
+ # from disk at consumption time and not stored here).
1018
+ "training_base": (
1019
+ {
1020
+ "n_gpus": 2400,
1021
+ "target_peak_W_per_gpu": 400.0,
1022
+ "t_start": 1000.0,
1023
+ "t_end": 2000.0,
1024
+ }
1025
+ if use_training_overlay
1026
+ else None
1027
+ ),
1028
+ }
1029
+ save_library(out_dir, accepted, config)
1030
+ logger.info("Wrote %d accepted scenarios to %s", len(accepted), out_dir)
1031
+
1032
+ # ── Optional: merge into an existing library ──
1033
+ if append_to is not None:
1034
+ if not append_to.exists() or not append_to.is_dir():
1035
+ raise FileNotFoundError(f"--append-to target not found (must be a library directory): {append_to}")
1036
+ existing_scenarios, existing_config = load_library_data(append_to)
1037
+ existing_seeds = {s.seed for s in existing_scenarios}
1038
+ new_scenarios = [s for s in accepted if s.seed not in existing_seeds]
1039
+ if new_scenarios:
1040
+ merged = existing_scenarios + new_scenarios
1041
+ save_library(append_to, merged, existing_config)
1042
+ logger.info(
1043
+ "Appended %d new scenario(s) to %s (total now: %d)",
1044
+ len(new_scenarios),
1045
+ append_to,
1046
+ len(merged),
1047
+ )
1048
+ else:
1049
+ logger.info("No new scenarios to append: all seeds already present in %s", append_to)
1050
+
1051
+ csv_path = out_dir / "candidates.csv"
1052
+ with open(csv_path, "w", newline="") as f:
1053
+ cols = [
1054
+ "seed",
1055
+ "pv_scale",
1056
+ "load_scale",
1057
+ "baseline_integral",
1058
+ "ofo_integral",
1059
+ "baseline_violation_time_s",
1060
+ "ofo_violation_time_s",
1061
+ "bl_undervoltage_time_s",
1062
+ "bl_overvoltage_time_s",
1063
+ "ofo_undervoltage_time_s",
1064
+ "ofo_overvoltage_time_s",
1065
+ "recovery_frac",
1066
+ "accepted",
1067
+ ]
1068
+ writer = csv.DictWriter(f, fieldnames=cols)
1069
+ writer.writeheader()
1070
+ for s in all_stats:
1071
+ writer.writerow({k: s[k] for k in cols})
1072
+ logger.info("Wrote candidate stats to %s", csv_path)
1073
+
1074
+ # Plots
1075
+ if accepted:
1076
+ _plot_envelopes(
1077
+ accepted,
1078
+ envelopes,
1079
+ out_dir / "scenario_envelopes.png",
1080
+ total_duration_s=len(accepted[0].ofo_voltage_pen_per_step),
1081
+ zones=zones,
1082
+ )
1083
+ logger.info("Wrote envelope plot to %s", out_dir / "scenario_envelopes.png")
1084
+ _plot_batch_sizes(accepted, batch_data, out_dir / "scenario_batch_sizes.png")
1085
+ logger.info("Wrote batch-size plot to %s", out_dir / "scenario_batch_sizes.png")
1086
+ _plot_summary(all_stats, out_dir / "scenario_summary.png")
1087
+ logger.info("Wrote summary plot to %s", out_dir / "scenario_summary.png")
1088
+
1089
+ # Headline
1090
+ n_acc = len(accepted)
1091
+ accept_rate = n_acc / max(1, n_total)
1092
+ logger.info("")
1093
+ logger.info("=" * 60)
1094
+ logger.info("Library build complete: %d/%d accepted (%.0f%%)", n_acc, n_total, 100 * accept_rate)
1095
+ if n_acc > 0:
1096
+ recs = np.array([r.recovery_frac for r in accepted])
1097
+ bls = np.array([r.baseline_integral for r in accepted])
1098
+ logger.info(" recovery (mean ± std): %.2f ± %.2f", recs.mean(), recs.std())
1099
+ logger.info(" baseline integral (mean ± std): %.2f ± %.2f", bls.mean(), bls.std())
1100
+
1101
+
1102
+ def _is_pure_overvoltage(rec: ScenarioRecord) -> bool:
1103
+ """Classify a ScenarioRecord as pure-overvoltage (over > 0, under == 0)."""
1104
+ return rec.bl_overvoltage_time_s > 0 and rec.bl_undervoltage_time_s == 0
1105
+
1106
+
1107
+ def _merge_supplement_into_base(
1108
+ base_dir: Path,
1109
+ supp_dir: Path,
1110
+ swap_undervoltage_for_overvoltage: int = 0,
1111
+ ) -> None:
1112
+ """Merge pure-overvoltage scenarios from a supplement library into a base library.
1113
+
1114
+ Train mode (swap_undervoltage_for_overvoltage = 0): append ALL pure-overvoltage
1115
+ scenarios from the supplement to the base. Library size grows.
1116
+
1117
+ Test mode (swap_undervoltage_for_overvoltage > 0): take up to N pure-overvoltage
1118
+ from the supplement, remove the same number of undervoltage-only scenarios
1119
+ (highest load_scale first) from the base. Library size unchanged.
1120
+ """
1121
+ if not base_dir.is_dir():
1122
+ raise FileNotFoundError(f"--n-supplement-candidates set but base library missing: {base_dir}")
1123
+ if not supp_dir.is_dir():
1124
+ raise FileNotFoundError(f"supplement library missing: {supp_dir}")
1125
+
1126
+ base_scenarios, base_config = load_library_data(base_dir)
1127
+ supp_scenarios, _ = load_library_data(supp_dir)
1128
+
1129
+ pure_over = [r for r in supp_scenarios if _is_pure_overvoltage(r)]
1130
+ base_seeds = {r.seed for r in base_scenarios}
1131
+ pure_over_new = [r for r in pure_over if r.seed not in base_seeds]
1132
+ logger.info(
1133
+ "Supplement merge: %d/%d supplement scenarios are pure-overvoltage (%d new vs base)",
1134
+ len(pure_over),
1135
+ len(supp_scenarios),
1136
+ len(pure_over_new),
1137
+ )
1138
+
1139
+ if swap_undervoltage_for_overvoltage <= 0:
1140
+ new_scenarios = list(base_scenarios) + pure_over_new
1141
+ else:
1142
+ n_take = min(swap_undervoltage_for_overvoltage, len(pure_over_new))
1143
+ under_only_idx = [
1144
+ i for i, r in enumerate(base_scenarios) if r.bl_undervoltage_time_s > 0 and r.bl_overvoltage_time_s == 0
1145
+ ]
1146
+ under_only_idx.sort(key=lambda i: base_scenarios[i].load_scale, reverse=True)
1147
+ remove_idx = set(under_only_idx[:n_take])
1148
+ if remove_idx:
1149
+ ls_min = min(base_scenarios[i].load_scale for i in remove_idx)
1150
+ ls_max = max(base_scenarios[i].load_scale for i in remove_idx)
1151
+ logger.info(
1152
+ " test-set swap: removing %d undervoltage-only scenarios (load_scale %.2f-%.2f)",
1153
+ len(remove_idx),
1154
+ ls_min,
1155
+ ls_max,
1156
+ )
1157
+ new_scenarios = [r for i, r in enumerate(base_scenarios) if i not in remove_idx] + pure_over_new[:n_take]
1158
+
1159
+ save_library(base_dir, new_scenarios, base_config)
1160
+ logger.info("Wrote merged library to %s (%d scenarios total)", base_dir, len(new_scenarios))
1161
+
1162
+
1163
+ if __name__ == "__main__":
1164
+ from dataclasses import dataclass
1165
+ from typing import Annotated
1166
+
1167
+ import tyro
1168
+
1169
+ @dataclass
1170
+ class Args:
1171
+ system: str = "ieee13"
1172
+ """Which feeder experiment to use. Valid: ieee13, ieee34, ieee123."""
1173
+ n_candidates: int = 20
1174
+ """Number of randomized candidate scenarios to evaluate."""
1175
+ min_recovery_frac: float = 0.7
1176
+ """Reject scenarios where OFO recovers less than this fraction of the baseline integral violation."""
1177
+ max_recovery_frac: float = 1.0
1178
+ """Reject scenarios where OFO recovers more than this fraction (use with min to select a recovery band, e.g. 0.4-0.6).""" # noqa: E501
1179
+ min_baseline_integral: float = 0.2
1180
+ """Reject scenarios where the baseline undervoltage integral is below this threshold (no learning signal)."""
1181
+ min_baseline_integral_over: float = 0.01
1182
+ """Reject pure-overvoltage scenarios where the baseline integral is below this threshold. Lower than min_baseline_integral since OFO recovers overvoltage less aggressively.""" # noqa: E501
1183
+ max_baseline_integral: float = 1e9
1184
+ """Reject scenarios where the baseline integral violation exceeds this threshold (saturated, dominate gradients). Default = no upper bound.""" # noqa: E501
1185
+ pv_base_kw: float | None = None
1186
+ """Base PV peak power per system (kW) before per-scenario scaling. If unset, use the per-bus defaults from the experiment definition (recommended for ieee34 where each PV has a distinct peak_kw).""" # noqa: E501
1187
+ tvl_base_kw: float | None = None
1188
+ """Base time-varying load peak power per load (kW) before per-scenario scaling. If unset, use the per-bus defaults from the experiment definition.""" # noqa: E501
1189
+ sensitivity_update_interval: int = 300
1190
+ """OFO H-matrix re-estimation interval in control steps. 300 = every 5 simulated minutes."""
1191
+ ofo_w_throughput: float = 0.0001
1192
+ """OFO throughput weight in the primal objective. 0 = pure voltage focus (the screening only cares how much violation OFO can recover).""" # noqa: E501
1193
+ tag: str = "v3"
1194
+ """Subdirectory under outputs/<system>/scenario_library/ to write artifacts to."""
1195
+ seed_start: int = 0
1196
+ """Starting candidate index. Effective seed = (seed_start + cand_idx) * 1000 + 7. Use to generate non-overlapping scenario sets (e.g., seed-start=400 for eval set when training used 0-349).""" # noqa: E501
1197
+ seeds: tuple[int, ...] = ()
1198
+ """Explicit list of effective seeds to run (overrides seed_start + n_candidates). Use to re-run only known-accepted seeds from a prior build log.""" # noqa: E501
1199
+ use_training_overlay: bool = True
1200
+ """Whether to add the training overlay (a few-hundred-kW training GPU spike) to in-distribution scenarios. Default True (ieee13 behavior). Set --no-use-training-overlay for ieee34 in-dist libraries; the overlay is reserved for OOD there.""" # noqa: E501
1201
+ randomize_ramps: bool = True
1202
+ """Whether randomize_scenario synthesizes per-episode inference ramps. Default True (ieee13 behavior). Set --no-randomize-ramps for ieee34 in-dist libraries; ramps are reserved for OOD there.""" # noqa: E501
1203
+ # ── Randomization profile ──
1204
+ randomization_profile: Annotated[bool, tyro.conf.FlagConversionOff] = True
1205
+ """True (broad, default) = bidirectional multi-ramps, wider PV/TVL scales, shape randomness, stochastic overlay. False (narrow) = single descending ramp + scalar PV/TVL only.""" # noqa: E501
1206
+ pv_scale_min: float = 0.5
1207
+ pv_scale_max: float = 2.0
1208
+ load_scale_min: float = 0.5
1209
+ load_scale_max: float = 2.0
1210
+ pv_t_shift_max_s: float = 0.0
1211
+ """Max abs PV profile peak-time shift in seconds (broad profile only). 0 disables shape randomness on PV."""
1212
+ tvl_t_shift_max_s: float = 0.0
1213
+ """Max abs TVL profile peak-time shift in seconds (broad profile only)."""
1214
+ pv_warp_min: float = 1.0
1215
+ pv_warp_max: float = 1.0
1216
+ tvl_warp_min: float = 1.0
1217
+ tvl_warp_max: float = 1.0
1218
+ overlay_prob: float = 1.0
1219
+ """Probability the training overlay is applied to a candidate (broad profile only)."""
1220
+ overlay_intensity_min: float = 1.0
1221
+ overlay_intensity_max: float = 1.0
1222
+ overlay_gpu_frac_min: float = 0.85
1223
+ overlay_gpu_frac_max: float = 1.0
1224
+ n_ramps_per_site_choices: tuple[int, ...] = (1,)
1225
+ """Possible numbers of ramps per DC site (broad profile). e.g. (1, 2) gives 50/50 single/double ramps."""
1226
+ ramp_up_prob: float = 0.0
1227
+ """Probability a sampled ramp goes UP (broad profile). GPU capacity is checked per-site so up-ramps are clamped automatically.""" # noqa: E501
1228
+ ramp_down_frac_min: float = 0.5
1229
+ ramp_down_frac_max: float = 0.85
1230
+ ramp_up_frac_min: float = 1.05
1231
+ ramp_up_frac_max: float = 1.5
1232
+ ramp_start_min: float = 500.0
1233
+ """Earliest time (s) a ramp can begin (broad mode). Lower this to allow ramps near episode start."""
1234
+ ramp_start_max: float = 3000.0
1235
+ """Latest time (s) a ramp can begin."""
1236
+ ramp_dur_min: float = 300.0
1237
+ """Minimum ramp duration (s). Lower for fast/sharp ramps."""
1238
+ ramp_dur_max: float = 800.0
1239
+ """Maximum ramp duration (s). Higher for slow/gentle ramps."""
1240
+ randomize_pv_profile: bool = False
1241
+ """Replace the analytical pv_profile_kw with a multi-shape random PV profile (broad mode only)."""
1242
+ pv_shape_choices: tuple[str, ...] = (
1243
+ "flat",
1244
+ "rising_falling",
1245
+ "morning_ramp",
1246
+ "afternoon_decline",
1247
+ "midday_dip",
1248
+ )
1249
+ """PV shapes to sample from (broad mode + --randomize-pv-profile). Default covers all 5 transient/flat envelopes.""" # noqa: E501
1250
+ pv_baseline_min: float = 0.75
1251
+ pv_baseline_max: float = 0.95
1252
+ pv_cloud_count_max: int = 3
1253
+ pv_cloud_depth_min: float = 0.30
1254
+ pv_cloud_depth_max: float = 0.70
1255
+ pv_cloud_width_min: float = 60.0
1256
+ pv_cloud_width_max: float = 300.0
1257
+ randomize_tvl_profile: bool = False
1258
+ """Replace the analytical load_profile_kw with one of {flat, increasing, decreasing, peaked, valley} per scenario (broad mode only).""" # noqa: E501
1259
+ tvl_shape_choices: tuple[str, ...] = ("flat", "increasing", "decreasing", "peaked", "valley")
1260
+ max_always_min: int = 5
1261
+ """Max number of 'OFO always at min batch' scenarios to accept. Increase to 10 for training libraries to avoid under-representing easy scenarios.""" # noqa: E501
1262
+ t_control_start_buffer: int = 200
1263
+ """Steps of buffer before the first baseline voltage violation (for episode truncation)."""
1264
+ t_control_end_buffer: int = 300
1265
+ """Steps of buffer after the last baseline voltage violation (for episode truncation)."""
1266
+ log_level: str = "INFO"
1267
+ append_to: Path | None = None
1268
+ """If set, merge newly accepted scenarios into this existing library directory (deduplicates by seed). The new run is still saved under --tag for diagnostic plots.""" # noqa: E501
1269
+ # ── Optional supplement phase (paper-balanced library workflow) ──
1270
+ n_supplement_candidates: int = 0
1271
+ """If > 0, after the main build run a second pass with overvoltage-favouring params (high PV, low load) and merge pure-overvoltage scenarios into the base library.""" # noqa: E501
1272
+ supplement_seed_start: int = -1
1273
+ """Starting candidate index for the supplement pass. Default -1 means seed_start + 4000 (prevents seed overlap).""" # noqa: E501
1274
+ supplement_pv_scale_min: float = 2.0
1275
+ supplement_pv_scale_max: float = 5.0
1276
+ supplement_load_scale_min: float = 0.5
1277
+ supplement_load_scale_max: float = 1.0
1278
+ supplement_min_baseline_integral_over: float = 1.0
1279
+ """Tighter overvoltage-integral floor for the supplement pass (we want real overvoltage scenarios, not borderline ones).""" # noqa: E501
1280
+ supplement_tag: str = ""
1281
+ """Subdirectory for the supplement build artifacts. Default empty means f'{tag}_over_supp'."""
1282
+ swap_undervoltage_for_overvoltage: int = 0
1283
+ """If > 0, after the supplement merge swap N undervoltage-only scenarios out of the base for N pure-overvoltage scenarios from the supplement (test-set mode that keeps library size constant). Default 0 = train mode (append all pure-over).""" # noqa: E501
1284
+
1285
+ args = tyro.cli(Args)
1286
+ main(
1287
+ system=args.system,
1288
+ n_candidates=args.n_candidates,
1289
+ min_recovery_frac=args.min_recovery_frac,
1290
+ max_recovery_frac=args.max_recovery_frac,
1291
+ min_baseline_integral=args.min_baseline_integral,
1292
+ min_baseline_integral_over=args.min_baseline_integral_over,
1293
+ max_baseline_integral=args.max_baseline_integral,
1294
+ pv_base_kw=args.pv_base_kw,
1295
+ tvl_base_kw=args.tvl_base_kw,
1296
+ sensitivity_update_interval=args.sensitivity_update_interval,
1297
+ ofo_w_throughput=args.ofo_w_throughput,
1298
+ tag=args.tag,
1299
+ seed_start=args.seed_start,
1300
+ seeds=args.seeds,
1301
+ use_training_overlay=args.use_training_overlay,
1302
+ randomize_ramps=args.randomize_ramps,
1303
+ randomization_profile=args.randomization_profile,
1304
+ pv_scale_min=args.pv_scale_min,
1305
+ pv_scale_max=args.pv_scale_max,
1306
+ load_scale_min=args.load_scale_min,
1307
+ load_scale_max=args.load_scale_max,
1308
+ pv_t_shift_max_s=args.pv_t_shift_max_s,
1309
+ tvl_t_shift_max_s=args.tvl_t_shift_max_s,
1310
+ pv_warp_min=args.pv_warp_min,
1311
+ pv_warp_max=args.pv_warp_max,
1312
+ tvl_warp_min=args.tvl_warp_min,
1313
+ tvl_warp_max=args.tvl_warp_max,
1314
+ overlay_prob=args.overlay_prob,
1315
+ overlay_intensity_min=args.overlay_intensity_min,
1316
+ overlay_intensity_max=args.overlay_intensity_max,
1317
+ overlay_gpu_frac_min=args.overlay_gpu_frac_min,
1318
+ overlay_gpu_frac_max=args.overlay_gpu_frac_max,
1319
+ n_ramps_per_site_choices=args.n_ramps_per_site_choices,
1320
+ ramp_up_prob=args.ramp_up_prob,
1321
+ ramp_down_frac_min=args.ramp_down_frac_min,
1322
+ ramp_down_frac_max=args.ramp_down_frac_max,
1323
+ ramp_up_frac_min=args.ramp_up_frac_min,
1324
+ ramp_up_frac_max=args.ramp_up_frac_max,
1325
+ ramp_start_min=args.ramp_start_min,
1326
+ ramp_start_max=args.ramp_start_max,
1327
+ ramp_dur_min=args.ramp_dur_min,
1328
+ ramp_dur_max=args.ramp_dur_max,
1329
+ randomize_pv_profile=args.randomize_pv_profile,
1330
+ pv_shape_choices=args.pv_shape_choices,
1331
+ pv_baseline_min=args.pv_baseline_min,
1332
+ pv_baseline_max=args.pv_baseline_max,
1333
+ pv_cloud_count_max=args.pv_cloud_count_max,
1334
+ pv_cloud_depth_min=args.pv_cloud_depth_min,
1335
+ pv_cloud_depth_max=args.pv_cloud_depth_max,
1336
+ pv_cloud_width_min=args.pv_cloud_width_min,
1337
+ pv_cloud_width_max=args.pv_cloud_width_max,
1338
+ randomize_tvl_profile=args.randomize_tvl_profile,
1339
+ tvl_shape_choices=args.tvl_shape_choices,
1340
+ max_always_min=args.max_always_min,
1341
+ t_control_start_buffer=args.t_control_start_buffer,
1342
+ t_control_end_buffer=args.t_control_end_buffer,
1343
+ log_level=args.log_level,
1344
+ append_to=args.append_to,
1345
+ )
1346
+
1347
+ # ── Optional supplement phase: second pass with overvoltage-favouring params + merge ──
1348
+ if args.n_supplement_candidates > 0:
1349
+ supp_tag = args.supplement_tag or f"{args.tag}_over_supp"
1350
+ supp_seed_start = args.supplement_seed_start if args.supplement_seed_start >= 0 else args.seed_start + 4000
1351
+
1352
+ logger.info("")
1353
+ logger.info("=" * 60)
1354
+ logger.info(
1355
+ "Supplement phase: %d candidates, seed_start=%d, tag=%s",
1356
+ args.n_supplement_candidates,
1357
+ supp_seed_start,
1358
+ supp_tag,
1359
+ )
1360
+ logger.info(
1361
+ " PV scale [%.2f-%.2f], load scale [%.2f-%.2f] (overvoltage-favouring)",
1362
+ args.supplement_pv_scale_min,
1363
+ args.supplement_pv_scale_max,
1364
+ args.supplement_load_scale_min,
1365
+ args.supplement_load_scale_max,
1366
+ )
1367
+ logger.info("=" * 60)
1368
+
1369
+ main(
1370
+ system=args.system,
1371
+ n_candidates=args.n_supplement_candidates,
1372
+ min_recovery_frac=args.min_recovery_frac,
1373
+ max_recovery_frac=args.max_recovery_frac,
1374
+ min_baseline_integral=args.min_baseline_integral,
1375
+ min_baseline_integral_over=args.supplement_min_baseline_integral_over,
1376
+ max_baseline_integral=args.max_baseline_integral,
1377
+ pv_base_kw=args.pv_base_kw,
1378
+ tvl_base_kw=args.tvl_base_kw,
1379
+ sensitivity_update_interval=args.sensitivity_update_interval,
1380
+ ofo_w_throughput=args.ofo_w_throughput,
1381
+ tag=supp_tag,
1382
+ seed_start=supp_seed_start,
1383
+ seeds=(),
1384
+ use_training_overlay=args.use_training_overlay,
1385
+ randomize_ramps=args.randomize_ramps,
1386
+ randomization_profile=args.randomization_profile,
1387
+ pv_scale_min=args.supplement_pv_scale_min,
1388
+ pv_scale_max=args.supplement_pv_scale_max,
1389
+ load_scale_min=args.supplement_load_scale_min,
1390
+ load_scale_max=args.supplement_load_scale_max,
1391
+ pv_t_shift_max_s=args.pv_t_shift_max_s,
1392
+ tvl_t_shift_max_s=args.tvl_t_shift_max_s,
1393
+ pv_warp_min=args.pv_warp_min,
1394
+ pv_warp_max=args.pv_warp_max,
1395
+ tvl_warp_min=args.tvl_warp_min,
1396
+ tvl_warp_max=args.tvl_warp_max,
1397
+ overlay_prob=args.overlay_prob,
1398
+ overlay_intensity_min=args.overlay_intensity_min,
1399
+ overlay_intensity_max=args.overlay_intensity_max,
1400
+ overlay_gpu_frac_min=args.overlay_gpu_frac_min,
1401
+ overlay_gpu_frac_max=args.overlay_gpu_frac_max,
1402
+ n_ramps_per_site_choices=args.n_ramps_per_site_choices,
1403
+ ramp_up_prob=args.ramp_up_prob,
1404
+ ramp_down_frac_min=args.ramp_down_frac_min,
1405
+ ramp_down_frac_max=args.ramp_down_frac_max,
1406
+ ramp_up_frac_min=args.ramp_up_frac_min,
1407
+ ramp_up_frac_max=args.ramp_up_frac_max,
1408
+ ramp_start_min=args.ramp_start_min,
1409
+ ramp_start_max=args.ramp_start_max,
1410
+ ramp_dur_min=args.ramp_dur_min,
1411
+ ramp_dur_max=args.ramp_dur_max,
1412
+ randomize_pv_profile=args.randomize_pv_profile,
1413
+ pv_shape_choices=args.pv_shape_choices,
1414
+ pv_baseline_min=args.pv_baseline_min,
1415
+ pv_baseline_max=args.pv_baseline_max,
1416
+ pv_cloud_count_max=args.pv_cloud_count_max,
1417
+ pv_cloud_depth_min=args.pv_cloud_depth_min,
1418
+ pv_cloud_depth_max=args.pv_cloud_depth_max,
1419
+ pv_cloud_width_min=args.pv_cloud_width_min,
1420
+ pv_cloud_width_max=args.pv_cloud_width_max,
1421
+ randomize_tvl_profile=args.randomize_tvl_profile,
1422
+ tvl_shape_choices=args.tvl_shape_choices,
1423
+ max_always_min=args.max_always_min,
1424
+ t_control_start_buffer=args.t_control_start_buffer,
1425
+ t_control_end_buffer=args.t_control_end_buffer,
1426
+ log_level=args.log_level,
1427
+ append_to=None, # we run a separate merge below (with the pure-over filter)
1428
+ )
1429
+
1430
+ scenario_root = Path(__file__).resolve().parent / "outputs" / args.system / "scenario_library"
1431
+ _merge_supplement_into_base(
1432
+ base_dir=scenario_root / args.tag,
1433
+ supp_dir=scenario_root / supp_tag,
1434
+ swap_undervoltage_for_overvoltage=args.swap_undervoltage_for_overvoltage,
1435
+ )
examples/rl_controller/controller.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PPO-trained batch-size controller for voltage regulation.
2
+
3
+ Loads a trained stable-baselines3 PPO model and uses it for deterministic
4
+ inference within the openg2g Controller interface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from fractions import Fraction
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ from env import ObservationConfig, build_observation, compute_zone_mask, decode_action
14
+
15
+ from openg2g.clock import SimulationClock
16
+ from openg2g.controller.base import Controller
17
+ from openg2g.datacenter.base import LLMBatchSizeControlledDatacenter, LLMDatacenterState
18
+ from openg2g.datacenter.command import DatacenterCommand, SetBatchSize
19
+ from openg2g.datacenter.config import InferenceModelSpec
20
+ from openg2g.events import EventEmitter
21
+ from openg2g.grid.command import GridCommand
22
+ from openg2g.grid.opendss import OpenDSSGrid
23
+
24
+
25
+ def load_sb3_policy(model_path: str | Path, *, algorithm: str = "PPO"):
26
+ """Load a stable-baselines3 policy by algorithm name.
27
+
28
+ `algorithm` is the SB3 class name (`"PPO"`, `"SAC"`, `"DQN"`,
29
+ `"A2C"`, `"TD3"`, `"DDPG"`). Resolved by attribute lookup on the
30
+ `stable_baselines3` package; mismatches raise an explicit error.
31
+
32
+ Trailing `.zip` on `model_path` is stripped because SB3's `load`
33
+ appends the extension itself.
34
+ """
35
+ import stable_baselines3 as sb3
36
+
37
+ try:
38
+ algo_cls = getattr(sb3, algorithm)
39
+ except AttributeError as exc:
40
+ raise ValueError(
41
+ f"Unknown SB3 algorithm {algorithm!r}; expected one of PPO, SAC, DQN, A2C, TD3, DDPG."
42
+ ) from exc
43
+
44
+ resolved = Path(model_path).resolve()
45
+ load_path = str(resolved.with_suffix("")) if resolved.suffix == ".zip" else str(resolved)
46
+ return algo_cls.load(load_path)
47
+
48
+
49
+ def _load_vecnormalize(vecnormalize_path: str | Path, observation_space, action_space):
50
+ """Load a saved VecNormalize wrapper, restoring its running obs/reward statistics.
51
+
52
+ Returns a VecNormalize whose `normalize_obs` reproduces what the policy
53
+ saw during training. We attach a 1-env DummyVecEnv whose obs/action spaces
54
+ match the trained model so SB3's loader is happy.
55
+ """
56
+ import gymnasium as gym
57
+ from stable_baselines3.common.vec_env import DummyVecEnv, VecNormalize
58
+
59
+ class _DummyEnv(gym.Env):
60
+ metadata = {"render_modes": []} # noqa: RUF012
61
+
62
+ def __init__(self, obs_space, act_space):
63
+ super().__init__()
64
+ self.observation_space = obs_space
65
+ self.action_space = act_space
66
+ self.render_mode = None
67
+
68
+ def reset(self, *, seed=None, options=None):
69
+ return self.observation_space.sample(), {}
70
+
71
+ def step(self, action):
72
+ return self.observation_space.sample(), 0.0, False, False, {}
73
+
74
+ def close(self):
75
+ pass
76
+
77
+ venv = DummyVecEnv([lambda: _DummyEnv(observation_space, action_space)])
78
+ vn = VecNormalize.load(str(vecnormalize_path), venv)
79
+ vn.training = False
80
+ vn.norm_reward = False
81
+ return vn
82
+
83
+
84
+ def _detect_action_mode(action_space) -> str:
85
+ """Infer the action mode from a saved model's action space shape."""
86
+ from gymnasium import spaces as gspaces
87
+
88
+ if isinstance(action_space, gspaces.Discrete):
89
+ return "coupled"
90
+ if isinstance(action_space, gspaces.MultiDiscrete):
91
+ nvec = tuple(int(d) for d in action_space.nvec)
92
+ if all(d == 3 for d in nvec):
93
+ return "delta"
94
+ raise ValueError(
95
+ f"Unrecognised action space {action_space!r}: expected Discrete (coupled) or MultiDiscrete([3]*N) (delta)"
96
+ )
97
+
98
+
99
+ class PPOBatchSizeController(
100
+ Controller[LLMBatchSizeControlledDatacenter[LLMDatacenterState], OpenDSSGrid],
101
+ ):
102
+ """Batch-size controller using a trained PPO policy (single site).
103
+
104
+ Args:
105
+ inference_models: Model specifications served in the datacenter.
106
+ model_path: Path to saved SB3 PPO model (.zip).
107
+ obs_config: Observation space configuration.
108
+ dt_s: Control interval (seconds).
109
+ site_id: Site identifier for multi-datacenter setups.
110
+ vecnormalize_path: Optional path to a saved VecNormalize stats pickle
111
+ (`*_vecnormalize.pkl`). If provided, observations are normalized
112
+ with the saved running mean/var before being passed to the policy;
113
+ this MUST match the wrapper used during training, otherwise the
114
+ policy sees out-of-distribution input.
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ inference_models: tuple[InferenceModelSpec, ...],
120
+ *,
121
+ datacenter: LLMBatchSizeControlledDatacenter[LLMDatacenterState],
122
+ grid: OpenDSSGrid,
123
+ model_path: str | Path,
124
+ obs_config: ObservationConfig,
125
+ dt_s: Fraction = Fraction(1),
126
+ algorithm: str = "PPO",
127
+ vecnormalize_path: str | Path | None = None,
128
+ ) -> None:
129
+ self._models = inference_models
130
+ self._datacenter = datacenter
131
+ self._grid = grid
132
+ self._sb3_model = load_sb3_policy(model_path, algorithm=algorithm)
133
+ self._vecnormalize = (
134
+ _load_vecnormalize(vecnormalize_path, self._sb3_model.observation_space, self._sb3_model.action_space)
135
+ if vecnormalize_path is not None
136
+ else None
137
+ )
138
+ self._obs_config = obs_config
139
+ self._dt_s = dt_s
140
+ self._feasible = {s.model_label: tuple(s.feasible_batch_sizes) for s in inference_models}
141
+ self._prev_batch: dict[str, int] = {}
142
+ self._zone_mask: np.ndarray | None = None
143
+ self._zone_masks: dict[str, np.ndarray] | None = None
144
+ self._zone_masks_computed = False
145
+ # Detect action mode from model's action space
146
+ self._action_mode = _detect_action_mode(self._sb3_model.action_space)
147
+ n_feasible = min(len(f) for f in self._feasible.values())
148
+ self._coupled_max_shift = n_feasible - 1
149
+ self._init_prev_batch()
150
+
151
+ def _init_prev_batch(self) -> None:
152
+ self._prev_batch = {s.model_label: self._obs_config.get_initial_batch(s.model_label) for s in self._models}
153
+
154
+ @property
155
+ def dt_s(self) -> Fraction:
156
+ return self._dt_s
157
+
158
+ def reset(self) -> None:
159
+ self._init_prev_batch()
160
+ self._zone_masks_computed = False
161
+
162
+ def step(
163
+ self,
164
+ clock: SimulationClock,
165
+ events: EventEmitter,
166
+ ) -> list[DatacenterCommand | GridCommand]:
167
+ datacenter = self._datacenter
168
+ grid = self._grid
169
+ # Grid must be started before v_index is valid, so zone masks are
170
+ # computed lazily on the first step.
171
+ if not self._zone_masks_computed:
172
+ if self._obs_config.zone_buses is not None:
173
+ self._zone_mask = compute_zone_mask(grid.v_index, self._obs_config.zone_buses)
174
+ if self._obs_config.zone_summary:
175
+ self._zone_masks = {
176
+ zname: compute_zone_mask(grid.v_index, tuple(zbuses))
177
+ for zname, zbuses in self._obs_config.zone_summary.items()
178
+ }
179
+ self._zone_masks_computed = True
180
+
181
+ obs = build_observation(grid, datacenter, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks)
182
+ if self._vecnormalize is not None:
183
+ obs = self._vecnormalize.normalize_obs(obs)
184
+ action, _ = self._sb3_model.predict(obs, deterministic=True)
185
+
186
+ batch_sizes = decode_action(
187
+ action,
188
+ self._action_mode,
189
+ self._obs_config.model_labels,
190
+ self._feasible,
191
+ self._prev_batch,
192
+ self._coupled_max_shift,
193
+ )
194
+
195
+ self._prev_batch = batch_sizes
196
+ events.emit("controller.ppo.step", {"batch_size_by_model": batch_sizes})
197
+ return [SetBatchSize(batch_size_by_model=batch_sizes, target=datacenter)]
198
+
199
+
200
+ class SharedPPOBatchSizeController(
201
+ Controller[LLMBatchSizeControlledDatacenter[LLMDatacenterState], OpenDSSGrid],
202
+ ):
203
+ """Shared PPO controller that outputs batch sizes for ALL sites jointly.
204
+
205
+ Requires the coordinator to have all datacenter sites registered.
206
+ Outputs one `SetBatchSize` command per site.
207
+
208
+ Args:
209
+ model_path: Path to saved SB3 PPO model (.zip).
210
+ obs_config: Combined observation config (all models from all sites).
211
+ site_model_mapping: Maps site_id → list of model labels at that site.
212
+ dt_s: Control interval (seconds).
213
+ """
214
+
215
+ def __init__(
216
+ self,
217
+ *,
218
+ datacenter: LLMBatchSizeControlledDatacenter[LLMDatacenterState],
219
+ grid: OpenDSSGrid,
220
+ model_path: str | Path,
221
+ obs_config: ObservationConfig,
222
+ site_model_mapping: dict[str, list[str]],
223
+ dt_s: Fraction = Fraction(1),
224
+ algorithm: str = "PPO",
225
+ vecnormalize_path: str | Path | None = None,
226
+ ) -> None:
227
+ self._datacenter = datacenter
228
+ self._grid = grid
229
+ self._sb3_model = load_sb3_policy(model_path, algorithm=algorithm)
230
+ self._vecnormalize = (
231
+ _load_vecnormalize(vecnormalize_path, self._sb3_model.observation_space, self._sb3_model.action_space)
232
+ if vecnormalize_path is not None
233
+ else None
234
+ )
235
+ self._obs_config = obs_config
236
+ self._site_model_mapping = site_model_mapping
237
+ self._dt_s = dt_s
238
+ self._feasible = dict(obs_config.feasible_batch_sizes)
239
+ self._prev_batch: dict[str, int] = {}
240
+ self._zone_mask: np.ndarray | None = None
241
+ self._zone_masks: dict[str, np.ndarray] | None = None
242
+ self._zone_masks_computed = False
243
+ self._action_mode = _detect_action_mode(self._sb3_model.action_space)
244
+ n_feasible = min(len(f) for f in self._feasible.values())
245
+ self._coupled_max_shift = n_feasible - 1
246
+ # Per-site datacenter routing (set by attach_datacenters() before
247
+ # coord.run()). The shared policy needs every DC's
248
+ # batch/itl/replicas/power state to build the joint observation; the
249
+ # site-id → DC mapping additionally lets step() route each per-site
250
+ # SetBatchSize command back to its specific DC.
251
+ self._dcs_by_sid: dict[str, LLMBatchSizeControlledDatacenter[LLMDatacenterState]] = {}
252
+ self._all_datacenters: list = []
253
+ self._init_prev_batch()
254
+
255
+ def attach_datacenters(
256
+ self,
257
+ datacenters: dict[str, LLMBatchSizeControlledDatacenter[LLMDatacenterState]],
258
+ ) -> None:
259
+ """Register the site-id → DC mapping so the shared policy can both
260
+ observe the joint multi-site state and route per-site
261
+ `SetBatchSize` commands to the correct DC. Call once after the
262
+ Coordinator is constructed and before `coord.run()`.
263
+ """
264
+ self._dcs_by_sid = dict(datacenters)
265
+ self._all_datacenters = list(datacenters.values())
266
+
267
+ def _init_prev_batch(self) -> None:
268
+ self._prev_batch = {label: self._obs_config.get_initial_batch(label) for label in self._obs_config.model_labels}
269
+
270
+ @property
271
+ def dt_s(self) -> Fraction:
272
+ return self._dt_s
273
+
274
+ def reset(self) -> None:
275
+ self._init_prev_batch()
276
+ self._zone_masks_computed = False
277
+
278
+ def step(
279
+ self,
280
+ clock: SimulationClock,
281
+ events: EventEmitter,
282
+ ) -> list[DatacenterCommand | GridCommand]:
283
+ grid = self._grid
284
+ if not self._zone_masks_computed:
285
+ if self._obs_config.zone_buses is not None:
286
+ self._zone_mask = compute_zone_mask(grid.v_index, self._obs_config.zone_buses)
287
+ if self._obs_config.zone_summary:
288
+ self._zone_masks = {
289
+ zname: compute_zone_mask(grid.v_index, tuple(zbuses))
290
+ for zname, zbuses in self._obs_config.zone_summary.items()
291
+ }
292
+ self._zone_masks_computed = True
293
+
294
+ # Shared policies need joint state from every site, but the Coordinator
295
+ # hands each controller only its own DC. `attach_datacenters` must be
296
+ # called before run().
297
+ if not self._all_datacenters:
298
+ raise RuntimeError(
299
+ "SharedPPOBatchSizeController.step() called before attach_datacenters(); "
300
+ "a shared policy requires the full per-site DC mapping to build its joint observation."
301
+ )
302
+ obs = build_observation(
303
+ grid, self._all_datacenters, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks
304
+ )
305
+ if self._vecnormalize is not None:
306
+ obs = self._vecnormalize.normalize_obs(obs)
307
+ action, _ = self._sb3_model.predict(obs, deterministic=True)
308
+
309
+ all_batch = decode_action(
310
+ action,
311
+ self._action_mode,
312
+ self._obs_config.model_labels,
313
+ self._feasible,
314
+ self._prev_batch,
315
+ self._coupled_max_shift,
316
+ )
317
+
318
+ self._prev_batch = all_batch
319
+ events.emit("controller.ppo.step", {"batch_size_by_model": all_batch})
320
+
321
+ commands: list[DatacenterCommand | GridCommand] = []
322
+ for sid, labels in self._site_model_mapping.items():
323
+ site_batch = {label: all_batch[label] for label in labels if label in all_batch}
324
+ if not site_batch:
325
+ continue
326
+ commands.append(SetBatchSize(batch_size_by_model=site_batch, target=self._dcs_by_sid[sid]))
327
+ return commands
examples/rl_controller/env.py ADDED
@@ -0,0 +1,819 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gymnasium environment wrapping the openg2g simulation for RL training."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from fractions import Fraction
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import gymnasium
13
+ import numpy as np
14
+ from gymnasium import spaces
15
+ from scenarios import (
16
+ EXPERIMENTS,
17
+ PVSystemSpec,
18
+ ScenarioRecord,
19
+ TimeVaryingLoadSpec,
20
+ load_library_data,
21
+ materialize_scenario,
22
+ )
23
+
24
+ from openg2g.controller.ofo import LogisticModelStore
25
+ from openg2g.controller.tap_schedule import TapScheduleController
26
+ from openg2g.coordinator import Coordinator
27
+ from openg2g.datacenter.base import DatacenterBackend, LLMDatacenterState
28
+ from openg2g.datacenter.command import DatacenterCommand, SetBatchSize
29
+ from openg2g.datacenter.config import InferenceModelSpec
30
+ from openg2g.grid.base import GridBackend
31
+ from openg2g.grid.command import GridCommand
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ObservationConfig:
36
+ """Fixed observation space configuration.
37
+
38
+ The observation vector layout is:
39
+
40
+ ```
41
+ [0 .. M-1] voltage vector (M bus-phase magnitudes, pu)
42
+ ```
43
+
44
+ Then, if `zone_summary` is set (per-zone voltage summary mode):
45
+
46
+ ```
47
+ [M + 3*z + 0] zone z: worst undervoltage magnitude
48
+ [M + 3*z + 1] zone z: worst overvoltage magnitude
49
+ [M + 3*z + 2] zone z: fraction of bus-phases in violation
50
+ [M + 3*n_zones + 5*i] model i features (see below)
51
+ ```
52
+
53
+ Otherwise (global summary mode):
54
+
55
+ ```
56
+ [M] worst undervoltage magnitude
57
+ [M+1] worst overvoltage magnitude
58
+ [M+2] fraction of bus-phases in violation
59
+ [M+3 + 5*i + 0] model i features (see below)
60
+ ```
61
+
62
+ Per-model features (5 values each):
63
+
64
+ ```
65
+ + 0 normalized batch size (log2 scale) [0,1]
66
+ + 1 ITL / deadline [0,3]
67
+ + 2 active_replicas / max_replicas [0,1]
68
+ + 3 total 3-phase power (MW)
69
+ + 4 delta batch from prev step (log2 norm) [-1,1]
70
+ ```
71
+
72
+ Where M = `n_bus_phases`. Set M = 0 for summary-only observations. When
73
+ `zone_buses` is set, M is the number of bus-phases within those buses
74
+ (subset of the full grid), and violation summaries are computed over
75
+ that zone only. When `zone_summary` is set, the 3 global violation
76
+ scalars are replaced by 3 scalars per zone: use with M = 0 for a
77
+ compact multi-zone observation. When `bus_phase_groups` is set, M must
78
+ equal `2 * n_buses` and the first M slots contain [min_phase_voltage,
79
+ max_phase_voltage] per bus instead of raw per-phase voltages; each
80
+ entry is a tuple of indices into the full `v_vec` for that bus's phases.
81
+ """
82
+
83
+ model_labels: tuple[str, ...]
84
+ feasible_batch_sizes: dict[str, tuple[int, ...]]
85
+ itl_deadlines: dict[str, float]
86
+ max_replicas: dict[str, int]
87
+ n_bus_phases: int
88
+ initial_batch_sizes: dict[str, int] | None = None
89
+ zone_buses: tuple[str, ...] | None = None
90
+ zone_summary: dict[str, tuple[str, ...]] | None = None
91
+ bus_phase_groups: tuple[tuple[int, ...], ...] | None = None
92
+ v_min: float = 0.95
93
+ v_max: float = 1.05
94
+
95
+ def get_initial_batch(self, label: str) -> int:
96
+ """Return the initial batch size for *label*, falling back to midpoint of feasible sizes."""
97
+ if self.initial_batch_sizes is not None and label in self.initial_batch_sizes:
98
+ return self.initial_batch_sizes[label]
99
+ fbs = self.feasible_batch_sizes[label]
100
+ return fbs[len(fbs) // 2]
101
+
102
+ @property
103
+ def n_models(self) -> int:
104
+ return len(self.model_labels)
105
+
106
+ @property
107
+ def n_zone_summary_slots(self) -> int:
108
+ return len(self.zone_summary) * 3 if self.zone_summary else 0
109
+
110
+ @property
111
+ def obs_dim(self) -> int:
112
+ if self.zone_summary:
113
+ return self.n_bus_phases + self.n_zone_summary_slots + 5 * self.n_models
114
+ return self.n_bus_phases + 3 + 5 * self.n_models
115
+
116
+ @classmethod
117
+ def from_model_specs(
118
+ cls,
119
+ specs: tuple[InferenceModelSpec, ...],
120
+ replica_counts: dict[str, int],
121
+ n_bus_phases: int,
122
+ initial_batch_sizes: dict[str, int] | None = None,
123
+ zone_buses: tuple[str, ...] | None = None,
124
+ v_min: float = 0.95,
125
+ v_max: float = 1.05,
126
+ ) -> ObservationConfig:
127
+ return cls(
128
+ model_labels=tuple(s.model_label for s in specs),
129
+ feasible_batch_sizes={s.model_label: tuple(s.feasible_batch_sizes) for s in specs},
130
+ itl_deadlines={s.model_label: s.itl_deadline_s for s in specs},
131
+ max_replicas={s.model_label: replica_counts.get(s.model_label, 1) for s in specs},
132
+ n_bus_phases=n_bus_phases,
133
+ initial_batch_sizes=initial_batch_sizes,
134
+ zone_buses=zone_buses,
135
+ v_min=v_min,
136
+ v_max=v_max,
137
+ )
138
+
139
+ @classmethod
140
+ def from_multi_site(
141
+ cls,
142
+ site_specs: dict[str, tuple[InferenceModelSpec, ...]],
143
+ site_replica_counts: dict[str, dict[str, int]],
144
+ n_bus_phases: int,
145
+ initial_batch_sizes: dict[str, int] | None = None,
146
+ zone_buses: tuple[str, ...] | None = None,
147
+ zone_summary: dict[str, tuple[str, ...]] | None = None,
148
+ bus_phase_groups: tuple[tuple[int, ...], ...] | None = None,
149
+ v_min: float = 0.95,
150
+ v_max: float = 1.05,
151
+ ) -> ObservationConfig:
152
+ """Build config combining models from ALL sites."""
153
+ all_labels: list[str] = []
154
+ all_feasible: dict[str, tuple[int, ...]] = {}
155
+ all_deadlines: dict[str, float] = {}
156
+ all_max_rep: dict[str, int] = {}
157
+ for sid in site_specs:
158
+ for spec in site_specs[sid]:
159
+ label = spec.model_label
160
+ all_labels.append(label)
161
+ all_feasible[label] = tuple(spec.feasible_batch_sizes)
162
+ all_deadlines[label] = spec.itl_deadline_s
163
+ all_max_rep[label] = site_replica_counts.get(sid, {}).get(label, 1)
164
+ return cls(
165
+ model_labels=tuple(all_labels),
166
+ feasible_batch_sizes=all_feasible,
167
+ itl_deadlines=all_deadlines,
168
+ max_replicas=all_max_rep,
169
+ n_bus_phases=n_bus_phases,
170
+ initial_batch_sizes=initial_batch_sizes,
171
+ zone_buses=zone_buses,
172
+ zone_summary=zone_summary,
173
+ bus_phase_groups=bus_phase_groups,
174
+ v_min=v_min,
175
+ v_max=v_max,
176
+ )
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class RewardConfig:
181
+ """Reward function weights."""
182
+
183
+ w_voltage: float = 1000.0
184
+ w_throughput: float = 0.01
185
+ w_latency: float = 10.0
186
+ w_switch: float = 0.1
187
+ v_min: float = 0.95
188
+ v_max: float = 1.05
189
+ reward_clip: float = 0.0
190
+ """If > 0, clip per-step reward to [-reward_clip, +inf). Prevents
191
+ catastrophic scenarios from dominating PPO updates. Recommended: 1.0."""
192
+ switch_mode: str = "magnitude"
193
+ """Switch penalty mode:
194
+ - `"magnitude"`: `-w_switch * |log2(b_t) - log2(b_{t-1})|` (original).
195
+ - `"binary"`: `-w_switch` per model whenever batch size changes.
196
+ - `"cooldown"`: `-w_switch * exp(-steps_since_last_change / switch_cooldown_tau)`
197
+ per model whenever batch size changes (recent changes are expensive).
198
+ """
199
+ w_safe: float = 0.0
200
+ """Small positive reward for keeping voltages in range. Each step the agent
201
+ receives `+w_safe * (fraction of bus-phases within [v_min, v_max])`.
202
+ Default 0 (disabled). Recommended starting value: 0.01."""
203
+ switch_cooldown_tau: float = 30.0
204
+ """Time constant (in steps) for the cooldown switch penalty.
205
+ Only used when `switch_mode="cooldown"`."""
206
+
207
+
208
+ def compute_zone_mask(v_index: list[tuple[str, int]], zone_buses: tuple[str, ...]) -> np.ndarray:
209
+ """Boolean mask selecting bus-phases belonging to *zone_buses*."""
210
+ bus_set = {b.lower() for b in zone_buses}
211
+ return np.array([bus.lower() in bus_set for bus, _ph in v_index], dtype=bool)
212
+
213
+
214
+ def compute_bus_phase_groups(v_index: list[tuple[str, int]]) -> tuple[tuple[int, ...], ...]:
215
+ """Group v_index positions by bus name.
216
+
217
+ Returns one tuple of indices per unique bus, in the order buses first
218
+ appear in *v_index*. Used to compute per-bus min/max voltages.
219
+ """
220
+ groups: dict[str, list[int]] = {}
221
+ for i, (bus, _ph) in enumerate(v_index):
222
+ groups.setdefault(bus, []).append(i)
223
+ return tuple(tuple(idx) for idx in groups.values())
224
+
225
+
226
+ def decode_action(
227
+ action: np.ndarray,
228
+ action_mode: str,
229
+ model_labels: tuple[str, ...],
230
+ feasible_batch_sizes: dict[str, tuple[int, ...]],
231
+ prev_batch: dict[str, int],
232
+ coupled_max_shift: int = 6,
233
+ ) -> dict[str, int]:
234
+ """Decode a raw action array into batch-size assignments.
235
+
236
+ This is the single source of truth for action → batch-size mapping,
237
+ shared between the training env and the inference controller.
238
+ """
239
+
240
+ def _apply_delta(label: str, delta: int) -> int:
241
+ feasible = feasible_batch_sizes[label]
242
+ prev_b = prev_batch.get(label, feasible[len(feasible) // 2])
243
+ try:
244
+ cur_idx = feasible.index(prev_b)
245
+ except ValueError:
246
+ cur_idx = len(feasible) // 2
247
+ new_idx = max(0, min(len(feasible) - 1, cur_idx + delta))
248
+ return feasible[new_idx]
249
+
250
+ if action_mode == "coupled":
251
+ delta = int(action) - coupled_max_shift
252
+ return {label: _apply_delta(label, delta) for label in model_labels}
253
+
254
+ if action_mode == "delta":
255
+ return {label: _apply_delta(label, int(action[i]) - 1) for i, label in enumerate(model_labels)}
256
+
257
+ raise ValueError(f"Unknown action_mode: {action_mode!r}")
258
+
259
+
260
+ def build_observation(
261
+ grid: GridBackend,
262
+ datacenter: DatacenterBackend[LLMDatacenterState] | list[DatacenterBackend[LLMDatacenterState]],
263
+ obs_config: ObservationConfig,
264
+ prev_batch: dict[str, int],
265
+ zone_mask: np.ndarray | None = None,
266
+ zone_masks: dict[str, np.ndarray] | None = None,
267
+ ) -> np.ndarray:
268
+ """Build a fixed-size observation vector from grid and datacenter state.
269
+
270
+ *datacenter* may be a single backend or a list (for shared multi-site).
271
+ When a list, per-model features are gathered from all DCs in order.
272
+ *zone_mask* filters the voltage vector to the agent's zone (single-zone mode).
273
+ *zone_masks* provides per-zone boolean masks for per-zone summary obs
274
+ (used when obs_config.zone_summary is set).
275
+ """
276
+ obs = np.zeros(obs_config.obs_dim, dtype=np.float32)
277
+ M = obs_config.n_bus_phases
278
+
279
+ # Voltage vector (optionally filtered by zone)
280
+ v_vec_full = grid.voltages_vector()
281
+ v_vec = v_vec_full[zone_mask] if zone_mask is not None else v_vec_full
282
+
283
+ if obs_config.bus_phase_groups is not None:
284
+ # Per-bus min/max: [min_phase, max_phase] for each bus
285
+ for k, indices in enumerate(obs_config.bus_phase_groups):
286
+ ph_v = v_vec_full[list(indices)]
287
+ obs[2 * k] = float(np.min(ph_v))
288
+ obs[2 * k + 1] = float(np.max(ph_v))
289
+ elif M > 0:
290
+ n = min(len(v_vec), M)
291
+ obs[:n] = v_vec[:n].astype(np.float32)
292
+
293
+ v_min_cfg, v_max_cfg = obs_config.v_min, obs_config.v_max
294
+
295
+ if obs_config.zone_summary:
296
+ # Per-zone violation summary: replaces the 3 global scalars
297
+ base = M
298
+ for zone_name, _zone_bus_list in obs_config.zone_summary.items():
299
+ mask_z = (zone_masks or {}).get(zone_name)
300
+ v_z = v_vec_full[mask_z] if mask_z is not None else v_vec_full
301
+ under_z = np.maximum(v_min_cfg - v_z, 0.0)
302
+ over_z = np.maximum(v_z - v_max_cfg, 0.0)
303
+ n_z = len(v_z)
304
+ obs[base + 0] = float(np.max(under_z)) if n_z > 0 else 0.0
305
+ obs[base + 1] = float(np.max(over_z)) if n_z > 0 else 0.0
306
+ obs[base + 2] = float(np.count_nonzero(under_z > 0) + np.count_nonzero(over_z > 0)) / max(n_z, 1)
307
+ base += 3
308
+ else:
309
+ # Global violation summary
310
+ under = np.maximum(v_min_cfg - v_vec, 0.0)
311
+ over = np.maximum(v_vec - v_max_cfg, 0.0)
312
+ n_total = len(v_vec)
313
+ obs[M] = float(np.max(under)) if n_total > 0 else 0.0
314
+ obs[M + 1] = float(np.max(over)) if n_total > 0 else 0.0
315
+ obs[M + 2] = float(np.count_nonzero(under > 0) + np.count_nonzero(over > 0)) / max(n_total, 1)
316
+
317
+ # Per-model features: gather DC states
318
+ dcs = datacenter if isinstance(datacenter, list) else [datacenter]
319
+ # Build a merged state dict from all DCs
320
+ batch_by_model: dict[str, int] = {}
321
+ itl_by_model: dict[str, float] = {}
322
+ replicas_by_model: dict[str, int] = {}
323
+ total_power_w = 0.0
324
+ for dc in dcs:
325
+ st = dc.state
326
+ batch_by_model.update(st.batch_size_by_model)
327
+ itl_by_model.update(st.observed_itl_s_by_model)
328
+ replicas_by_model.update(st.active_replicas_by_model)
329
+ total_power_w += st.power_w.a + st.power_w.b + st.power_w.c
330
+
331
+ base_offset = M + (obs_config.n_zone_summary_slots if obs_config.zone_summary else 3)
332
+ for i, label in enumerate(obs_config.model_labels):
333
+ base = base_offset + 5 * i
334
+ feasible = obs_config.feasible_batch_sizes[label]
335
+ log2_min = math.log2(feasible[0])
336
+ log2_max = math.log2(feasible[-1])
337
+ log2_range = log2_max - log2_min if log2_max > log2_min else 1.0
338
+
339
+ batch = batch_by_model.get(label, feasible[len(feasible) // 2])
340
+ obs[base + 0] = (math.log2(max(batch, 1)) - log2_min) / log2_range
341
+
342
+ itl = itl_by_model.get(label, float("nan"))
343
+ deadline = obs_config.itl_deadlines[label]
344
+ obs[base + 1] = float(np.clip(itl / deadline, 0.0, 3.0)) if not math.isnan(itl) else 0.0
345
+
346
+ replicas = replicas_by_model.get(label, 0)
347
+ max_rep = obs_config.max_replicas[label]
348
+ obs[base + 2] = replicas / max(max_rep, 1)
349
+
350
+ obs[base + 3] = total_power_w / 1e6 # MW
351
+
352
+ prev_b = prev_batch.get(label, batch)
353
+ if prev_b > 0 and batch > 0:
354
+ delta = (math.log2(batch) - math.log2(prev_b)) / log2_range
355
+ obs[base + 4] = float(np.clip(delta, -1.0, 1.0))
356
+
357
+ return obs
358
+
359
+
360
+ def compute_reward(
361
+ grid: GridBackend,
362
+ datacenter: DatacenterBackend[LLMDatacenterState] | list[DatacenterBackend[LLMDatacenterState]],
363
+ obs_config: ObservationConfig,
364
+ reward_config: RewardConfig,
365
+ prev_batch: dict[str, int],
366
+ curr_batch: dict[str, int],
367
+ logistic_models: LogisticModelStore | None = None,
368
+ steps_since_change: dict[str, int] | None = None,
369
+ ) -> tuple[float, dict[str, float], dict[str, float]]:
370
+ """Compute per-step scalar reward.
371
+
372
+ Returns:
373
+ `(total_reward, reward_components, voltage_stats)`.
374
+
375
+ `reward_components` is a signed breakdown by source: keys
376
+ `"voltage"`, `"throughput"`, `"latency"`, `"switch"`,
377
+ `"safe"`. Penalties are negative, bonuses positive; the sum
378
+ equals `total_reward`.
379
+
380
+ `voltage_stats` reports per-step grid health: `"max_under"`
381
+ (worst undervoltage magnitude in pu), `"max_over"` (worst
382
+ overvoltage), and `"violation_frac"` (fraction of bus-phases
383
+ currently in violation). All computed over the full grid.
384
+ """
385
+ # Voltage violation penalty (over ALL buses, not just zone)
386
+ v_vec = grid.voltages_vector()
387
+ v_min_cfg, v_max_cfg = reward_config.v_min, reward_config.v_max
388
+ under = np.maximum(v_min_cfg - v_vec, 0.0)
389
+ over = np.maximum(v_vec - v_max_cfg, 0.0)
390
+ voltage_penalty = float(np.sum(under**2) + np.sum(over**2))
391
+ voltage_term = -reward_config.w_voltage * voltage_penalty
392
+
393
+ n_total = len(v_vec)
394
+ n_violating = int(np.count_nonzero(under > 0) + np.count_nonzero(over > 0))
395
+ safe_frac = (n_total - n_violating) / max(n_total, 1)
396
+ safe_term = reward_config.w_safe * safe_frac
397
+
398
+ voltage_stats = {
399
+ "max_under": float(np.max(under)) if n_total > 0 else 0.0,
400
+ "max_over": float(np.max(over)) if n_total > 0 else 0.0,
401
+ "violation_frac": float(n_violating) / max(n_total, 1),
402
+ }
403
+
404
+ # Gather DC states
405
+ dcs = datacenter if isinstance(datacenter, list) else [datacenter]
406
+ itl_by_model: dict[str, float] = {}
407
+ for dc in dcs:
408
+ itl_by_model.update(dc.state.observed_itl_s_by_model)
409
+
410
+ throughput_term = 0.0
411
+ latency_term = 0.0
412
+ switch_term = 0.0
413
+ switch_mode = reward_config.switch_mode
414
+ for label in obs_config.model_labels:
415
+ feasible = obs_config.feasible_batch_sizes[label]
416
+ batch = curr_batch.get(label, feasible[len(feasible) // 2])
417
+
418
+ if logistic_models is not None:
419
+ th_fit = logistic_models.throughput(label)
420
+ th_max = th_fit.eval(feasible[-1])
421
+ th_max = max(th_max, 1e-9)
422
+ throughput_term += reward_config.w_throughput * th_fit.eval(batch) / th_max
423
+ else:
424
+ log2_max = math.log2(feasible[-1])
425
+ throughput_term += reward_config.w_throughput * math.log2(max(batch, 1)) / log2_max
426
+
427
+ itl = itl_by_model.get(label, float("nan"))
428
+ deadline = obs_config.itl_deadlines[label]
429
+ if not math.isnan(itl) and itl > deadline:
430
+ latency_term -= reward_config.w_latency * (itl - deadline) / deadline
431
+
432
+ prev_b = prev_batch.get(label, batch)
433
+ if prev_b > 0 and batch > 0 and batch != prev_b:
434
+ if switch_mode == "magnitude":
435
+ switch_term -= reward_config.w_switch * abs(math.log2(batch) - math.log2(prev_b))
436
+ elif switch_mode == "binary":
437
+ switch_term -= reward_config.w_switch
438
+ elif switch_mode == "cooldown":
439
+ ssc = (steps_since_change or {}).get(label, 999)
440
+ switch_term -= reward_config.w_switch * math.exp(-ssc / reward_config.switch_cooldown_tau)
441
+
442
+ reward = voltage_term + throughput_term + latency_term + switch_term + safe_term
443
+ components = {
444
+ "voltage": voltage_term,
445
+ "throughput": throughput_term,
446
+ "latency": latency_term,
447
+ "switch": switch_term,
448
+ "safe": safe_term,
449
+ }
450
+ return reward, components, voltage_stats
451
+
452
+
453
+ SimComponents = tuple[
454
+ dict[str, DatacenterBackend], # datacenters keyed by site_id
455
+ GridBackend, # grid
456
+ TapScheduleController | None, # tap controller (optional)
457
+ ]
458
+
459
+ MakeSimFn = Callable[..., SimComponents]
460
+
461
+
462
+ class ScenarioLibrary:
463
+ """Pre-screened scenario bank for PPO training and evaluation.
464
+
465
+ Loaded from the directory produced by `build_library.py`:
466
+
467
+ - `metadata.json`: build-time config + per-scenario scalar fields.
468
+ - `traces.npz`: per-scenario voltage penalty arrays
469
+ (`ofo_<i>` / `baseline_<i>`).
470
+
471
+ `materialize(rec)` replays `randomize_scenario(seed=rec.seed, ...)` to
472
+ rebuild the full per-episode scenario configuration on demand. Replay is
473
+ bit-identical to the build-time output because `randomize_scenario` is
474
+ fully seeded: there is no on-disk cache of the resolved configs to keep
475
+ in sync.
476
+ """
477
+
478
+ def __init__(
479
+ self,
480
+ path: str,
481
+ *,
482
+ training_trace=None,
483
+ ) -> None:
484
+ lib_dir = Path(path)
485
+ self.scenarios, self.config = load_library_data(lib_dir)
486
+ if not self.scenarios:
487
+ raise ValueError(f"Scenario library at {path} is empty.")
488
+
489
+ # Materialization base components, reconstructed from the experiment
490
+ # factory + library config. randomize_scenario needs these to replay.
491
+ base_exp = EXPERIMENTS[self.config["system"]](training_trace=training_trace)
492
+ self._dc_sites_base: dict[str, Any] = base_exp["dc_sites"]
493
+ self._pv_systems_base = [PVSystemSpec(**p) for p in self.config["pv_systems_base"]]
494
+ self._tvl_base = [TimeVaryingLoadSpec(**t) for t in self.config["tvl_base"]]
495
+ overlay_cfg = self.config.get("training_base")
496
+ if overlay_cfg is not None and training_trace is not None:
497
+ self._training_base: dict | None = {**overlay_cfg, "trace": training_trace}
498
+ else:
499
+ self._training_base = None
500
+
501
+ self._rng = np.random.default_rng()
502
+
503
+ def __len__(self) -> int:
504
+ return len(self.scenarios)
505
+
506
+ def sample(self) -> Any:
507
+ """Return a random scenario record."""
508
+ idx = int(self._rng.integers(0, len(self.scenarios)))
509
+ return self.scenarios[idx]
510
+
511
+ def materialize(self, rec: ScenarioRecord) -> dict:
512
+ """Re-derive the full per-episode scenario dict for `rec`.
513
+
514
+ Returns the same shape as `randomize_scenario` (keys `dc_sites`,
515
+ `pv_systems`, `tvl`, `training_run`, `params`, ...).
516
+ """
517
+ return materialize_scenario(
518
+ rec,
519
+ dc_sites_base=self._dc_sites_base,
520
+ pv_systems_base=self._pv_systems_base,
521
+ tvl_base=self._tvl_base,
522
+ training_base=self._training_base,
523
+ randomize_kwargs=self.config["randomize_kwargs"],
524
+ )
525
+
526
+
527
+ class BatchSizeEnv(gymnasium.Env):
528
+ """Gymnasium environment for batch-size voltage regulation.
529
+
530
+ Each `step(action)` advances the simulation by one control interval.
531
+ For multi-DC setups, `agent_site_id` specifies which site the RL agent
532
+ controls. Other sites run with fixed batch sizes.
533
+
534
+ When `scenario_library` is provided, each `reset()` samples a
535
+ pre-screened scenario and replays it. When `ofo_baseline=True` the
536
+ voltage reward term becomes the per-step difference between PPO's
537
+ voltage penalty and the OFO oracle's (stored in the library), so the
538
+ agent is rewarded for *improving on OFO*. When `truncate_episode=True`
539
+ the episode fast-forwards through the initial quiet period (before the
540
+ first baseline violation) and terminates after the last violation clears.
541
+ """
542
+
543
+ metadata = {"render_modes": []} # noqa: RUF012 # gym Env.metadata override; not annotated to avoid ty's invalid-attribute-override
544
+
545
+ def __init__(
546
+ self,
547
+ make_sim_fn: MakeSimFn,
548
+ obs_config: ObservationConfig,
549
+ agent_site_id: str = "_default",
550
+ reward_config: RewardConfig | None = None,
551
+ action_mode: str = "delta",
552
+ logistic_models: LogisticModelStore | None = None,
553
+ dt_ctrl: Fraction = Fraction(1),
554
+ total_duration_s: int = 3600,
555
+ scenario_library: ScenarioLibrary | None = None,
556
+ ofo_baseline: bool = False,
557
+ truncate_episode: bool = False,
558
+ ) -> None:
559
+ super().__init__()
560
+ self._make_sim = make_sim_fn
561
+ self._obs_config = obs_config
562
+ self._agent_site_id = agent_site_id
563
+ self._reward_config = reward_config or RewardConfig()
564
+ self._logistic_models = logistic_models
565
+ self._dt_ctrl = dt_ctrl
566
+ self._total_duration_s = total_duration_s
567
+ self._scenario_library = scenario_library
568
+ self._ofo_baseline = ofo_baseline
569
+ self._truncate_episode = truncate_episode
570
+
571
+ self._action_mode = action_mode
572
+
573
+ self.observation_space = spaces.Box(low=-np.inf, high=np.inf, shape=(obs_config.obs_dim,), dtype=np.float32)
574
+ n_feasible = min(len(obs_config.feasible_batch_sizes[m]) for m in obs_config.model_labels)
575
+ n_models = len(obs_config.model_labels)
576
+ self._coupled_max_shift = n_feasible - 1
577
+ n_coupled = 2 * self._coupled_max_shift + 1
578
+
579
+ if self._action_mode == "coupled":
580
+ self.action_space = spaces.Discrete(n_coupled)
581
+ elif self._action_mode == "delta":
582
+ self.action_space = spaces.MultiDiscrete([3] * n_models)
583
+ else:
584
+ raise ValueError(f"Unknown action_mode: {self._action_mode!r}")
585
+
586
+ self._datacenters: dict[str, DatacenterBackend] = {}
587
+ self._coord: Coordinator | None = None
588
+ self._prev_batch: dict[str, int] = {}
589
+ self._steps_since_change: dict[str, int] = {}
590
+ self._steps_done: int = 0
591
+ self._max_steps: int = 0
592
+ self._zone_mask: np.ndarray | None = None
593
+ self._zone_masks: dict[str, np.ndarray] | None = None
594
+ self._ofo_voltage_trace: np.ndarray | None = None
595
+ self._sim_step_offset: int = 0 # base-tick offset of the first PPO control step
596
+
597
+ def _action_to_batch_sizes(self, action: np.ndarray) -> dict[str, int]:
598
+ return decode_action(
599
+ action,
600
+ self._action_mode,
601
+ self._obs_config.model_labels,
602
+ self._obs_config.feasible_batch_sizes,
603
+ self._prev_batch,
604
+ self._coupled_max_shift,
605
+ )
606
+
607
+ def _action_to_commands(self, action: np.ndarray) -> tuple[list[DatacenterCommand | GridCommand], dict[str, int]]:
608
+ """Decode an action into the commands to dispatch this step.
609
+
610
+ Returns `(commands, applied_batch_sizes)`: the `applied_batch_sizes`
611
+ dict is what `compute_reward` and the steps-since-change tracking
612
+ consume. Subclasses (e.g. `SharedBatchSizeEnv`) override to dispatch
613
+ per-site commands when one policy controls multiple datacenters.
614
+ """
615
+ batch_sizes = self._action_to_batch_sizes(action)
616
+ commands: list[DatacenterCommand | GridCommand] = [
617
+ SetBatchSize(batch_size_by_model=batch_sizes, target=self._datacenters[self._agent_site_id])
618
+ ]
619
+ return commands, batch_sizes
620
+
621
+ def _obs_target(self) -> DatacenterBackend | list[DatacenterBackend]:
622
+ """Datacenter(s) feeding `build_observation` / `compute_reward`.
623
+
624
+ Single-policy default: just the agent's DC. `SharedBatchSizeEnv`
625
+ overrides to return all DCs jointly.
626
+ """
627
+ return self._datacenters[self._agent_site_id]
628
+
629
+ def _advance_one_control_interval(self) -> None:
630
+ coord = self._coord
631
+ if coord is None:
632
+ raise RuntimeError("Coordinator not started; call reset() first.")
633
+ n_ticks = int(self._dt_ctrl / coord.clock.tick_s)
634
+ for _ in range(n_ticks):
635
+ coord.step()
636
+
637
+ def reset(self, *, seed: int | None = None, options: dict[str, Any] | None = None) -> tuple[np.ndarray, dict]:
638
+ super().reset(seed=seed, options=options)
639
+
640
+ if self._coord is not None:
641
+ self._coord.stop()
642
+ self._coord = None
643
+
644
+ self._ofo_voltage_trace = None
645
+ self._sim_step_offset = 0
646
+ t_control_start = 0
647
+ t_control_end = self._total_duration_s
648
+
649
+ if self._scenario_library is not None:
650
+ scenario_record = self._scenario_library.sample()
651
+ if self._ofo_baseline:
652
+ self._ofo_voltage_trace = scenario_record.ofo_voltage_pen_per_step
653
+ if self._truncate_episode:
654
+ t_control_start = scenario_record.t_control_start
655
+ t_control_end = scenario_record.t_control_end
656
+ scenario_dict = self._scenario_library.materialize(scenario_record)
657
+ datacenters, grid, tap_ctrl = self._make_sim(scenario_override=scenario_dict)
658
+ else:
659
+ datacenters, grid, tap_ctrl = self._make_sim()
660
+ self._datacenters = datacenters
661
+
662
+ controllers = [tap_ctrl] if tap_ctrl is not None else []
663
+ self._coord = Coordinator(
664
+ datacenters=list(datacenters.values()),
665
+ grid=grid,
666
+ controllers=controllers,
667
+ total_duration_s=self._total_duration_s,
668
+ )
669
+ self._coord.reset()
670
+ self._coord.start()
671
+
672
+ zone_buses = self._obs_config.zone_buses
673
+ self._zone_mask = compute_zone_mask(grid.v_index, zone_buses) if zone_buses is not None else None
674
+ if self._obs_config.zone_summary:
675
+ self._zone_masks = {
676
+ zname: compute_zone_mask(grid.v_index, tuple(zbuses))
677
+ for zname, zbuses in self._obs_config.zone_summary.items()
678
+ }
679
+ else:
680
+ self._zone_masks = None
681
+
682
+ # Fast-forward through the quiet pre-violation window with fixed initial
683
+ # batch sizes; the sim still runs so grid/DC states evolve.
684
+ self._sim_step_offset = t_control_start
685
+ for _ in range(t_control_start):
686
+ self._advance_one_control_interval()
687
+
688
+ # One more interval so the first observation reflects t_control_start.
689
+ # That consumes one tick of the control window, so the agent gets
690
+ # `(t_control_end - t_control_start - 1)` steps to act on; without the
691
+ # -1, the final step's sim time would be t_control_end and overshoot
692
+ # the OFO trace (which covers [0, total_duration_s - 1]).
693
+ self._advance_one_control_interval()
694
+ self._max_steps = t_control_end - t_control_start - 1
695
+ self._steps_done = 0
696
+
697
+ self._prev_batch = {label: self._obs_config.get_initial_batch(label) for label in self._obs_config.model_labels}
698
+ self._steps_since_change = {label: 999 for label in self._obs_config.model_labels}
699
+ obs = build_observation(
700
+ self._coord.grid, self._obs_target(), self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks
701
+ )
702
+ return obs, {}
703
+
704
+ def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict]:
705
+ coord = self._coord
706
+ if coord is None:
707
+ raise RuntimeError("Coordinator not started; call reset() first.")
708
+
709
+ commands, applied_batch = self._action_to_commands(action)
710
+ coord.dispatch_commands(commands)
711
+ self._advance_one_control_interval()
712
+ self._steps_done += 1
713
+
714
+ target = self._obs_target()
715
+ reward, reward_components, voltage_stats = compute_reward(
716
+ coord.grid,
717
+ target,
718
+ self._obs_config,
719
+ self._reward_config,
720
+ self._prev_batch,
721
+ applied_batch,
722
+ self._logistic_models,
723
+ steps_since_change=self._steps_since_change,
724
+ )
725
+
726
+ for label in self._obs_config.model_labels:
727
+ if applied_batch.get(label) != self._prev_batch.get(label):
728
+ self._steps_since_change[label] = 0
729
+ else:
730
+ self._steps_since_change[label] = self._steps_since_change.get(label, 999) + 1
731
+
732
+ # OFO-difference reward: add back the OFO oracle's voltage penalty at
733
+ # this simulation timestep so the agent is rewarded for beating OFO.
734
+ if self._ofo_voltage_trace is not None:
735
+ sim_t = self._sim_step_offset + self._steps_done
736
+ if sim_t >= len(self._ofo_voltage_trace):
737
+ raise IndexError(
738
+ f"OFO trace lookup at sim_t={sim_t} but trace length is "
739
+ f"{len(self._ofo_voltage_trace)}; episode is running past the trace. "
740
+ "Check t_control_end vs. the scenario library's total_duration_s."
741
+ )
742
+ ofo_pen = float(self._ofo_voltage_trace[sim_t])
743
+ ofo_voltage_baseline = self._reward_config.w_voltage * ofo_pen
744
+ reward += ofo_voltage_baseline
745
+ reward_components = dict(reward_components)
746
+ reward_components["voltage"] += ofo_voltage_baseline
747
+ reward_components["ofo_baseline"] = ofo_voltage_baseline
748
+
749
+ if self._reward_config.reward_clip > 0:
750
+ reward = max(reward, -self._reward_config.reward_clip)
751
+
752
+ self._prev_batch = dict(applied_batch)
753
+ obs = build_observation(
754
+ coord.grid, target, self._obs_config, self._prev_batch, self._zone_mask, self._zone_masks
755
+ )
756
+
757
+ truncated = self._steps_done >= self._max_steps
758
+ info = {"reward_components": reward_components, "voltage_stats": voltage_stats}
759
+ return obs, reward, False, truncated, info
760
+
761
+ def close(self) -> None:
762
+ if self._coord is not None:
763
+ self._coord.stop()
764
+ self._coord = None
765
+ super().close()
766
+
767
+
768
+ class SharedBatchSizeEnv(BatchSizeEnv):
769
+ """Controls ALL datacenter sites jointly with a single policy.
770
+
771
+ The observation includes per-model features from all sites; the action
772
+ space covers all models across all sites. `site_model_mapping` maps each
773
+ site_id to the list of model labels served at that site.
774
+ """
775
+
776
+ def __init__(
777
+ self,
778
+ make_sim_fn: MakeSimFn,
779
+ obs_config: ObservationConfig,
780
+ site_model_mapping: dict[str, list[str]],
781
+ reward_config: RewardConfig | None = None,
782
+ action_mode: str = "delta",
783
+ logistic_models: LogisticModelStore | None = None,
784
+ dt_ctrl: Fraction = Fraction(1),
785
+ total_duration_s: int = 3600,
786
+ scenario_library: ScenarioLibrary | None = None,
787
+ ofo_baseline: bool = False,
788
+ truncate_episode: bool = False,
789
+ ) -> None:
790
+ super().__init__(
791
+ make_sim_fn=make_sim_fn,
792
+ obs_config=obs_config,
793
+ reward_config=reward_config,
794
+ action_mode=action_mode,
795
+ logistic_models=logistic_models,
796
+ dt_ctrl=dt_ctrl,
797
+ total_duration_s=total_duration_s,
798
+ scenario_library=scenario_library,
799
+ ofo_baseline=ofo_baseline,
800
+ truncate_episode=truncate_episode,
801
+ )
802
+ self._site_model_mapping = site_model_mapping
803
+
804
+ def _action_to_commands(self, action: np.ndarray) -> tuple[list[DatacenterCommand | GridCommand], dict[str, int]]:
805
+ flat = self._action_to_batch_sizes(action)
806
+ commands: list[DatacenterCommand | GridCommand] = []
807
+ applied_batch: dict[str, int] = {}
808
+ for sid, labels in self._site_model_mapping.items():
809
+ if sid not in self._datacenters:
810
+ continue
811
+ site_batch = {label: flat[label] for label in labels if label in flat}
812
+ if not site_batch:
813
+ continue
814
+ commands.append(SetBatchSize(batch_size_by_model=site_batch, target=self._datacenters[sid]))
815
+ applied_batch.update(site_batch)
816
+ return commands, applied_batch
817
+
818
+ def _obs_target(self) -> list[DatacenterBackend]:
819
+ return list(self._datacenters.values())
examples/rl_controller/evaluate.py ADDED
@@ -0,0 +1,897 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate controllers (baseline, OFO, rule-based, PPO) across scenarios.
2
+
3
+ Combines single-run comparison helpers (plotting, display names) with the
4
+ multi-scenario evaluation pipeline (scenario generation, CSV export, aggregate
5
+ plots).
6
+
7
+ Usage:
8
+ python examples/rl_controller/evaluate.py \
9
+ --ppo-models outputs/ieee13/ppo_seed1/ppo_model.zip \
10
+ outputs/ieee13/ppo_seed2/ppo_model.zip \
11
+ --ppo-labels seed1 seed2 \
12
+ --n-scenarios 10 --seed-start 500
13
+
14
+ # Quick test with 3 scenarios
15
+ python examples/rl_controller/evaluate.py \
16
+ --ppo-models outputs/ieee13/ppo/ppo_model.zip \
17
+ --n-scenarios 3
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import csv
23
+ import logging
24
+ import math
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+ from build_library import run_simulation
30
+ from env import ScenarioLibrary
31
+ from scenarios import (
32
+ EXPERIMENTS,
33
+ randomize_scenario,
34
+ )
35
+
36
+ from openg2g.controller.ofo import LogisticModelStore, OFOConfig
37
+ from openg2g.controller.rule_based import RuleBasedConfig
38
+ from openg2g.datacenter.workloads.inference import InferenceData
39
+ from openg2g.datacenter.workloads.training import TrainingTrace
40
+
41
+ from plotting import (
42
+ plot_aggregate,
43
+ plot_batch_comparison,
44
+ plot_violation_bars,
45
+ plot_voltage_comparison,
46
+ )
47
+ from systems import (
48
+ DT_DC,
49
+ SPECS_CACHE_DIR,
50
+ TRAINING_TRACE_PATH,
51
+ V_MAX,
52
+ V_MIN,
53
+ )
54
+
55
+ logger = logging.getLogger("evaluate_controllers")
56
+
57
+
58
+ def count_batch_changes(log) -> int:
59
+ """Count step-to-step batch-size changes summed across every (site, model) series."""
60
+ total = 0
61
+ states_by_site = getattr(log, "dc_states_by_site", None) or {}
62
+ for states in states_by_site.values():
63
+ if len(states) < 2:
64
+ continue
65
+ labels: set[str] = set()
66
+ for s in states:
67
+ labels.update(s.batch_size_by_model.keys())
68
+ for lab in labels:
69
+ prev = None
70
+ for s in states:
71
+ bs = s.batch_size_by_model.get(lab, 0)
72
+ if prev is not None and bs != prev:
73
+ total += 1
74
+ prev = bs
75
+ return total
76
+
77
+
78
+ def extract_perf_metrics(log, itl_deadlines: dict[str, float] | None = None) -> dict[str, float]:
79
+ """Pull throughput / latency / power time-series from a SimulationLog and
80
+ return scalar summaries (means, peaks, percentiles).
81
+ """
82
+ dc_states = list(log.dc_states)
83
+ if not dc_states:
84
+ return {
85
+ "mean_throughput_toks_s": 0.0,
86
+ "peak_throughput_toks_s": 0.0,
87
+ "mean_latency_s": 0.0,
88
+ "p99_latency_s": 0.0,
89
+ "mean_power_kw": 0.0,
90
+ "peak_power_kw": 0.0,
91
+ "batch_changes": 0,
92
+ "itl_violation_rate": 0.0,
93
+ }
94
+
95
+ labels = set()
96
+ for s in dc_states:
97
+ labels.update(s.batch_size_by_model.keys())
98
+ labels = sorted(labels)
99
+
100
+ tps_total = np.zeros(len(dc_states))
101
+ itl_vals: list[float] = []
102
+ for i, s in enumerate(dc_states):
103
+ for lab in labels:
104
+ bs = float(s.batch_size_by_model.get(lab, 0) or 0)
105
+ replicas = float(s.active_replicas_by_model.get(lab, 0) or 0)
106
+ itl = float(s.observed_itl_s_by_model.get(lab, float("nan")))
107
+ if itl > 0 and not math.isnan(itl):
108
+ tps_total[i] += bs * replicas / itl
109
+ itl_vals.append(itl)
110
+ mean_tps = float(np.nanmean(tps_total)) if len(tps_total) else 0.0
111
+ peak_tps = float(np.nanmax(tps_total)) if len(tps_total) else 0.0
112
+ mean_itl = float(np.nanmean(itl_vals)) if itl_vals else 0.0
113
+ p99_itl = float(np.nanpercentile(itl_vals, 99)) if itl_vals else 0.0
114
+
115
+ dc_kw_series: list[float] = []
116
+ for s in dc_states:
117
+ p = getattr(s, "power_w", None)
118
+ if p is None:
119
+ continue
120
+ try:
121
+ total_w = float(p.a) + float(p.b) + float(p.c)
122
+ except Exception:
123
+ total_w = 0.0
124
+ dc_kw_series.append(total_w / 1000.0)
125
+ mean_kw = float(np.mean(dc_kw_series)) if dc_kw_series else 0.0
126
+ peak_kw = float(np.max(dc_kw_series)) if dc_kw_series else 0.0
127
+
128
+ itl_viol_count = 0
129
+ itl_total_count = 0
130
+ if itl_deadlines:
131
+ for s in dc_states:
132
+ for label, deadline in itl_deadlines.items():
133
+ itl = s.observed_itl_s_by_model.get(label, float("nan"))
134
+ itl = float(itl)
135
+ if not math.isnan(itl) and itl > 0:
136
+ itl_total_count += 1
137
+ if itl > deadline:
138
+ itl_viol_count += 1
139
+ itl_violation_rate = itl_viol_count / itl_total_count if itl_total_count > 0 else 0.0
140
+
141
+ return {
142
+ "mean_throughput_toks_s": mean_tps,
143
+ "peak_throughput_toks_s": peak_tps,
144
+ "mean_latency_s": mean_itl,
145
+ "p99_latency_s": p99_itl,
146
+ "mean_power_kw": mean_kw,
147
+ "peak_power_kw": peak_kw,
148
+ "batch_changes": count_batch_changes(log),
149
+ "itl_violation_rate": itl_violation_rate,
150
+ }
151
+
152
+
153
+ def generate_test_scenarios(
154
+ exp: dict,
155
+ inference_data: InferenceData,
156
+ training_trace: TrainingTrace,
157
+ logistic_models: LogisticModelStore,
158
+ *,
159
+ n_scenarios: int,
160
+ seed_start: int,
161
+ min_baseline_integral: float = 0.2,
162
+ min_recovery_frac: float = 0.7,
163
+ save_dir: Path,
164
+ randomize_ramps: bool = True,
165
+ ) -> list[dict]:
166
+ """Generate randomized scenarios and filter using the library recovery rule.
167
+
168
+ A seed is accepted only if:
169
+ baseline_no_tap integral >= min_baseline_integral AND
170
+ (base - ofo) / base >= min_recovery_frac
171
+ """
172
+ dc_sites_base = exp["dc_sites"]
173
+ pv_base = exp.get("pv_systems", [])
174
+ tvl_base = exp.get("time_varying_loads", [])
175
+ training_base = exp.get("training_base")
176
+
177
+ sys_cfg = exp["sys"]
178
+ ofo_config = exp["ofo_config"]
179
+
180
+ accepted: list[dict] = []
181
+ seed = seed_start
182
+ attempts = 0
183
+ max_attempts = n_scenarios * 20
184
+ tried: list[str] = []
185
+
186
+ while len(accepted) < n_scenarios and attempts < max_attempts:
187
+ effective_seed = seed * 1000 + 7
188
+ sc = randomize_scenario(
189
+ seed=effective_seed,
190
+ dc_sites_base=dc_sites_base,
191
+ pv_systems_base=pv_base,
192
+ tvl_base=tvl_base,
193
+ training_base=training_base,
194
+ randomize_ramps=randomize_ramps,
195
+ )
196
+
197
+ training_overlay = sc["params"]["training_overlay"]
198
+
199
+ bl_stats, bl_log = run_simulation(
200
+ "baseline_no_tap",
201
+ sys=sys_cfg,
202
+ dc_sites=sc["dc_sites"],
203
+ ofo_config=ofo_config,
204
+ inference_data=inference_data,
205
+ training_trace=training_trace,
206
+ logistic_models=logistic_models,
207
+ pv_systems=sc["pv_systems"],
208
+ time_varying_loads=sc["tvl"],
209
+ tap_schedule=exp.get("tap_schedule"),
210
+ training_overlay=training_overlay,
211
+ save_dir=save_dir,
212
+ )
213
+ ofo_stats, ofo_log = run_simulation(
214
+ "ofo",
215
+ sys=sys_cfg,
216
+ dc_sites=sc["dc_sites"],
217
+ ofo_config=ofo_config,
218
+ inference_data=inference_data,
219
+ training_trace=training_trace,
220
+ logistic_models=logistic_models,
221
+ pv_systems=sc["pv_systems"],
222
+ time_varying_loads=sc["tvl"],
223
+ tap_schedule=exp.get("tap_schedule"),
224
+ training_overlay=training_overlay,
225
+ save_dir=save_dir,
226
+ )
227
+ base_int = float(bl_stats.integral_violation_pu_s)
228
+ ofo_int = float(ofo_stats.integral_violation_pu_s)
229
+ recovery = (base_int - ofo_int) / base_int if base_int > 0 else 0.0
230
+ passes = base_int >= min_baseline_integral and recovery >= min_recovery_frac
231
+ verdict = "ACCEPT" if passes else "reject"
232
+ line = (
233
+ f" seed={effective_seed} base_int={base_int:.3f} ofo_int={ofo_int:.4f} "
234
+ f"recovery={100 * recovery:.1f}% {verdict}"
235
+ )
236
+ logger.info(line)
237
+ tried.append(line)
238
+
239
+ if passes:
240
+ sc["_filter_results"] = {
241
+ "baseline_no_tap": {"stats": bl_stats, "log": bl_log},
242
+ "ofo": {"stats": ofo_stats, "log": ofo_log},
243
+ }
244
+ accepted.append(sc)
245
+ seed += 1
246
+ attempts += 1
247
+
248
+ logger.info(
249
+ "Filter complete: %d accepted out of %d attempted seeds (%d-%d)",
250
+ len(accepted),
251
+ attempts,
252
+ seed_start,
253
+ seed - 1,
254
+ )
255
+ if len(accepted) < n_scenarios:
256
+ logger.warning(
257
+ "Only accepted %d/%d scenarios: consider lowering min_recovery_frac or expanding seed range",
258
+ len(accepted),
259
+ n_scenarios,
260
+ )
261
+ return accepted
262
+
263
+
264
+ def load_scenarios_from_library(
265
+ library_path: str,
266
+ *,
267
+ n_scenarios: int,
268
+ training_trace: TrainingTrace,
269
+ ) -> list[dict]:
270
+ """Load pre-screened scenarios from a `ScenarioLibrary` directory.
271
+
272
+ Replays `randomize_scenario(seed)` for each record (deterministic, since
273
+ the RNG is seeded) to rebuild the per-episode dict. `training_trace` is
274
+ needed because libraries built with `--use-training-overlay` reference
275
+ a TrainingTrace at materialization time; for libraries without overlay
276
+ it can be `None`.
277
+ """
278
+ lib = ScenarioLibrary(library_path, training_trace=training_trace)
279
+
280
+ logger.info("Loaded library with %d records from %s", len(lib), library_path)
281
+
282
+ n_take = min(n_scenarios, len(lib))
283
+ if n_take < n_scenarios:
284
+ logger.warning(
285
+ "Library has only %d scenarios; capping n_scenarios from %d to %d",
286
+ len(lib),
287
+ n_scenarios,
288
+ n_take,
289
+ )
290
+
291
+ return [lib.materialize(rec) for rec in lib.scenarios[:n_take]]
292
+
293
+
294
+ def run_scenario(
295
+ scenario: dict,
296
+ *,
297
+ exp: dict,
298
+ inference_data: InferenceData,
299
+ training_trace: TrainingTrace,
300
+ logistic_models: LogisticModelStore,
301
+ ppo_models: list[str],
302
+ ppo_labels: list[str],
303
+ save_dir: Path,
304
+ scenario_idx: int,
305
+ obs_mode: str = "full-voltage",
306
+ ofo_variants: list[tuple[str, OFOConfig]] | None = None,
307
+ include_rule_based: bool = False,
308
+ rule_step_sizes: tuple[float, ...] = (10.0,),
309
+ rule_zone_local: bool = False,
310
+ no_per_scenario_plots: bool = False,
311
+ no_default_ofo: bool = False,
312
+ use_display_names: bool = False,
313
+ ) -> dict:
314
+ """Run baseline, OFO (+variants), rule-based, and PPO models on a single scenario."""
315
+ sys_cfg = exp["sys"]
316
+ ofo_config = exp["ofo_config"]
317
+ exclude_buses = tuple(sys_cfg["exclude_buses"])
318
+ ofo_variants = ofo_variants or []
319
+
320
+ itl_deadlines: dict[str, float] = {
321
+ md.spec.model_label: md.spec.itl_deadline_s for site in exp["dc_sites"].values() for md, _ in site.models
322
+ }
323
+
324
+ dc_sites = scenario["dc_sites"]
325
+ pv_systems = scenario["pv_systems"]
326
+ tvl = scenario["tvl"]
327
+ training_overlay = scenario["params"]["training_overlay"]
328
+
329
+ sc_save = save_dir / f"scenario_{scenario_idx:03d}"
330
+ sc_save.mkdir(parents=True, exist_ok=True)
331
+
332
+ results = {}
333
+ all_logs: dict[str, object] = {}
334
+
335
+ cached = scenario.get("_filter_results", {})
336
+ default_modes = ["baseline_no_tap"] if no_default_ofo else ["baseline_no_tap", "ofo"]
337
+ for mode in default_modes:
338
+ if mode in cached:
339
+ vstats = cached[mode]["stats"]
340
+ log = cached[mode]["log"]
341
+ else:
342
+ vstats, log = run_simulation(
343
+ mode,
344
+ sys=sys_cfg,
345
+ dc_sites=dc_sites,
346
+ ofo_config=ofo_config,
347
+ inference_data=inference_data,
348
+ training_trace=training_trace,
349
+ logistic_models=logistic_models,
350
+ pv_systems=pv_systems,
351
+ time_varying_loads=tvl,
352
+ tap_schedule=exp.get("tap_schedule"),
353
+ training_overlay=training_overlay,
354
+ save_dir=sc_save,
355
+ )
356
+ perf = extract_perf_metrics(log, itl_deadlines)
357
+ results[mode] = {
358
+ "violation_time_s": vstats.violation_time_s,
359
+ "integral": vstats.integral_violation_pu_s,
360
+ "worst_vmin": vstats.worst_vmin,
361
+ "worst_vmax": vstats.worst_vmax,
362
+ **perf,
363
+ }
364
+ all_logs[mode] = log
365
+ logger.info(
366
+ " scenario %d %s: viol=%.0fs integral=%.4f vmin=%.4f vmax=%.4f "
367
+ "tput=%.1f p99_lat=%.3fs power=%.1fkW batch_chg=%d",
368
+ scenario_idx,
369
+ mode,
370
+ vstats.violation_time_s,
371
+ vstats.integral_violation_pu_s,
372
+ vstats.worst_vmin,
373
+ vstats.worst_vmax,
374
+ perf["mean_throughput_toks_s"],
375
+ perf["p99_latency_s"],
376
+ perf["mean_power_kw"],
377
+ perf["batch_changes"],
378
+ )
379
+
380
+ if include_rule_based:
381
+ for step_size in rule_step_sizes:
382
+ label = "rule_based" if len(rule_step_sizes) == 1 else f"rule_based_s{step_size:g}"
383
+ rb_config = RuleBasedConfig(v_min=V_MIN, v_max=V_MAX, step_size=step_size)
384
+ vstats, log = run_simulation(
385
+ label,
386
+ sys=sys_cfg,
387
+ dc_sites=dc_sites,
388
+ ofo_config=ofo_config,
389
+ inference_data=inference_data,
390
+ training_trace=training_trace,
391
+ logistic_models=logistic_models,
392
+ pv_systems=pv_systems,
393
+ time_varying_loads=tvl,
394
+ tap_schedule=exp.get("tap_schedule"),
395
+ rule_based_config=rb_config,
396
+ rule_zone_local=rule_zone_local,
397
+ training_overlay=training_overlay,
398
+ save_dir=sc_save,
399
+ )
400
+ perf = extract_perf_metrics(log, itl_deadlines)
401
+ results[label] = {
402
+ "violation_time_s": vstats.violation_time_s,
403
+ "integral": vstats.integral_violation_pu_s,
404
+ "worst_vmin": vstats.worst_vmin,
405
+ "worst_vmax": vstats.worst_vmax,
406
+ **perf,
407
+ }
408
+ all_logs[label] = log
409
+ logger.info(
410
+ " scenario %d %s: viol=%.0fs integral=%.4f vmin=%.4f vmax=%.4f "
411
+ "tput=%.1f p99_lat=%.3fs power=%.1fkW batch_chg=%d",
412
+ scenario_idx,
413
+ label,
414
+ vstats.violation_time_s,
415
+ vstats.integral_violation_pu_s,
416
+ vstats.worst_vmin,
417
+ vstats.worst_vmax,
418
+ perf["mean_throughput_toks_s"],
419
+ perf["p99_latency_s"],
420
+ perf["mean_power_kw"],
421
+ perf["batch_changes"],
422
+ )
423
+
424
+ for variant_label, variant_cfg in ofo_variants:
425
+ vstats, log = run_simulation(
426
+ "ofo",
427
+ sys=sys_cfg,
428
+ dc_sites=dc_sites,
429
+ ofo_config=variant_cfg,
430
+ inference_data=inference_data,
431
+ training_trace=training_trace,
432
+ logistic_models=logistic_models,
433
+ pv_systems=pv_systems,
434
+ time_varying_loads=tvl,
435
+ tap_schedule=exp.get("tap_schedule"),
436
+ training_overlay=training_overlay,
437
+ save_dir=sc_save,
438
+ )
439
+ perf = extract_perf_metrics(log, itl_deadlines)
440
+ mode_key = f"ofo_{variant_label}"
441
+ results[mode_key] = {
442
+ "violation_time_s": vstats.violation_time_s,
443
+ "integral": vstats.integral_violation_pu_s,
444
+ "worst_vmin": vstats.worst_vmin,
445
+ "worst_vmax": vstats.worst_vmax,
446
+ **perf,
447
+ }
448
+ all_logs[mode_key] = log
449
+ logger.info(
450
+ " scenario %d %s: viol=%.0fs integral=%.4f vmin=%.4f vmax=%.4f "
451
+ "tput=%.1f p99_lat=%.3fs power=%.1fkW batch_chg=%d",
452
+ scenario_idx,
453
+ mode_key,
454
+ vstats.violation_time_s,
455
+ vstats.integral_violation_pu_s,
456
+ vstats.worst_vmin,
457
+ vstats.worst_vmax,
458
+ perf["mean_throughput_toks_s"],
459
+ perf["p99_latency_s"],
460
+ perf["mean_power_kw"],
461
+ perf["batch_changes"],
462
+ )
463
+
464
+ for ppo_path, label in zip(ppo_models, ppo_labels, strict=False):
465
+ vstats, log = run_simulation(
466
+ "ppo",
467
+ sys=sys_cfg,
468
+ dc_sites=dc_sites,
469
+ ofo_config=ofo_config,
470
+ inference_data=inference_data,
471
+ training_trace=training_trace,
472
+ logistic_models=logistic_models,
473
+ pv_systems=pv_systems,
474
+ time_varying_loads=tvl,
475
+ tap_schedule=exp.get("tap_schedule"),
476
+ ppo_model=ppo_path,
477
+ obs_mode=obs_mode,
478
+ training_overlay=training_overlay,
479
+ save_dir=sc_save,
480
+ )
481
+ perf = extract_perf_metrics(log, itl_deadlines)
482
+ results[f"ppo_{label}"] = {
483
+ "violation_time_s": vstats.violation_time_s,
484
+ "integral": vstats.integral_violation_pu_s,
485
+ "worst_vmin": vstats.worst_vmin,
486
+ "worst_vmax": vstats.worst_vmax,
487
+ **perf,
488
+ }
489
+ all_logs[f"ppo_{label}"] = log
490
+ logger.info(
491
+ " scenario %d ppo_%s: viol=%.0fs integral=%.4f vmin=%.4f vmax=%.4f "
492
+ "tput=%.1f p99_lat=%.3fs power=%.1fkW batch_chg=%d",
493
+ scenario_idx,
494
+ label,
495
+ vstats.violation_time_s,
496
+ vstats.integral_violation_pu_s,
497
+ vstats.worst_vmin,
498
+ vstats.worst_vmax,
499
+ perf["mean_throughput_toks_s"],
500
+ perf["p99_latency_s"],
501
+ perf["mean_power_kw"],
502
+ perf["batch_changes"],
503
+ )
504
+
505
+ if not no_per_scenario_plots:
506
+ plot_voltage_comparison(
507
+ all_logs,
508
+ sc_save,
509
+ v_min=V_MIN,
510
+ v_max=V_MAX,
511
+ exclude_buses=exclude_buses,
512
+ scenario_idx=scenario_idx,
513
+ use_display_names=use_display_names,
514
+ )
515
+ plot_batch_comparison(all_logs, sc_save, scenario_idx=scenario_idx, use_display_names=use_display_names)
516
+ plot_violation_bars(results, sc_save, scenario_idx=scenario_idx, use_display_names=use_display_names)
517
+
518
+ return results
519
+
520
+
521
+ def main(
522
+ *,
523
+ ppo_models: tuple[str, ...] = (),
524
+ ppo_labels: tuple[str, ...] = (),
525
+ system: str = "ieee13",
526
+ n_scenarios: int = 10,
527
+ seed_start: int = 500,
528
+ output_dir: str = "",
529
+ obs_mode: str = "full-voltage",
530
+ min_baseline_integral: float = 0.2,
531
+ min_recovery_frac: float = 0.7,
532
+ ofo_w_throughputs: tuple[float, ...] = (),
533
+ ofo_w_switches: tuple[float, ...] = (),
534
+ ofo_primal_steps: tuple[float, ...] = (),
535
+ ofo_extra_variants: tuple[str, ...] = (),
536
+ no_default_ofo: bool = False,
537
+ include_rule_based: bool = False,
538
+ rule_step_sizes: tuple[float, ...] = (10.0,),
539
+ rule_zone_local: bool = False,
540
+ no_per_scenario_plots: bool = False,
541
+ no_aggregate_plots: bool = False,
542
+ randomize_ramps: bool = True,
543
+ scenario_library: str = "",
544
+ use_display_names: bool = False,
545
+ log_level: str = "INFO",
546
+ ) -> None:
547
+ logging.basicConfig(
548
+ level=getattr(logging, log_level),
549
+ format="%(levelname)s %(asctime)s [%(name)s:%(lineno)d] %(message)s",
550
+ datefmt="%H:%M:%S",
551
+ )
552
+ logging.getLogger("httpx").setLevel(logging.WARNING)
553
+ logging.getLogger("openg2g.coordinator").setLevel(logging.WARNING)
554
+ logging.getLogger("openg2g.datacenter").setLevel(logging.WARNING)
555
+ logging.getLogger("openg2g.grid").setLevel(logging.WARNING)
556
+ logging.getLogger("openg2g.controller.ofo").setLevel(logging.WARNING)
557
+
558
+ ppo_models_resolved = [str(Path(p).resolve()) for p in ppo_models]
559
+
560
+ if not ppo_labels:
561
+ ppo_labels = tuple(str(i) for i in range(len(ppo_models)))
562
+
563
+ if system not in EXPERIMENTS:
564
+ raise ValueError(f"Unknown system {system!r}. Valid: {sorted(EXPERIMENTS)}")
565
+
566
+ training_trace = TrainingTrace.ensure(TRAINING_TRACE_PATH)
567
+
568
+ exp = EXPERIMENTS[system](training_trace=training_trace)
569
+ all_models = []
570
+ for site in exp["dc_sites"].values():
571
+ # site.models is now tuple[(ModelDeployment, ReplicaSchedule), ...]
572
+ all_models.extend(md for md, _ in site.models)
573
+ all_specs = tuple(m.spec for m in all_models)
574
+
575
+ inference_data = InferenceData.ensure(
576
+ SPECS_CACHE_DIR,
577
+ all_specs,
578
+ plot=False,
579
+ dt_s=float(DT_DC),
580
+ )
581
+ logistic_models = LogisticModelStore.ensure(
582
+ SPECS_CACHE_DIR,
583
+ all_specs,
584
+ plot=False,
585
+ )
586
+
587
+ base_ofo = exp["ofo_config"]
588
+ ofo_variants: list[tuple[str, OFOConfig]] = []
589
+
590
+ def _w_tag(w: float) -> str:
591
+ if w == 0:
592
+ return "w0"
593
+ if w >= 1e-3:
594
+ return f"w{w:g}".replace(".", "p")
595
+ return f"w{w:.0e}".replace("-0", "-")
596
+
597
+ def _fmt(v: float) -> str:
598
+ return f"{v:g}".replace(".", "p")
599
+
600
+ if ofo_w_throughputs or ofo_w_switches or ofo_primal_steps:
601
+ w_list = ofo_w_throughputs or (base_ofo.w_throughput,)
602
+ s_list = ofo_w_switches or (base_ofo.w_switch,)
603
+ p_list = ofo_primal_steps or (base_ofo.primal_step_size,)
604
+ for w in w_list:
605
+ for s in s_list:
606
+ for p in p_list:
607
+ overrides: dict = {}
608
+ parts: list[str] = []
609
+ if ofo_w_throughputs:
610
+ overrides["w_throughput"] = float(w)
611
+ parts.append(_w_tag(float(w)))
612
+ if ofo_w_switches:
613
+ overrides["w_switch"] = float(s)
614
+ parts.append(f"ws{_fmt(float(s))}")
615
+ if ofo_primal_steps:
616
+ overrides["primal_step_size"] = float(p)
617
+ parts.append(f"ps{_fmt(float(p))}")
618
+ cfg = base_ofo.model_copy(update=overrides)
619
+ label = "_".join(parts) if parts else "variant"
620
+ ofo_variants.append((label, cfg))
621
+
622
+ for spec in ofo_extra_variants:
623
+ overrides: dict = {}
624
+ parts: list[str] = []
625
+ for kv in spec.split(","):
626
+ k, _, v = kv.strip().partition("=")
627
+ if not k:
628
+ continue
629
+ fv = float(v)
630
+ overrides[k] = fv
631
+ if k == "w_throughput":
632
+ parts.append(_w_tag(fv))
633
+ elif k == "w_switch":
634
+ parts.append(f"ws{_fmt(fv)}")
635
+ elif k == "primal_step_size":
636
+ parts.append(f"ps{_fmt(fv)}")
637
+ else:
638
+ parts.append(f"{k}{_fmt(fv)}")
639
+ cfg = base_ofo.model_copy(update=overrides)
640
+ ofo_variants.append(("_".join(parts) if parts else "extra", cfg))
641
+
642
+ if ofo_variants:
643
+ logger.info(
644
+ "OFO variants (%d): %s",
645
+ len(ofo_variants),
646
+ [(lbl, c.w_throughput, c.w_switch, c.primal_step_size) for lbl, c in ofo_variants],
647
+ )
648
+
649
+ save_dir = (
650
+ Path(__file__).resolve().parent
651
+ / "outputs"
652
+ / system
653
+ / (output_dir or f"eval_multi_seed{seed_start}_n{n_scenarios}")
654
+ )
655
+ save_dir.mkdir(parents=True, exist_ok=True)
656
+
657
+ if scenario_library:
658
+ test_scenarios = load_scenarios_from_library(
659
+ scenario_library,
660
+ n_scenarios=n_scenarios,
661
+ training_trace=training_trace,
662
+ )
663
+ else:
664
+ test_scenarios = generate_test_scenarios(
665
+ exp,
666
+ inference_data=inference_data,
667
+ training_trace=training_trace,
668
+ logistic_models=logistic_models,
669
+ n_scenarios=n_scenarios,
670
+ seed_start=seed_start,
671
+ min_baseline_integral=min_baseline_integral,
672
+ min_recovery_frac=min_recovery_frac,
673
+ save_dir=save_dir,
674
+ randomize_ramps=randomize_ramps,
675
+ )
676
+
677
+ all_results = []
678
+ scenario_params = []
679
+ modes_set = set()
680
+
681
+ for i, scenario in enumerate(test_scenarios):
682
+ params = scenario["params"]
683
+ logger.info("")
684
+ logger.info("=" * 70)
685
+ logger.info(
686
+ "SCENARIO %d/%d: seed=%d pv_scale=%.2f load_scale=%.2f",
687
+ i + 1,
688
+ n_scenarios,
689
+ scenario["seed"],
690
+ params["pv_scale"],
691
+ params["load_scale"],
692
+ )
693
+ if params["training_overlay"]:
694
+ to = params["training_overlay"]
695
+ logger.info(
696
+ " training: t=[%.0f, %.0f] n_gpus=%d",
697
+ to["t_start"],
698
+ to["t_end"],
699
+ to["n_gpus"],
700
+ )
701
+ logger.info("=" * 70)
702
+
703
+ results = run_scenario(
704
+ scenario,
705
+ exp=exp,
706
+ inference_data=inference_data,
707
+ training_trace=training_trace,
708
+ logistic_models=logistic_models,
709
+ ppo_models=ppo_models_resolved,
710
+ ppo_labels=list(ppo_labels),
711
+ save_dir=save_dir,
712
+ scenario_idx=i,
713
+ obs_mode=obs_mode,
714
+ ofo_variants=ofo_variants,
715
+ include_rule_based=include_rule_based,
716
+ rule_step_sizes=rule_step_sizes,
717
+ rule_zone_local=rule_zone_local,
718
+ no_per_scenario_plots=no_per_scenario_plots,
719
+ no_default_ofo=no_default_ofo,
720
+ use_display_names=use_display_names,
721
+ )
722
+ all_results.append(results)
723
+ scenario_params.append(params)
724
+ modes_set.update(results.keys())
725
+
726
+ rb_labels = ["rule_based"] if len(rule_step_sizes) == 1 else [f"rule_based_s{s:g}" for s in rule_step_sizes]
727
+ mode_order = (
728
+ ["baseline_no_tap"]
729
+ + rb_labels
730
+ + [f"ppo_{lbl}" for lbl in ppo_labels]
731
+ + ["ofo"]
732
+ + [f"ofo_{label}" for label, _ in ofo_variants]
733
+ )
734
+ modes = [m for m in mode_order if m in modes_set]
735
+
736
+ logger.info("")
737
+ logger.info("=" * 90)
738
+ logger.info("AGGREGATE RESULTS (%d scenarios)", n_scenarios)
739
+ logger.info("=" * 90)
740
+ header = (
741
+ f"{'Mode':<20s} {'Viol(s)':>10s} {'±':>8s} {'Integral':>10s} {'±':>8s} "
742
+ f"{'Worst Vmin':>12s} {'Worst Vmax':>12s} {'Batch Δ':>10s} {'±':>8s}"
743
+ )
744
+ logger.info(header)
745
+ logger.info("-" * 104)
746
+
747
+ for mode in modes:
748
+ viol = [r[mode]["violation_time_s"] for r in all_results if mode in r]
749
+ intg = [r[mode]["integral"] for r in all_results if mode in r]
750
+ vmin = [r[mode]["worst_vmin"] for r in all_results if mode in r]
751
+ vmax = [r[mode]["worst_vmax"] for r in all_results if mode in r]
752
+ bchg = [r[mode].get("batch_changes", 0) for r in all_results if mode in r]
753
+ if viol:
754
+ logger.info(
755
+ "%-20s %10.1f %8.1f %10.4f %8.4f %12.4f %12.4f %10.1f %8.1f",
756
+ mode,
757
+ np.mean(viol),
758
+ np.std(viol),
759
+ np.mean(intg),
760
+ np.std(intg),
761
+ np.mean(vmin),
762
+ np.mean(vmax),
763
+ np.mean(bchg),
764
+ np.std(bchg),
765
+ )
766
+
767
+ extra_cols = [
768
+ "mean_throughput_toks_s",
769
+ "peak_throughput_toks_s",
770
+ "mean_latency_s",
771
+ "p99_latency_s",
772
+ "mean_power_kw",
773
+ "peak_power_kw",
774
+ "batch_changes",
775
+ "itl_violation_rate",
776
+ ]
777
+ csv_path = save_dir / "results.csv"
778
+ with open(csv_path, "w", newline="") as f:
779
+ writer = csv.writer(f)
780
+ writer.writerow(
781
+ [
782
+ "scenario",
783
+ "seed",
784
+ "pv_scale",
785
+ "load_scale",
786
+ "mode",
787
+ "violation_time_s",
788
+ "integral",
789
+ "worst_vmin",
790
+ "worst_vmax",
791
+ *extra_cols,
792
+ ]
793
+ )
794
+ for i, (results, params) in enumerate(zip(all_results, scenario_params, strict=False)):
795
+ for mode, stats in results.items():
796
+ sc = test_scenarios[i]
797
+ writer.writerow(
798
+ [
799
+ i,
800
+ sc["seed"],
801
+ params["pv_scale"],
802
+ params["load_scale"],
803
+ mode,
804
+ stats["violation_time_s"],
805
+ stats["integral"],
806
+ stats["worst_vmin"],
807
+ stats["worst_vmax"],
808
+ *[stats.get(k, "") for k in extra_cols],
809
+ ]
810
+ )
811
+ logger.info("Results CSV: %s", csv_path)
812
+
813
+ if not no_aggregate_plots:
814
+ plot_aggregate(
815
+ all_results, scenario_params, save_dir, modes, system=system, use_display_names=use_display_names
816
+ )
817
+ logger.info("All outputs saved to: %s", save_dir)
818
+
819
+
820
+ if __name__ == "__main__":
821
+ import tyro
822
+
823
+ @dataclass
824
+ class Args:
825
+ ppo_models: tuple[str, ...] = ()
826
+ """Paths to trained PPO model .zip files. Empty = run only baseline + OFO variants."""
827
+ ppo_labels: tuple[str, ...] = ()
828
+ """Legend labels (one per model). Defaults to parent dir names."""
829
+ system: str = "ieee13"
830
+ """Which feeder experiment to use. Valid: ieee13, ieee34, ieee123."""
831
+ n_scenarios: int = 10
832
+ """Number of held-out scenarios to evaluate on."""
833
+ seed_start: int = 500
834
+ """Starting seed offset for test scenarios (seeds = seed_start*1000+7, ...)."""
835
+ output_dir: str = ""
836
+ """Output directory name under outputs/<system>/. Auto-generated if empty."""
837
+ obs_mode: str = "full-voltage"
838
+ """Observation mode used during PPO training: full-voltage, per-zone-summary, per-bus-summary, or system-summary-only.""" # noqa: E501
839
+ min_baseline_integral: float = 0.2
840
+ """Minimum baseline_no_tap integral (pu*s) for a scenario seed to be accepted."""
841
+ min_recovery_frac: float = 0.7
842
+ """Minimum (base-ofo)/base recovery fraction for a seed to be accepted."""
843
+ ofo_w_throughputs: tuple[float, ...] = ()
844
+ """Extra OFO variants, given as throughput weight values (e.g. 0.0001 0.00001 0)."""
845
+ ofo_w_switches: tuple[float, ...] = ()
846
+ """Extra OFO variants, given as switching-cost weight values (e.g. 1.0 3.0 10.0)."""
847
+ ofo_primal_steps: tuple[float, ...] = ()
848
+ """Extra OFO variants, given as primal-step-size values (e.g. 0.02 0.05 0.1)."""
849
+ ofo_extra_variants: tuple[str, ...] = ()
850
+ """Explicit OFO variants as 'k=v,k=v' (e.g. 'w_throughput=0,w_switch=1.0')."""
851
+ no_default_ofo: bool = False
852
+ """Skip emitting the default OFO as 'ofo' in results. Filter/cache path still uses it."""
853
+ include_rule_based: bool = False
854
+ """Also evaluate the rule-based controller."""
855
+ rule_step_sizes: tuple[float, ...] = (10.0,)
856
+ """Step size(s) for the rule-based controller."""
857
+ rule_zone_local: bool = False
858
+ """When True AND sys defines `zones` AND there are >1 DC sites, each rule-based controller observes only buses in its own zone (decentralized credit assignment for ieee123).""" # noqa: E501
859
+ no_per_scenario_plots: bool = False
860
+ """Skip per-scenario voltage and batch plots (saves disk + time)."""
861
+ no_aggregate_plots: bool = False
862
+ """Skip aggregate comparison and per-scenario integral plots."""
863
+ randomize_ramps: bool = True
864
+ """Synthesize per-episode inference ramps. Set --no-randomize-ramps for ieee34."""
865
+ scenario_library: str = ""
866
+ """Path to a pre-screened scenario library directory (from build_library.py)."""
867
+ use_display_names: bool = False
868
+ """Use human-readable display names in all plots."""
869
+ log_level: str = "INFO"
870
+ """Logging verbosity."""
871
+
872
+ args = tyro.cli(Args)
873
+ main(
874
+ ppo_models=args.ppo_models,
875
+ ppo_labels=args.ppo_labels,
876
+ system=args.system,
877
+ n_scenarios=args.n_scenarios,
878
+ seed_start=args.seed_start,
879
+ output_dir=args.output_dir,
880
+ obs_mode=args.obs_mode,
881
+ min_baseline_integral=args.min_baseline_integral,
882
+ min_recovery_frac=args.min_recovery_frac,
883
+ ofo_w_throughputs=args.ofo_w_throughputs,
884
+ ofo_w_switches=args.ofo_w_switches,
885
+ ofo_primal_steps=args.ofo_primal_steps,
886
+ ofo_extra_variants=args.ofo_extra_variants,
887
+ no_default_ofo=args.no_default_ofo,
888
+ include_rule_based=args.include_rule_based,
889
+ rule_step_sizes=args.rule_step_sizes,
890
+ rule_zone_local=args.rule_zone_local,
891
+ no_per_scenario_plots=args.no_per_scenario_plots,
892
+ no_aggregate_plots=args.no_aggregate_plots,
893
+ randomize_ramps=args.randomize_ramps,
894
+ scenario_library=args.scenario_library,
895
+ use_display_names=args.use_display_names,
896
+ log_level=args.log_level,
897
+ )
examples/rl_controller/plotting.py ADDED
@@ -0,0 +1,723 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Plotting helpers for the RL pipeline.
2
+
3
+ Used by `build_library.py` (per-scenario voltage envelopes, batch traces,
4
+ acceptance summary across the library) and `evaluate.py` (per-scenario
5
+ controller comparison plots and aggregate roll-up across the test set).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import math
12
+ from pathlib import Path
13
+
14
+ import matplotlib
15
+
16
+ matplotlib.use("Agg")
17
+ import matplotlib.pyplot as plt
18
+ import numpy as np
19
+ from scenarios import ScenarioRecord
20
+
21
+ from systems import V_MAX, V_MIN
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ DISPLAY_NAMES: dict[str, str] = {
27
+ "baseline_no_tap": "No Control",
28
+ "rule_based": "Droop Control",
29
+ "ofo": "OFO Control",
30
+ }
31
+
32
+
33
+ def _display_order_key(mode: str) -> int:
34
+ if mode == "baseline_no_tap":
35
+ return 0
36
+ if mode == "rule_based" or mode.startswith("rule_based_s"):
37
+ return 1
38
+ if mode.startswith("ppo_"):
39
+ return 2
40
+ if mode == "ofo" or mode.startswith("ofo_"):
41
+ return 3
42
+ return 99
43
+
44
+
45
+ def _sort_modes(modes: list[str]) -> list[str]:
46
+ return sorted(modes, key=_display_order_key)
47
+
48
+
49
+ def _display_name(mode: str) -> str:
50
+ if mode in DISPLAY_NAMES:
51
+ return DISPLAY_NAMES[mode]
52
+ if mode.startswith("rule_based_s"):
53
+ return "Droop Control"
54
+ if mode.startswith("ppo_"):
55
+ return "PPO Control"
56
+ return mode.replace("_", " ").title()
57
+
58
+
59
+ def _voltage_envelope(grid_states, *, exclude_buses: tuple[str, ...]) -> tuple[np.ndarray, np.ndarray]:
60
+ """Return (vmin_t, vmax_t) per step for plotting."""
61
+ drop = {b.lower() for b in exclude_buses}
62
+ vmin = np.full(len(grid_states), np.inf)
63
+ vmax = np.full(len(grid_states), -np.inf)
64
+ for i, gs in enumerate(grid_states):
65
+ for bus in gs.voltages.buses():
66
+ if bus.lower() in drop:
67
+ continue
68
+ pv = gs.voltages[bus]
69
+ for v in (pv.a, pv.b, pv.c):
70
+ if math.isnan(v):
71
+ continue
72
+ if v < vmin[i]:
73
+ vmin[i] = v
74
+ if v > vmax[i]:
75
+ vmax[i] = v
76
+ return vmin, vmax
77
+
78
+
79
+ def _voltage_envelope_by_zone(
80
+ grid_states,
81
+ *,
82
+ zones: dict[str, list[str]],
83
+ exclude_buses: tuple[str, ...],
84
+ ) -> dict[str, tuple[np.ndarray, np.ndarray]]:
85
+ """Return {zone_name: (vmin_t, vmax_t)} per step, one array pair per zone."""
86
+ drop = {b.lower() for b in exclude_buses}
87
+ zone_sets = {z: {b.lower() for b in buses} for z, buses in zones.items()}
88
+ n = len(grid_states)
89
+ vmin = {z: np.full(n, np.inf) for z in zones}
90
+ vmax = {z: np.full(n, -np.inf) for z in zones}
91
+ for i, gs in enumerate(grid_states):
92
+ for bus in gs.voltages.buses():
93
+ bl = bus.lower()
94
+ if bl in drop:
95
+ continue
96
+ pv = gs.voltages[bus]
97
+ for z, bset in zone_sets.items():
98
+ if bl not in bset:
99
+ continue
100
+ for v in (pv.a, pv.b, pv.c):
101
+ if math.isnan(v):
102
+ continue
103
+ if v < vmin[z][i]:
104
+ vmin[z][i] = v
105
+ if v > vmax[z][i]:
106
+ vmax[z][i] = v
107
+ return {z: (vmin[z], vmax[z]) for z in zones}
108
+
109
+
110
+ def _plot_batch_sizes(
111
+ records: list[ScenarioRecord],
112
+ batch_data: dict[int, dict],
113
+ save_path: Path,
114
+ *,
115
+ max_rows: int = 40,
116
+ ) -> None:
117
+ """Plot batch size over time per accepted scenario, baseline vs OFO, one row per scenario.
118
+
119
+ When the library has more than `max_rows` scenarios, only the first
120
+ `max_rows` are shown. A single tall figure of hundreds of rows quickly
121
+ exceeds matplotlib's 65535-pixel dimension limit, so we cap here.
122
+ """
123
+ n = len(records)
124
+ if n == 0:
125
+ return
126
+ if n > max_rows:
127
+ logger.info("_plot_batch_sizes: capping at first %d of %d records", max_rows, n)
128
+ records = records[:max_rows]
129
+ n = max_rows
130
+
131
+ # Collect all (site_id, label) columns from the first scenario. For
132
+ # single-DC feeders (ieee13) there's one site; multi-DC feeders
133
+ # (ieee34) get one column per (site, model) pair.
134
+ first_seed = records[0].seed
135
+ ofo_by_site = batch_data[first_seed]["ofo"]
136
+ cols_meta: list[tuple[str, str]] = []
137
+ for site_id, sdata in ofo_by_site.items():
138
+ for label in sdata["batch_by_model"]:
139
+ cols_meta.append((site_id, label))
140
+ n_cols = len(cols_meta)
141
+
142
+ fig, axes = plt.subplots(n, n_cols, figsize=(4 * n_cols, 2.5 * n), sharex=True, squeeze=False)
143
+
144
+ for row, rec in enumerate(records):
145
+ bd = batch_data[rec.seed]
146
+ for col, (site_id, label) in enumerate(cols_meta):
147
+ ax = axes[row][col]
148
+ bl_site = bd["baseline"][site_id]
149
+ ofo_site = bd["ofo"][site_id]
150
+ ax.plot(
151
+ bl_site["time_s"],
152
+ bl_site["batch_by_model"][label],
153
+ color="#888",
154
+ linewidth=0.7,
155
+ alpha=0.7,
156
+ label="baseline",
157
+ )
158
+ ax.plot(
159
+ ofo_site["time_s"],
160
+ ofo_site["batch_by_model"][label],
161
+ color="#2196F3",
162
+ linewidth=0.7,
163
+ alpha=0.9,
164
+ label="OFO",
165
+ )
166
+ if row == 0:
167
+ short = label.split("/")[-1] if "/" in label else label
168
+ title = f"{site_id}:{short}" if len(ofo_by_site) > 1 else short
169
+ ax.set_title(title, fontsize=9)
170
+ if col == 0:
171
+ ax.set_ylabel(f"seed={rec.seed}\nBatch", fontsize=8)
172
+ ax.grid(True, alpha=0.2)
173
+ if row == 0 and col == 0:
174
+ ax.legend(fontsize=7, loc="upper right")
175
+
176
+ for col in range(n_cols):
177
+ axes[-1][col].set_xlabel("Time (s)")
178
+ fig.suptitle("Accepted scenarios: batch size (baseline vs OFO)", fontsize=13, fontweight="bold")
179
+ fig.tight_layout()
180
+ fig.savefig(save_path, dpi=120, bbox_inches="tight")
181
+ plt.close(fig)
182
+
183
+
184
+ def _extract_batch_data(log) -> dict:
185
+ """Extract time_s and per-model batch sizes from a simulation log.
186
+
187
+ Returns {site_id: {"time_s": [...], "batch_by_model": {label: [bs]}}}.
188
+ Uses `log.dc_states_by_site` (per-site lists) so multi-DC feeders
189
+ like ieee34 don't get interleaved timestamps or alternating zeros.
190
+ """
191
+ per_site: dict[str, dict] = {}
192
+ for site_id, states in log.dc_states_by_site.items():
193
+ time_s = [s.time_s for s in states]
194
+ labels: list[str] = []
195
+ if states:
196
+ for m in states[0].batch_size_by_model:
197
+ if m not in labels:
198
+ labels.append(m)
199
+ batch_by_model = {m: [s.batch_size_by_model.get(m, 0) for s in states] for m in labels}
200
+ per_site[site_id] = {"time_s": time_s, "batch_by_model": batch_by_model}
201
+ return per_site
202
+
203
+
204
+ def _plot_envelopes(
205
+ records: list[ScenarioRecord],
206
+ envelopes: dict,
207
+ save_path: Path,
208
+ *,
209
+ total_duration_s: int,
210
+ zones: dict[str, list[str]] | None = None,
211
+ max_rows: int = 40,
212
+ ) -> None:
213
+ """Plot voltage envelope per accepted scenario, baseline vs OFO.
214
+
215
+ When `zones` is provided (multi-zone feeders like ieee123), each scenario
216
+ gets one subplot per zone showing the per-zone vmin/vmax band. Otherwise a
217
+ single subplot with the global envelope is used.
218
+
219
+ Caps at `max_rows * 2` records (global mode) or `max_rows` records
220
+ (per-zone mode) to stay under matplotlib's 65535-pixel dimension limit.
221
+ """
222
+ n = len(records)
223
+ if n == 0:
224
+ return
225
+
226
+ t = np.arange(total_duration_s)
227
+
228
+ if zones:
229
+ zone_names = list(zones.keys())
230
+ n_zones = len(zone_names)
231
+ cap = max_rows
232
+ if n > cap:
233
+ logger.info("_plot_envelopes: capping at first %d of %d records", cap, n)
234
+ records = records[:cap]
235
+ n = cap
236
+ zone_colors = ["#2196F3", "#4CAF50", "#FF9800", "#9C27B0"]
237
+ fig, axes = plt.subplots(n, n_zones, figsize=(5 * n_zones, 3 * n), sharex=True, squeeze=False)
238
+ for row, rec in enumerate(records):
239
+ env = envelopes[rec.seed]
240
+ for col, z in enumerate(zone_names):
241
+ ax = axes[row][col]
242
+ bl_z = env["baseline_zones"].get(z)
243
+ of_z = env["ofo_zones"].get(z)
244
+ color = zone_colors[col % len(zone_colors)]
245
+ if bl_z is not None:
246
+ ax.fill_between(t, bl_z[0], bl_z[1], alpha=0.25, color="#888", label="baseline")
247
+ if of_z is not None:
248
+ ax.fill_between(t, of_z[0], of_z[1], alpha=0.4, color=color, label="OFO")
249
+ ax.axhline(V_MIN, color="red", linestyle="--", linewidth=0.8, alpha=0.6)
250
+ ax.axhline(V_MAX, color="red", linestyle="--", linewidth=0.8, alpha=0.6)
251
+ ax.grid(True, alpha=0.2)
252
+ if row == 0:
253
+ ax.set_title(z, fontsize=10, fontweight="bold")
254
+ if col == 0:
255
+ ax.set_ylabel(
256
+ f"seed={rec.seed}\npv×{rec.pv_scale:.2f} ld×{rec.load_scale:.2f}\n"
257
+ f"bl={rec.baseline_integral:.1f} ofo={rec.ofo_integral:.1f} "
258
+ f"rec={rec.recovery_frac:.0%}",
259
+ fontsize=7,
260
+ )
261
+ else:
262
+ ax.set_ylabel("V (pu)", fontsize=8)
263
+ if row == 0 and col == 0:
264
+ ax.legend(loc="lower right", fontsize=7)
265
+ for col in range(n_zones):
266
+ axes[-1][col].set_xlabel("Time (s)", fontsize=8)
267
+ fig.suptitle(
268
+ "Accepted scenarios: per-zone voltage envelope (baseline vs OFO)",
269
+ fontsize=13,
270
+ fontweight="bold",
271
+ )
272
+ else:
273
+ cap = max_rows * 2
274
+ if n > cap:
275
+ logger.info("_plot_envelopes: capping at first %d of %d records", cap, n)
276
+ records = records[:cap]
277
+ n = cap
278
+ cols = 2
279
+ rows = (n + cols - 1) // cols
280
+ fig, axes = plt.subplots(rows, cols, figsize=(7 * cols, 3 * rows), sharex=True)
281
+ axes = np.atleast_2d(axes)
282
+
283
+ for idx, rec in enumerate(records):
284
+ r, c = divmod(idx, cols)
285
+ ax = axes[r][c]
286
+ bmin, bmax = envelopes[rec.seed]["baseline"]
287
+ omin, omax = envelopes[rec.seed]["ofo"]
288
+ ax.fill_between(t, bmin, bmax, alpha=0.25, color="#888", label="baseline")
289
+ ax.fill_between(t, omin, omax, alpha=0.4, color="#2196F3", label="OFO")
290
+ ax.axhline(V_MIN, color="red", linestyle="--", linewidth=1, alpha=0.6)
291
+ ax.axhline(V_MAX, color="red", linestyle="--", linewidth=1, alpha=0.6)
292
+ ax.set_title(
293
+ f"seed={rec.seed} pv×{rec.pv_scale:.2f} load×{rec.load_scale:.2f}\n"
294
+ f"int: bl={rec.baseline_integral:.2f} ofo={rec.ofo_integral:.2f} "
295
+ f"recov={rec.recovery_frac:.0%}",
296
+ fontsize=9,
297
+ )
298
+ ax.set_ylabel("V (pu)", fontsize=9)
299
+ ax.grid(True, alpha=0.2)
300
+ if idx == 0:
301
+ ax.legend(loc="lower right", fontsize=8)
302
+
303
+ for k in range(n, rows * cols):
304
+ r, c = divmod(k, cols)
305
+ axes[r][c].axis("off")
306
+
307
+ for c in range(cols):
308
+ axes[-1][c].set_xlabel("Time (s)")
309
+ fig.suptitle("Accepted scenarios: voltage envelope (baseline vs OFO)", fontsize=13, fontweight="bold")
310
+
311
+ fig.tight_layout()
312
+ fig.savefig(save_path, dpi=120, bbox_inches="tight")
313
+ plt.close(fig)
314
+
315
+
316
+ def _plot_summary(all_stats: list[dict], save_path: Path) -> None:
317
+ """Bar chart of baseline vs OFO integral for every candidate (accepted + rejected)."""
318
+ n = len(all_stats)
319
+ if n == 0:
320
+ return
321
+ seeds = [s["seed"] for s in all_stats]
322
+ bl = [s["baseline_integral"] for s in all_stats]
323
+ of = [s["ofo_integral"] for s in all_stats]
324
+ accepted = [s["accepted"] for s in all_stats]
325
+
326
+ x = np.arange(n)
327
+ w = 0.4
328
+ fig, ax = plt.subplots(figsize=(max(8, 0.7 * n), 5))
329
+ ax.bar(x - w / 2, bl, w, color="#888", label="baseline integral")
330
+ ax.bar(x + w / 2, of, w, color="#2196F3", label="OFO integral")
331
+ for i, ok in enumerate(accepted):
332
+ marker = "✓" if ok else "✗"
333
+ color = "green" if ok else "red"
334
+ ax.annotate(marker, xy=(i, max(bl[i], of[i])), ha="center", va="bottom", color=color, fontsize=12)
335
+ ax.set_xticks(x)
336
+ ax.set_xticklabels([str(s) for s in seeds], rotation=45)
337
+ ax.set_xlabel("Seed")
338
+ ax.set_ylabel("Integral voltage violation (pu·s)")
339
+ ax.set_yscale("symlog", linthresh=0.1)
340
+ ax.set_title("Candidate scenarios: baseline vs OFO integral violation")
341
+ ax.legend()
342
+ ax.grid(True, axis="y", alpha=0.2)
343
+ fig.tight_layout()
344
+ fig.savefig(save_path, dpi=120, bbox_inches="tight")
345
+ plt.close(fig)
346
+
347
+
348
+ def plot_voltage_comparison(
349
+ logs: dict[str, object],
350
+ save_dir: Path,
351
+ *,
352
+ v_min: float = 0.95,
353
+ v_max: float = 1.05,
354
+ exclude_buses: tuple[str, ...] = (),
355
+ scenario_idx: int | None = None,
356
+ use_display_names: bool = False,
357
+ ) -> None:
358
+ """Side-by-side voltage envelopes for each controller mode."""
359
+ modes = _sort_modes(list(logs.keys()))
360
+ n = len(modes)
361
+ fig, axes = plt.subplots(1, n, figsize=(6 * n, 5), sharey=True)
362
+ if n == 1:
363
+ axes = [axes]
364
+
365
+ drop = {b.lower() for b in exclude_buses}
366
+
367
+ for ax, mode in zip(axes, modes, strict=False):
368
+ log = logs[mode]
369
+ time_s = np.array(log.time_s)
370
+
371
+ v_min_arr = np.full(len(log.grid_states), np.inf)
372
+ v_max_arr = np.full(len(log.grid_states), -np.inf)
373
+
374
+ for t_idx, gs in enumerate(log.grid_states):
375
+ for bus in gs.voltages.buses():
376
+ if bus.lower() in drop:
377
+ continue
378
+ pv = gs.voltages[bus]
379
+ for v in (pv.a, pv.b, pv.c):
380
+ if not math.isnan(v):
381
+ v_min_arr[t_idx] = min(v_min_arr[t_idx], v)
382
+ v_max_arr[t_idx] = max(v_max_arr[t_idx], v)
383
+
384
+ ax.fill_between(time_s, v_min_arr, v_max_arr, alpha=0.3, color="steelblue")
385
+ ax.plot(time_s, v_min_arr, color="steelblue", linewidth=0.5, label="Vmin")
386
+ ax.plot(time_s, v_max_arr, color="coral", linewidth=0.5, label="Vmax")
387
+ ax.axhline(v_min, color="red", linestyle="--", linewidth=1, alpha=0.7)
388
+ ax.axhline(v_max, color="red", linestyle="--", linewidth=1, alpha=0.7)
389
+ ax.set_xlabel("Time (s)", fontsize=13)
390
+ ax.set_title(_display_name(mode) if use_display_names else mode, fontsize=14)
391
+ ax.legend(fontsize=12)
392
+ ax.tick_params(labelsize=12)
393
+ ax.grid(True, alpha=0.2)
394
+
395
+ axes[0].set_ylabel("Voltage (pu)", fontsize=13)
396
+ fig.suptitle("Voltage Envelope Comparison", fontsize=16, fontweight="bold")
397
+ fig.tight_layout()
398
+ stem = f"scenario_{scenario_idx:03d}_voltage_comparison" if scenario_idx is not None else "voltage_comparison"
399
+ fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight")
400
+ plt.close(fig)
401
+ logger.info("Saved %s.png", stem)
402
+
403
+
404
+ def plot_violation_bars(
405
+ results: dict[str, dict],
406
+ save_dir: Path,
407
+ *,
408
+ scenario_idx: int | None = None,
409
+ use_display_names: bool = False,
410
+ ) -> None:
411
+ """Four-panel bar chart for a single scenario:
412
+ violation time, integral violation, mean throughput, batch size changes.
413
+ """
414
+ modes = _sort_modes(list(results.keys()))
415
+ if not modes:
416
+ return
417
+
418
+ cmap = plt.get_cmap("tab10")
419
+ colors = [cmap(i % 10) for i in range(len(modes))]
420
+
421
+ viol_s = [float(results[m].get("violation_time_s", 0.0)) for m in modes]
422
+ integ = [float(results[m].get("integral", 0.0)) for m in modes]
423
+ tput = [float(results[m].get("mean_throughput_toks_s", 0.0)) for m in modes]
424
+ batch_chg = [float(results[m].get("batch_changes", 0.0)) for m in modes]
425
+ labels = [_display_name(m) if use_display_names else m for m in modes]
426
+
427
+ fig, axes = plt.subplots(1, 4, figsize=(max(18, 2.0 * len(modes) + 12), 6))
428
+ ax_v, ax_i, ax_t, ax_b = axes
429
+
430
+ x = np.arange(len(modes))
431
+ for ax, vals, ylabel, title, fmt in [
432
+ (ax_v, viol_s, "Violation time (s)", "Violation time", "{:.0f}"),
433
+ (ax_i, integ, "Integral violation (pu·s)", "Integral violation", "{:.2f}"),
434
+ (ax_t, tput, "Throughput (tok/s)", "Mean throughput", "{:.2e}"),
435
+ (ax_b, batch_chg, "Batch size changes", "Batch size changes", "{:.0f}"),
436
+ ]:
437
+ ax.bar(x, vals, color=colors, alpha=0.88, edgecolor="black", linewidth=0.5)
438
+ ax.set_xticks(x)
439
+ ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=12)
440
+ ax.set_ylabel(ylabel, fontsize=13)
441
+ ax.set_title(title, fontsize=14)
442
+ ax.tick_params(axis="y", labelsize=12)
443
+ ax.grid(axis="y", alpha=0.3)
444
+ for xi, val in zip(x, vals, strict=False):
445
+ ax.text(xi, val, fmt.format(val), ha="center", va="bottom", fontsize=10)
446
+
447
+ fig.suptitle("Per-scenario controller metrics", fontsize=16, fontweight="bold")
448
+ fig.tight_layout()
449
+ stem = f"scenario_{scenario_idx:03d}_performance_summary" if scenario_idx is not None else "violation_bars"
450
+ fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight")
451
+ plt.close(fig)
452
+ logger.info("Saved %s.png", stem)
453
+
454
+
455
+ def plot_batch_comparison(
456
+ logs: dict[str, object],
457
+ save_dir: Path,
458
+ *,
459
+ scenario_idx: int | None = None,
460
+ use_display_names: bool = False,
461
+ ) -> None:
462
+ """Batch size over time for each controller mode, one subplot per (site, model) pair."""
463
+ modes = _sort_modes(list(logs.keys()))
464
+ if not modes:
465
+ logger.info("plot_batch_comparison: no controllers to plot")
466
+ return
467
+
468
+ site_models: list[tuple[str, str]] = []
469
+ for log in logs.values():
470
+ for site_id, states in log.dc_states_by_site.items():
471
+ if not states:
472
+ continue
473
+ for m in states[0].batch_size_by_model:
474
+ pair = (site_id, m)
475
+ if pair not in site_models:
476
+ site_models.append(pair)
477
+ break
478
+
479
+ n_rows = len(site_models)
480
+ cmap = plt.get_cmap("tab10")
481
+ fig, axes = plt.subplots(
482
+ n_rows,
483
+ 1,
484
+ figsize=(13, 4 * n_rows),
485
+ sharex=True,
486
+ squeeze=False,
487
+ )
488
+
489
+ for row, (site_id, model_label) in enumerate(site_models):
490
+ ax = axes[row][0]
491
+ for i, mode in enumerate(modes):
492
+ log = logs[mode]
493
+ site_states = log.dc_states_by_site.get(site_id, [])
494
+ times = [s.time_s for s in site_states]
495
+ batches = [s.batch_size_by_model.get(model_label, 0) for s in site_states]
496
+ ax.plot(
497
+ times,
498
+ batches,
499
+ color=cmap(i % 10),
500
+ linewidth=1.5,
501
+ alpha=0.85,
502
+ label=_display_name(mode) if use_display_names else mode,
503
+ )
504
+ ax.set_ylabel("Batch Size", fontsize=13)
505
+ title = f"{model_label} @ {site_id}" if len(log.dc_states_by_site) > 1 else model_label
506
+ ax.set_title(title, fontsize=14)
507
+ ax.legend(fontsize=12, loc="upper right")
508
+ ax.tick_params(labelsize=12)
509
+ ax.grid(True, alpha=0.3)
510
+
511
+ axes[-1][0].set_xlabel("Time (s)", fontsize=13)
512
+ fig.suptitle("Batch Size Comparison by Model", fontsize=16, fontweight="bold")
513
+ fig.tight_layout(rect=[0, 0, 1, 0.95])
514
+ stem = f"scenario_{scenario_idx:03d}_batch_size_comparison" if scenario_idx is not None else "batch_size_comparison"
515
+ fig.savefig(save_dir / f"{stem}.png", dpi=150, bbox_inches="tight")
516
+ plt.close(fig)
517
+ logger.info("Saved %s.png", stem)
518
+
519
+
520
+ def plot_aggregate(
521
+ all_results: list[dict],
522
+ scenario_params: list[dict],
523
+ save_dir: Path,
524
+ modes: list[str],
525
+ *,
526
+ system: str = "",
527
+ use_display_names: bool = False,
528
+ ) -> None:
529
+ """2×3 aggregate bar chart (means) + per-scenario breakdown + normalized integral + CDF + scatter."""
530
+ n_sc = len(all_results)
531
+ prefix = f"{system}_" if system else ""
532
+
533
+ colors = ["#999999", "#4CAF50", "#2196F3", "#FF9800", "#E91E63", "#9C27B0", "#00BCD4", "#795548", "#607D8B"]
534
+
535
+ display_labels = [_display_name(m) if use_display_names else m for m in modes]
536
+
537
+ metrics = [
538
+ ("violation_time_s", "Mean Violation Time (s)"),
539
+ ("integral", "Mean Integral Violation (pu·s)"),
540
+ ("batch_changes", "Mean Batch Size Changes"),
541
+ ("mean_throughput_toks_s", "Mean Throughput (tok/s)"),
542
+ ("mean_power_kw", "Mean Data Center Power (kW)"),
543
+ ("itl_violation_rate", "Mean ITL Violation Rate"),
544
+ ]
545
+
546
+ fig, axes = plt.subplots(2, 3, figsize=(max(15, len(modes) * 3.0), 10))
547
+ x = np.arange(len(modes))
548
+
549
+ for ax, (metric, title) in zip(axes.flat, metrics, strict=False):
550
+ means = []
551
+ for mode in modes:
552
+ vals = [r[mode].get(metric, 0) for r in all_results if mode in r]
553
+ means.append(np.mean(vals) if vals else 0.0)
554
+
555
+ ax.bar(x, means, color=colors[: len(modes)], alpha=0.85)
556
+ ax.set_xticks(x)
557
+ ax.set_xticklabels(display_labels, rotation=30, ha="right", fontsize=12)
558
+ ax.set_ylabel(title, fontsize=13)
559
+ ax.set_title(title, fontsize=14, fontweight="bold")
560
+ ax.tick_params(axis="y", labelsize=12)
561
+ ax.grid(axis="y", alpha=0.3)
562
+
563
+ fig.suptitle(f"Aggregate Controller Metrics: {n_sc} Scenarios", fontsize=16, fontweight="bold")
564
+ fig.tight_layout(rect=[0, 0, 1, 0.95])
565
+ fname = f"{prefix}controller_evaluation.png"
566
+ fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight")
567
+ plt.close(fig)
568
+ logger.info("Saved %s", fname)
569
+
570
+ # ── Per-scenario integral (absolute) ──
571
+ fig, ax = plt.subplots(figsize=(max(10, n_sc * 0.8), 6))
572
+ x = np.arange(n_sc)
573
+ width = 0.8 / len(modes)
574
+
575
+ for i, mode in enumerate(modes):
576
+ vals = [r[mode]["integral"] if mode in r else 0 for r in all_results]
577
+ ax.bar(
578
+ x + i * width,
579
+ vals,
580
+ width,
581
+ label=_display_name(mode) if use_display_names else mode,
582
+ color=colors[i % len(colors)],
583
+ alpha=0.85,
584
+ )
585
+
586
+ ax.set_xlabel("Scenario", fontsize=13)
587
+ ax.set_ylabel("Integral Violation (pu·s)", fontsize=13)
588
+ ax.set_title("Per-Scenario Integral Violation", fontsize=14, fontweight="bold")
589
+ ax.set_xticks(x + width * (len(modes) - 1) / 2)
590
+ ax.set_xticklabels([f"S{i}" for i in range(n_sc)], fontsize=10)
591
+ ax.legend(fontsize=10, loc="upper right")
592
+ ax.tick_params(axis="y", labelsize=12)
593
+ ax.grid(axis="y", alpha=0.3)
594
+ fig.tight_layout()
595
+ fname = f"{prefix}scenario_summary.png"
596
+ fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight")
597
+ plt.close(fig)
598
+ logger.info("Saved %s", fname)
599
+
600
+ # ── Per-scenario normalized integral (relative to baseline_no_tap) ──
601
+ baseline_key = "baseline_no_tap"
602
+ if baseline_key in modes:
603
+ fig, ax = plt.subplots(figsize=(max(10, n_sc * 0.8), 6))
604
+ x = np.arange(n_sc)
605
+ non_baseline = [m for m in modes if m != baseline_key]
606
+ width = 0.8 / len(non_baseline)
607
+
608
+ for i, mode in enumerate(non_baseline):
609
+ norm_vals = []
610
+ for r in all_results:
611
+ base = r.get(baseline_key, {}).get("integral", 0.0)
612
+ val = r.get(mode, {}).get("integral", 0.0)
613
+ norm_vals.append(val / base if base > 0 else 0.0)
614
+ ax.bar(
615
+ x + i * width,
616
+ norm_vals,
617
+ width,
618
+ label=_display_name(mode) if use_display_names else mode,
619
+ color=colors[(modes.index(mode)) % len(colors)],
620
+ alpha=0.85,
621
+ )
622
+
623
+ ax.axhline(1.0, color="black", linestyle="--", linewidth=1, alpha=0.6, label="Baseline")
624
+ ax.set_xlabel("Scenario", fontsize=13)
625
+ ax.set_ylabel("Normalized Integral (relative to No Control)", fontsize=13)
626
+ ax.set_title("Per-Scenario Normalized Integral Violation", fontsize=14, fontweight="bold")
627
+ ax.set_xticks(x + width * (len(non_baseline) - 1) / 2)
628
+ ax.set_xticklabels([f"S{i}" for i in range(n_sc)], fontsize=10)
629
+ ax.legend(fontsize=10, loc="upper right")
630
+ ax.tick_params(axis="y", labelsize=12)
631
+ ax.grid(axis="y", alpha=0.3)
632
+ fig.tight_layout()
633
+ fname = f"{prefix}scenario_normalized_integral.png"
634
+ fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight")
635
+ plt.close(fig)
636
+ logger.info("Saved %s", fname)
637
+
638
+ # ── CDF of integral violation ──
639
+ fig, ax = plt.subplots(figsize=(8, 6))
640
+ for i, mode in enumerate(modes):
641
+ vals = sorted([r[mode].get("integral", 0.0) for r in all_results if mode in r])
642
+ if not vals:
643
+ continue
644
+ cdf = np.arange(1, len(vals) + 1) / len(vals)
645
+ ax.plot(
646
+ vals,
647
+ cdf,
648
+ color=colors[i % len(colors)],
649
+ linewidth=2,
650
+ label=_display_name(mode) if use_display_names else mode,
651
+ )
652
+ ax.set_xlabel("Integral Violation (pu·s)", fontsize=13)
653
+ ax.set_ylabel("Cumulative Fraction", fontsize=13)
654
+ ax.set_title("CDF of Integral Violation Across Scenarios", fontsize=14, fontweight="bold")
655
+ ax.legend(fontsize=11, loc="lower right")
656
+ ax.tick_params(labelsize=12)
657
+ ax.grid(True, alpha=0.3)
658
+ fig.tight_layout()
659
+ fname = f"{prefix}cdf_integral.png"
660
+ fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight")
661
+ plt.close(fig)
662
+ logger.info("Saved %s", fname)
663
+
664
+ # ── Throughput vs. voltage violation scatter ──
665
+ scatter_data = {}
666
+ for _i, mode in enumerate(modes):
667
+ integrals = [r[mode].get("integral", 0.0) for r in all_results if mode in r]
668
+ tputs = [r[mode].get("mean_throughput_toks_s", 0.0) for r in all_results if mode in r]
669
+ scatter_data[mode] = (integrals, tputs)
670
+
671
+ n_modes = len(modes)
672
+ ncols = 2
673
+ nrows = math.ceil(n_modes / ncols)
674
+ fig, axes = plt.subplots(nrows, ncols, figsize=(12, 5 * nrows), sharex=True, sharey=True, squeeze=False)
675
+
676
+ for idx, mode in enumerate(modes):
677
+ ax = axes[idx // ncols][idx % ncols]
678
+ integrals, tputs = scatter_data[mode]
679
+ label = _display_name(mode) if use_display_names else mode
680
+
681
+ for other_mode, (oi, ot) in scatter_data.items():
682
+ if other_mode != mode:
683
+ ax.scatter(oi, ot, color="lightgrey", s=40, alpha=0.6, edgecolors="none", zorder=1)
684
+
685
+ ax.scatter(
686
+ integrals,
687
+ tputs,
688
+ color=colors[idx % len(colors)],
689
+ s=80,
690
+ alpha=0.9,
691
+ edgecolors="black",
692
+ linewidths=0.5,
693
+ zorder=2,
694
+ )
695
+ ax.scatter(
696
+ np.mean(integrals),
697
+ np.mean(tputs),
698
+ color=colors[idx % len(colors)],
699
+ s=220,
700
+ marker="*",
701
+ edgecolors="black",
702
+ linewidths=0.8,
703
+ zorder=3,
704
+ )
705
+
706
+ ax.set_title(label, fontsize=14, fontweight="bold")
707
+ ax.set_xlabel("Integral Violation (pu·s)", fontsize=12)
708
+ ax.set_ylabel("Mean Throughput (tok/s)", fontsize=12)
709
+ ax.tick_params(labelsize=11)
710
+ ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.2e}"))
711
+ ax.grid(True, alpha=0.3)
712
+
713
+ for idx in range(n_modes, nrows * ncols):
714
+ axes[idx // ncols][idx % ncols].set_visible(False)
715
+
716
+ fig.suptitle("Throughput vs. Voltage Violation by Controller", fontsize=16, fontweight="bold")
717
+ fig.tight_layout(rect=[0, 0, 1, 0.95])
718
+ fname = f"{prefix}throughput_vs_violation.png"
719
+ fig.savefig(save_dir / fname, dpi=150, bbox_inches="tight")
720
+ plt.close(fig)
721
+ logger.info("Saved %s", fname)
722
+
723
+ logger.info("Saved aggregate figures to %s", save_dir)
examples/rl_controller/scenarios.py ADDED
@@ -0,0 +1,1080 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RL scenario generation, randomization, and experiment factories.
2
+
3
+ What lives here:
4
+
5
+ - `DCSite`, `PVSystemSpec`, `TimeVaryingLoadSpec`: dataclasses describing the
6
+ per-site simulation components that are randomized across scenarios.
7
+ - `ScenarioRecord`: scalar snapshot of one randomized scenario, produced by
8
+ `build_library.py` and replayed by `train_ppo.py` and `evaluate.py`.
9
+ - `save_library` / `load_library_data`: serialize a list of `ScenarioRecord`
10
+ to a `metadata.json` + `traces.npz` pair (no pickle).
11
+ - `ScenarioOpenDSSGrid`: `OpenDSSGrid` subclass that injects PV systems and
12
+ time-varying loads at arbitrary buses.
13
+ - `pv_profile_random` / `tvl_profile_random` / `eval_profile`: parameterized
14
+ PV/TVL profile generators used at runtime to materialize ScenarioRecord
15
+ parameters into instantaneous power values.
16
+ - `randomize_scenario` / `materialize_scenario`: build a ScenarioRecord from
17
+ a seed and turn it back into the concrete simulation configs each episode
18
+ needs.
19
+ - `EXPERIMENTS` / `ieee*_experiment`: per-feeder factories that combine the
20
+ shared feeder definitions from `examples/offline/systems.py` with the
21
+ model deployments and DC layouts this study uses.
22
+
23
+ Shared simulation constants and feeder factories (ieee13/ieee34/ieee123,
24
+ DT_*, V_MIN/V_MAX, model specs, ...) come from the local `systems.py`,
25
+ which is a vendored copy of `examples/offline/systems.py` so the example
26
+ is self-contained.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import math
32
+ from collections.abc import Callable
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+
36
+ import numpy as np
37
+
38
+ from openg2g.controller.ofo import OFOConfig
39
+ from openg2g.datacenter.config import (
40
+ ModelDeployment,
41
+ ReplicaSchedule,
42
+ TrainingRun,
43
+ )
44
+ from openg2g.datacenter.workloads.training import TrainingTrace
45
+ from openg2g.grid.config import TapPosition, TapSchedule
46
+ from openg2g.grid.opendss import OpenDSSGrid
47
+
48
+ from systems import (
49
+ SYSTEMS,
50
+ TOTAL_DURATION_S,
51
+ V_MAX,
52
+ V_MIN,
53
+ _irregular_fluct,
54
+ _smooth_bump,
55
+ _smoothstep,
56
+ deploy,
57
+ load_profile_kw,
58
+ pv_profile_kw,
59
+ tap,
60
+ with_ramp,
61
+ )
62
+
63
+
64
+ @dataclass
65
+ class DCSite:
66
+ """One datacenter site for simulation setup.
67
+
68
+ Attributes:
69
+ bus: Distribution bus where the datacenter is connected.
70
+ bus_kv: Bus voltage level (kV).
71
+ base_kw_per_phase: Constant base load per phase (kW).
72
+ total_gpu_capacity: Total physical GPUs installed at this site.
73
+ models: (deployment, replica_schedule) pairs at this site. Replica
74
+ counts and runtime ramps both live on the ReplicaSchedule
75
+ (matching master's unpack_deployments() pattern).
76
+ seed: Random seed for layout generation.
77
+ connection_type: Grid connection type (`"wye"` or `"delta"`).
78
+ load_shift_headroom: Fraction of extra server capacity for load shifting.
79
+ """
80
+
81
+ bus: str
82
+ bus_kv: float
83
+ base_kw_per_phase: float
84
+ total_gpu_capacity: int
85
+ models: tuple[tuple[ModelDeployment, ReplicaSchedule], ...] = ()
86
+ seed: int = 0
87
+ connection_type: str = "wye"
88
+ load_shift_headroom: float = 0.0
89
+
90
+
91
+ @dataclass
92
+ class PVSystemSpec:
93
+ """PV system at a distribution bus (used by ScenarioOpenDSSGrid)."""
94
+
95
+ bus: str
96
+ bus_kv: float = 4.16
97
+ peak_kw: float = 1000.0
98
+ csv_path: Path | None = None
99
+ power_factor: float = 1.0
100
+ peak_t_shift_s: float = 0.0
101
+ time_warp: float = 1.0
102
+ profile_kind: str = "default"
103
+ profile_params: dict | None = None
104
+
105
+
106
+ @dataclass
107
+ class TimeVaryingLoadSpec:
108
+ """Time-varying load at a distribution bus (used by ScenarioOpenDSSGrid)."""
109
+
110
+ bus: str
111
+ bus_kv: float = 4.16
112
+ peak_kw: float = 500.0
113
+ csv_path: Path | None = None
114
+ power_factor: float = 0.96
115
+ peak_t_shift_s: float = 0.0
116
+ time_warp: float = 1.0
117
+ profile_kind: str = "default"
118
+ profile_params: dict | None = None
119
+
120
+
121
+ @dataclass
122
+ class ScenarioRecord:
123
+ """One randomized scenario, ready to be replayed at training time.
124
+
125
+ Built by `build_library.py` after baseline + OFO screening, and consumed
126
+ by `train_ppo.py` (sampled per episode) and `evaluate.py` (replayed
127
+ deterministically). `ofo_voltage_pen_per_step` is the per-second OFO
128
+ voltage penalty trace and is what gets subtracted from PPO's per-step
129
+ voltage_pen during training.
130
+ """
131
+
132
+ seed: int
133
+ pv_scale: float
134
+ load_scale: float
135
+ training_overlay: dict | None
136
+ baseline_integral: float
137
+ ofo_integral: float
138
+ baseline_violation_time_s: float
139
+ ofo_violation_time_s: float
140
+ recovery_frac: float
141
+ ofo_voltage_pen_per_step: np.ndarray = field(repr=False)
142
+ baseline_voltage_pen_per_step: np.ndarray = field(repr=False)
143
+ t_control_start: int = 0
144
+ t_control_end: int = 3600
145
+ bl_undervoltage_time_s: float = 0.0
146
+ bl_overvoltage_time_s: float = 0.0
147
+ randomize_ramps: bool | None = None
148
+ ramp_frac_min: float | None = None
149
+ ramp_frac_max: float | None = None
150
+ ramp_start_min: float | None = None
151
+ ramp_start_max: float | None = None
152
+ ramp_dur_min: float | None = None
153
+ ramp_dur_max: float | None = None
154
+
155
+
156
+ def pv_profile_random(t: float, peak_kw: float, params: dict) -> float:
157
+ """Multi-shape PV profile with random cloud events (1-hour episode).
158
+
159
+ params["shape"] picks the envelope:
160
+ "flat" constant baseline 0.75-0.95
161
+ "rising_falling" smooth bump from low_baseline up to high_baseline and back
162
+ "morning_ramp" low → high over a short ramp window, sustained high after
163
+ "afternoon_decline" sustained high then ramp down at the end
164
+ "midday_dip" high baseline with a substantial mid-episode dip
165
+ """
166
+ shape = params.get("shape", "flat")
167
+ T = float(TOTAL_DURATION_S)
168
+
169
+ if shape == "flat":
170
+ env = float(params.get("baseline", 0.85))
171
+ elif shape == "rising_falling":
172
+ lo = float(params.get("low_baseline", 0.50))
173
+ hi = float(params.get("high_baseline", 0.95))
174
+ peak_t = float(params.get("peak_t", T / 2))
175
+ half_width = float(params.get("half_width", 1200.0))
176
+ env = lo + (hi - lo) * _smooth_bump(t, peak_t, half_width)
177
+ elif shape == "morning_ramp":
178
+ lo = float(params.get("low_baseline", 0.25))
179
+ hi = float(params.get("high_baseline", 0.75))
180
+ ramp_start = float(params.get("ramp_start", 100.0))
181
+ ramp_end = float(params.get("ramp_end", 1200.0))
182
+ env = lo + (hi - lo) * _smoothstep(t, ramp_start, ramp_end)
183
+ elif shape == "afternoon_decline":
184
+ lo = float(params.get("low_baseline", 0.25))
185
+ hi = float(params.get("high_baseline", 0.75))
186
+ ramp_start = float(params.get("ramp_start", 2400.0))
187
+ ramp_end = float(params.get("ramp_end", T - 100.0))
188
+ env = hi - (hi - lo) * _smoothstep(t, ramp_start, ramp_end)
189
+ elif shape == "midday_dip":
190
+ hi = float(params.get("high_baseline", 0.90))
191
+ dip_t = float(params.get("dip_t", T / 2))
192
+ dip_half_width = float(params.get("dip_half_width", 700.0))
193
+ dip_depth = float(params.get("dip_depth", 0.55))
194
+ env = hi - dip_depth * _smooth_bump(t, dip_t, dip_half_width)
195
+ else:
196
+ env = 0.85
197
+
198
+ for tc, hw, depth in params.get("clouds", ()):
199
+ env -= float(depth) * _smooth_bump(t, float(tc), float(hw))
200
+
201
+ env = max(0.15, env)
202
+ noise_amp = float(params.get("noise_amp", 0.0))
203
+ if noise_amp > 0:
204
+ f = _irregular_fluct(t, seed=float(params.get("noise_seed", 0.0)))
205
+ env *= 1.0 + (noise_amp / 0.20) * (f - 1.0)
206
+ return max(0.0, peak_kw * env)
207
+
208
+
209
+ def tvl_profile_random(t: float, peak_kw: float, params: dict) -> float:
210
+ """Multi-shape TVL profile (1-hour episode)."""
211
+ shape = params.get("shape", "peaked")
212
+ T = float(TOTAL_DURATION_S)
213
+ if shape == "flat":
214
+ base = float(params.get("level", 0.7))
215
+ elif shape == "increasing":
216
+ lo = float(params.get("lo", 0.2))
217
+ hi = float(params.get("hi", 0.9))
218
+ base = lo + (hi - lo) * min(1.0, max(0.0, t / T))
219
+ elif shape == "decreasing":
220
+ lo = float(params.get("lo", 0.2))
221
+ hi = float(params.get("hi", 0.9))
222
+ base = hi - (hi - lo) * min(1.0, max(0.0, t / T))
223
+ elif shape == "peaked":
224
+ peak_t = float(params.get("peak_t", T / 2))
225
+ peak_w = float(params.get("peak_w", 1400.0))
226
+ baseline = float(params.get("baseline", 0.15))
227
+ amp = float(params.get("amp", 0.85))
228
+ base = baseline + amp * _smooth_bump(t, peak_t, peak_w)
229
+ elif shape == "valley":
230
+ valley_t = float(params.get("valley_t", T / 2))
231
+ valley_w = float(params.get("valley_w", 1200.0))
232
+ high = float(params.get("high", 0.85))
233
+ depth = float(params.get("depth", 0.55))
234
+ base = high - depth * _smooth_bump(t, valley_t, valley_w)
235
+ else:
236
+ base = 0.5
237
+ base = max(0.0, base)
238
+ noise_amp = float(params.get("noise_amp", 0.0))
239
+ if noise_amp > 0:
240
+ f = _irregular_fluct(t, seed=float(params.get("noise_seed", 0.0)))
241
+ base *= 1.0 + (noise_amp / 0.20) * (f - 1.0)
242
+ return max(0.0, peak_kw * base)
243
+
244
+
245
+ def load_csv_profile(csv_path: Path) -> tuple[np.ndarray, np.ndarray]:
246
+ data = np.loadtxt(csv_path, delimiter=",", skiprows=1)
247
+ return data[:, 0], data[:, 1]
248
+
249
+
250
+ def eval_profile(
251
+ t,
252
+ *,
253
+ peak_kw,
254
+ csv_data,
255
+ profile_fn,
256
+ site_idx,
257
+ peak_t_shift_s: float = 0.0,
258
+ time_warp: float = 1.0,
259
+ profile_kind: str = "default",
260
+ profile_params: dict | None = None,
261
+ ):
262
+ """Evaluate a PV/TVL profile at simulated time `t`.
263
+
264
+ Dispatch order:
265
+ 1. `profile_kind="random_flat"` + `profile_params` -> pv_profile_random
266
+ 2. `profile_kind="random_shape"` + `profile_params` -> tvl_profile_random
267
+ 3. `csv_data` not None -> CSV interpolation
268
+ 4. `profile_fn` (analytical pv_profile_kw / load_profile_kw)
269
+ """
270
+ if profile_kind == "random_flat" and profile_params is not None:
271
+ return pv_profile_random(t, peak_kw, profile_params)
272
+ if profile_kind == "random_shape" and profile_params is not None:
273
+ return tvl_profile_random(t, peak_kw, profile_params)
274
+ if time_warp <= 0:
275
+ time_warp = 1.0
276
+ t_eff = (t - peak_t_shift_s) / time_warp
277
+ if csv_data is not None:
278
+ return float(np.interp(t_eff, csv_data[0], csv_data[1]))
279
+ return profile_fn(t_eff, peak_kw, site_idx)
280
+
281
+
282
+ class ScenarioOpenDSSGrid(OpenDSSGrid):
283
+ """OpenDSSGrid with PV systems and external loads at arbitrary buses."""
284
+
285
+ def __init__(
286
+ self, *, pv_systems=None, time_varying_loads=None, source_pu=None, constant_pv: bool = False, **kwargs
287
+ ):
288
+ super().__init__(**kwargs)
289
+ self._pv_specs = list(pv_systems or [])
290
+ self._load_specs = list(time_varying_loads or [])
291
+ self._source_pu = source_pu
292
+ self._constant_pv = constant_pv
293
+
294
+ self._pv_csv = [load_csv_profile(s.csv_path) if s.csv_path else None for s in self._pv_specs]
295
+ self._load_csv = [load_csv_profile(s.csv_path) if s.csv_path else None for s in self._load_specs]
296
+ self._pv_load_names = [(f"PV_{i}_A", f"PV_{i}_B", f"PV_{i}_C") for i in range(len(self._pv_specs))]
297
+ self._ext_load_names = [
298
+ (f"ExtLoad_{i}_A", f"ExtLoad_{i}_B", f"ExtLoad_{i}_C") for i in range(len(self._load_specs))
299
+ ]
300
+
301
+ def _init_dss(self) -> None:
302
+ super()._init_dss()
303
+ from openg2g.grid.opendss import dss
304
+
305
+ if self._source_pu is not None:
306
+ dss.Text.Command(f"Edit Vsource.source pu={self._source_pu}")
307
+
308
+ for i, spec in enumerate(self._pv_specs):
309
+ kv_ln = spec.bus_kv / math.sqrt(3.0)
310
+ for ph, name in zip((1, 2, 3), self._pv_load_names[i], strict=False):
311
+ dss.Text.Command(
312
+ f"New Load.{name} bus1={spec.bus}.{ph} phases=1 "
313
+ f"conn=wye kV={kv_ln:.6f} kW=0 kvar=0 model=1 vminpu=0.85"
314
+ )
315
+
316
+ for i, spec in enumerate(self._load_specs):
317
+ kv_ln = spec.bus_kv / math.sqrt(3.0)
318
+ for ph, name in zip((1, 2, 3), self._ext_load_names[i], strict=False):
319
+ dss.Text.Command(
320
+ f"New Load.{name} bus1={spec.bus}.{ph} phases=1 "
321
+ f"conn=wye kV={kv_ln:.6f} kW=0 kvar=0 model=1 vminpu=0.85"
322
+ )
323
+
324
+ def step(self, clock, power_samples_w, events):
325
+ from openg2g.grid.opendss import dss
326
+
327
+ for i, spec in enumerate(self._pv_specs):
328
+ if self._constant_pv:
329
+ kw = spec.peak_kw
330
+ else:
331
+ kw = eval_profile(
332
+ clock.time_s,
333
+ peak_kw=spec.peak_kw,
334
+ csv_data=self._pv_csv[i],
335
+ profile_fn=pv_profile_kw,
336
+ site_idx=i,
337
+ peak_t_shift_s=getattr(spec, "peak_t_shift_s", 0.0),
338
+ time_warp=getattr(spec, "time_warp", 1.0),
339
+ profile_kind=getattr(spec, "profile_kind", "default"),
340
+ profile_params=getattr(spec, "profile_params", None),
341
+ )
342
+ pf = max(min(spec.power_factor, 0.999999), 1e-6)
343
+ kvar = kw * math.tan(math.acos(pf))
344
+ for name in self._pv_load_names[i]:
345
+ dss.Loads.Name(name)
346
+ dss.Loads.kW(-kw)
347
+ dss.Loads.kvar(-kvar)
348
+
349
+ for i, spec in enumerate(self._load_specs):
350
+ kw = eval_profile(
351
+ clock.time_s,
352
+ peak_kw=spec.peak_kw,
353
+ csv_data=self._load_csv[i],
354
+ profile_fn=load_profile_kw,
355
+ site_idx=i,
356
+ peak_t_shift_s=getattr(spec, "peak_t_shift_s", 0.0),
357
+ time_warp=getattr(spec, "time_warp", 1.0),
358
+ profile_kind=getattr(spec, "profile_kind", "default"),
359
+ profile_params=getattr(spec, "profile_params", None),
360
+ )
361
+ pf = max(min(spec.power_factor, 0.999999), 1e-6)
362
+ kvar = kw * math.tan(math.acos(pf))
363
+ for name in self._ext_load_names[i]:
364
+ dss.Loads.Name(name)
365
+ dss.Loads.kW(kw)
366
+ dss.Loads.kvar(kvar)
367
+
368
+ return super().step(clock, power_samples_w, events)
369
+
370
+
371
+ def _site_inference_gpus(site: DCSite) -> int:
372
+ """Sum of GPUs consumed by inference at a site (replicas × gpus_per_replica)."""
373
+ return sum(sched.initial * md.spec.gpus_per_replica for md, sched in site.models)
374
+
375
+
376
+ def _randomize_ramps(
377
+ dc_sites: dict[str, DCSite],
378
+ rng: np.random.Generator,
379
+ *,
380
+ ramp_frac_min: float = 0.15,
381
+ ramp_frac_max: float = 0.3,
382
+ ramp_start_min: float = 500.0,
383
+ ramp_start_max: float = 3000.0,
384
+ ramp_dur_min: float = 300.0,
385
+ ramp_dur_max: float = 800.0,
386
+ ) -> dict[str, DCSite]:
387
+ """Return a copy of dc_sites with randomized ramp targets and timing."""
388
+ ramp_frac = rng.uniform(ramp_frac_min, ramp_frac_max)
389
+ ramp_start = rng.uniform(ramp_start_min, ramp_start_max)
390
+ ramp_dur = rng.uniform(ramp_dur_min, ramp_dur_max)
391
+ ramp_end = ramp_start + ramp_dur
392
+
393
+ new_sites: dict[str, DCSite] = {}
394
+ for sid, site in dc_sites.items():
395
+ new_models: list[tuple[ModelDeployment, ReplicaSchedule]] = []
396
+ for md, sched in site.models:
397
+ target = max(1, int(ramp_frac * sched.initial))
398
+ new_sched = ReplicaSchedule(initial=sched.initial).ramp_to(
399
+ target,
400
+ t_start=ramp_start,
401
+ t_end=ramp_end,
402
+ )
403
+ new_models.append((md, new_sched))
404
+ new_sites[sid] = DCSite(
405
+ bus=site.bus,
406
+ bus_kv=site.bus_kv,
407
+ base_kw_per_phase=site.base_kw_per_phase,
408
+ total_gpu_capacity=site.total_gpu_capacity,
409
+ models=tuple(new_models),
410
+ seed=int(rng.integers(0, 10000)),
411
+ connection_type=site.connection_type,
412
+ )
413
+ return new_sites
414
+
415
+
416
+ def _randomize_broad_ramps(
417
+ dc_sites: dict[str, DCSite],
418
+ rng: np.random.Generator,
419
+ *,
420
+ overlay_gpus_at_first_site: int,
421
+ n_ramps_per_site_choices: tuple[int, ...] = (1, 2),
422
+ ramp_up_prob: float = 0.5,
423
+ ramp_down_frac_min: float = 0.15,
424
+ ramp_down_frac_max: float = 0.5,
425
+ ramp_up_frac_min: float = 1.05,
426
+ ramp_up_frac_max: float = 1.5,
427
+ ramp_start_min: float = 500.0,
428
+ ramp_start_max: float = 3000.0,
429
+ ramp_dur_min: float = 300.0,
430
+ ramp_dur_max: float = 800.0,
431
+ exclude_window: tuple[float, float] | None = None,
432
+ ) -> dict[str, DCSite]:
433
+ """Bidirectional, multi-ramp generator that respects DC GPU capacity."""
434
+ if exclude_window is not None:
435
+ ex_lo, ex_hi = exclude_window
436
+ zone1_hi = ex_lo - ramp_dur_max
437
+ zone2_lo = ex_hi
438
+ zones: list[tuple[float, float]] = []
439
+ if ramp_start_min < zone1_hi:
440
+ zones.append((ramp_start_min, min(zone1_hi, ramp_start_max)))
441
+ if zone2_lo < ramp_start_max:
442
+ zones.append((max(zone2_lo, ramp_start_min), ramp_start_max))
443
+ if not zones:
444
+ zones = [(ramp_start_min, ramp_start_max)]
445
+ else:
446
+ zones = [(ramp_start_min, ramp_start_max)]
447
+
448
+ total_width = sum(hi - lo for lo, hi in zones)
449
+
450
+ new_sites: dict[str, DCSite] = {}
451
+ sites_list = list(dc_sites.items())
452
+ for site_idx, (sid, site) in enumerate(sites_list):
453
+ current_gpus = _site_inference_gpus(site)
454
+ overlay_here = overlay_gpus_at_first_site if site_idx == 0 else 0
455
+ available_gpus = max(0, site.total_gpu_capacity - overlay_here)
456
+ max_feasible_up = available_gpus / current_gpus if current_gpus > 0 else 1.0
457
+ site_ramp_up_max = min(ramp_up_frac_max, max_feasible_up)
458
+ can_up_ramp = site_ramp_up_max >= max(1.05, ramp_up_frac_min)
459
+
460
+ n_ramps = int(rng.choice(n_ramps_per_site_choices))
461
+ zone_counts = [int(n_ramps * (hi - lo) / total_width) for lo, hi in zones]
462
+ remainder = n_ramps - sum(zone_counts)
463
+ if remainder > 0:
464
+ widest = max(range(len(zones)), key=lambda i: zones[i][1] - zones[i][0])
465
+ zone_counts[widest] += remainder
466
+
467
+ # Per-model schedule starts from the model's existing initial count.
468
+ model_scheds: dict[str, ReplicaSchedule] = {
469
+ md.spec.model_label: ReplicaSchedule(initial=sched.initial) for md, sched in site.models
470
+ }
471
+ for (z_lo, z_hi), z_count in zip(zones, zone_counts, strict=False):
472
+ if z_count == 0:
473
+ continue
474
+ band_width = (z_hi - z_lo) / z_count
475
+ for bi in range(z_count):
476
+ band_lo = z_lo + bi * band_width
477
+ band_hi = z_lo + (bi + 1) * band_width
478
+ if band_width < ramp_dur_min:
479
+ continue
480
+ t_dur = float(rng.uniform(ramp_dur_min, min(ramp_dur_max, band_width)))
481
+ t_start = float(rng.uniform(band_lo, max(band_lo + 1.0, band_hi - t_dur)))
482
+ t_end = t_start + t_dur
483
+
484
+ if can_up_ramp and rng.random() < ramp_up_prob:
485
+ lo = max(1.05, ramp_up_frac_min)
486
+ hi = max(lo + 1e-3, site_ramp_up_max)
487
+ frac = float(rng.uniform(lo, hi))
488
+ else:
489
+ frac = float(rng.uniform(ramp_down_frac_min, ramp_down_frac_max))
490
+
491
+ for md, sched in site.models:
492
+ target = max(1, int(round(frac * sched.initial)))
493
+ label = md.spec.model_label
494
+ model_scheds[label] = model_scheds[label].ramp_to(
495
+ target,
496
+ t_start=t_start,
497
+ t_end=t_end,
498
+ )
499
+
500
+ new_models = tuple((md, model_scheds[md.spec.model_label]) for md, _ in site.models)
501
+ new_sites[sid] = DCSite(
502
+ bus=site.bus,
503
+ bus_kv=site.bus_kv,
504
+ base_kw_per_phase=site.base_kw_per_phase,
505
+ total_gpu_capacity=site.total_gpu_capacity,
506
+ models=new_models,
507
+ seed=int(rng.integers(0, 10000)),
508
+ connection_type=site.connection_type,
509
+ )
510
+ return new_sites
511
+
512
+
513
+ def randomize_scenario(
514
+ seed: int,
515
+ *,
516
+ dc_sites_base: dict[str, DCSite],
517
+ pv_systems_base: list[PVSystemSpec],
518
+ tvl_base: list[TimeVaryingLoadSpec],
519
+ training_base: dict | None,
520
+ randomize_ramps: bool = True,
521
+ ramp_frac_min: float = 0.15,
522
+ ramp_frac_max: float = 0.3,
523
+ ramp_start_min: float = 500.0,
524
+ ramp_start_max: float = 3000.0,
525
+ ramp_dur_min: float = 300.0,
526
+ ramp_dur_max: float = 800.0,
527
+ randomization_profile: bool = True,
528
+ pv_scale_min: float = 0.5,
529
+ pv_scale_max: float = 2.0,
530
+ load_scale_min: float = 0.5,
531
+ load_scale_max: float = 2.0,
532
+ pv_t_shift_max_s: float = 0.0,
533
+ tvl_t_shift_max_s: float = 0.0,
534
+ pv_warp_min: float = 1.0,
535
+ pv_warp_max: float = 1.0,
536
+ tvl_warp_min: float = 1.0,
537
+ tvl_warp_max: float = 1.0,
538
+ overlay_prob: float = 1.0,
539
+ overlay_intensity_min: float = 1.0,
540
+ overlay_intensity_max: float = 1.0,
541
+ overlay_gpu_frac_min: float = 0.85,
542
+ overlay_gpu_frac_max: float = 1.0,
543
+ n_ramps_per_site_choices: tuple[int, ...] = (1,),
544
+ ramp_up_prob: float = 0.0,
545
+ ramp_down_frac_min: float = 0.15,
546
+ ramp_down_frac_max: float = 0.5,
547
+ ramp_up_frac_min: float = 1.05,
548
+ ramp_up_frac_max: float = 1.5,
549
+ randomize_pv_profile: bool = False,
550
+ pv_shape_choices: tuple[str, ...] = (
551
+ "flat",
552
+ "rising_falling",
553
+ "morning_ramp",
554
+ "afternoon_decline",
555
+ "midday_dip",
556
+ ),
557
+ pv_baseline_min: float = 0.75,
558
+ pv_baseline_max: float = 0.95,
559
+ pv_cloud_count_max: int = 3,
560
+ pv_cloud_depth_min: float = 0.30,
561
+ pv_cloud_depth_max: float = 0.70,
562
+ pv_cloud_width_min: float = 60.0,
563
+ pv_cloud_width_max: float = 300.0,
564
+ randomize_tvl_profile: bool = False,
565
+ tvl_shape_choices: tuple[str, ...] = ("flat", "increasing", "decreasing", "peaked", "valley"),
566
+ ) -> dict:
567
+ """Build a single randomized episode scenario from a seed."""
568
+ rng = np.random.default_rng(seed=seed)
569
+ is_broad = randomization_profile if isinstance(randomization_profile, bool) else (randomization_profile == "broad")
570
+
571
+ training_run = None
572
+ train_overlay_meta: dict | None = None
573
+ overlay_gpus_at_first_site = 0
574
+ if training_base is not None:
575
+ if is_broad:
576
+ overlay_on = bool(rng.random() < overlay_prob)
577
+ else:
578
+ overlay_on = True
579
+ if overlay_on:
580
+ train_dur = float(rng.uniform(500.0, 1200.0))
581
+ if is_broad:
582
+ train_start = float(rng.uniform(0.0, max(0.0, float(TOTAL_DURATION_S) - train_dur)))
583
+ else:
584
+ train_start = float(rng.uniform(500.0, 1500.0))
585
+ gpu_frac = (
586
+ float(rng.uniform(overlay_gpu_frac_min, overlay_gpu_frac_max))
587
+ if is_broad
588
+ else float(rng.uniform(0.85, 1.0))
589
+ )
590
+ train_gpus = int(gpu_frac * training_base["n_gpus"])
591
+ intensity = float(rng.uniform(overlay_intensity_min, overlay_intensity_max)) if is_broad else 1.0
592
+ target_peak = training_base["target_peak_W_per_gpu"] * intensity
593
+ training_run = TrainingRun(
594
+ n_gpus=train_gpus,
595
+ trace=training_base["trace"],
596
+ target_peak_W_per_gpu=target_peak,
597
+ ).at(t_start=train_start, t_end=train_start + train_dur)
598
+ train_overlay_meta = {
599
+ "n_gpus": train_gpus,
600
+ "target_peak_W_per_gpu": target_peak,
601
+ "intensity": intensity,
602
+ "t_start": train_start,
603
+ "t_end": train_start + train_dur,
604
+ }
605
+ overlay_gpus_at_first_site = train_gpus
606
+
607
+ if randomize_ramps:
608
+ if is_broad:
609
+ overlay_window: tuple[float, float] | None = None
610
+ if train_overlay_meta is not None:
611
+ overlay_window = (train_overlay_meta["t_start"], train_overlay_meta["t_end"])
612
+ sites = _randomize_broad_ramps(
613
+ dc_sites_base,
614
+ rng,
615
+ overlay_gpus_at_first_site=overlay_gpus_at_first_site,
616
+ n_ramps_per_site_choices=tuple(n_ramps_per_site_choices),
617
+ ramp_up_prob=ramp_up_prob,
618
+ ramp_down_frac_min=ramp_down_frac_min,
619
+ ramp_down_frac_max=ramp_down_frac_max,
620
+ ramp_up_frac_min=ramp_up_frac_min,
621
+ ramp_up_frac_max=ramp_up_frac_max,
622
+ ramp_start_min=ramp_start_min,
623
+ ramp_start_max=ramp_start_max,
624
+ ramp_dur_min=ramp_dur_min,
625
+ ramp_dur_max=ramp_dur_max,
626
+ exclude_window=overlay_window,
627
+ )
628
+ else:
629
+ sites = _randomize_ramps(
630
+ dc_sites_base,
631
+ rng,
632
+ ramp_frac_min=ramp_frac_min,
633
+ ramp_frac_max=ramp_frac_max,
634
+ ramp_start_min=ramp_start_min,
635
+ ramp_start_max=ramp_start_max,
636
+ ramp_dur_min=ramp_dur_min,
637
+ ramp_dur_max=ramp_dur_max,
638
+ )
639
+ else:
640
+ sites = dict(dc_sites_base)
641
+
642
+ pv_scale = float(rng.uniform(pv_scale_min, pv_scale_max)) if is_broad else float(rng.uniform(0.5, 2.0))
643
+ load_scale = float(rng.uniform(load_scale_min, load_scale_max)) if is_broad else float(rng.uniform(0.5, 2.0))
644
+ pv_t_shift = float(rng.uniform(-pv_t_shift_max_s, pv_t_shift_max_s)) if (is_broad and pv_t_shift_max_s > 0) else 0.0
645
+ tvl_t_shift = (
646
+ float(rng.uniform(-tvl_t_shift_max_s, tvl_t_shift_max_s)) if (is_broad and tvl_t_shift_max_s > 0) else 0.0
647
+ )
648
+ pv_warp = float(rng.uniform(pv_warp_min, pv_warp_max)) if is_broad else 1.0
649
+ tvl_warp = float(rng.uniform(tvl_warp_min, tvl_warp_max)) if is_broad else 1.0
650
+
651
+ def _sample_pv_profile(rng_) -> tuple[str, dict | None]:
652
+ n_clouds = int(rng_.integers(0, pv_cloud_count_max + 1))
653
+ clouds = [
654
+ (
655
+ float(rng_.uniform(120.0, 3480.0)),
656
+ float(rng_.uniform(pv_cloud_width_min, pv_cloud_width_max)),
657
+ float(rng_.uniform(pv_cloud_depth_min, pv_cloud_depth_max)),
658
+ )
659
+ for _ in range(n_clouds)
660
+ ]
661
+ shape = str(rng_.choice(list(pv_shape_choices)))
662
+ params: dict = {
663
+ "shape": shape,
664
+ "clouds": clouds,
665
+ "noise_amp": float(rng_.uniform(0.02, 0.06)),
666
+ "noise_seed": float(rng_.uniform(0.0, 10.0)),
667
+ }
668
+ if shape == "flat":
669
+ params["baseline"] = float(rng_.uniform(pv_baseline_min, pv_baseline_max))
670
+ elif shape == "rising_falling":
671
+ lo = float(rng_.uniform(0.15, 0.55))
672
+ params["low_baseline"] = lo
673
+ params["high_baseline"] = float(rng_.uniform(lo + 0.20, 1.00))
674
+ params["peak_t"] = float(rng_.uniform(900.0, 2700.0))
675
+ params["half_width"] = float(rng_.uniform(800.0, 1500.0))
676
+ elif shape == "morning_ramp":
677
+ lo = float(rng_.uniform(0.15, 0.45))
678
+ params["low_baseline"] = lo
679
+ params["high_baseline"] = float(rng_.uniform(lo + 0.20, 1.00))
680
+ params["ramp_start"] = float(rng_.uniform(0.0, 400.0))
681
+ params["ramp_end"] = float(rng_.uniform(800.0, 1800.0))
682
+ elif shape == "afternoon_decline":
683
+ lo = float(rng_.uniform(0.15, 0.45))
684
+ params["low_baseline"] = lo
685
+ params["high_baseline"] = float(rng_.uniform(lo + 0.20, 1.00))
686
+ params["ramp_start"] = float(rng_.uniform(1800.0, 2800.0))
687
+ params["ramp_end"] = float(rng_.uniform(3200.0, 3600.0))
688
+ elif shape == "midday_dip":
689
+ params["high_baseline"] = float(rng_.uniform(0.30, 1.00))
690
+ params["dip_t"] = float(rng_.uniform(1200.0, 2400.0))
691
+ params["dip_half_width"] = float(rng_.uniform(500.0, 1000.0))
692
+ params["dip_depth"] = float(rng_.uniform(0.20, 0.55))
693
+ return "random_flat", params
694
+
695
+ tvl_profile_kind = "default"
696
+ tvl_profile_params: dict | None = None
697
+ if is_broad and randomize_tvl_profile:
698
+ shape = str(rng.choice(list(tvl_shape_choices)))
699
+ params: dict = {"shape": shape}
700
+ if shape == "flat":
701
+ params["level"] = float(rng.uniform(0.5, 0.85))
702
+ elif shape == "increasing" or shape == "decreasing":
703
+ params["lo"] = float(rng.uniform(0.10, 0.30))
704
+ params["hi"] = float(rng.uniform(0.65, 0.95))
705
+ elif shape == "peaked":
706
+ params["peak_t"] = float(rng.uniform(800.0, 2800.0))
707
+ params["peak_w"] = float(rng.uniform(800.0, 1800.0))
708
+ params["baseline"] = float(rng.uniform(0.10, 0.30))
709
+ params["amp"] = float(rng.uniform(0.55, 0.90))
710
+ elif shape == "valley":
711
+ params["valley_t"] = float(rng.uniform(800.0, 2800.0))
712
+ params["valley_w"] = float(rng.uniform(800.0, 1500.0))
713
+ params["high"] = float(rng.uniform(0.70, 0.90))
714
+ params["depth"] = float(rng.uniform(0.40, 0.70))
715
+ params["noise_amp"] = float(rng.uniform(0.02, 0.06))
716
+ params["noise_seed"] = float(rng.uniform(0.0, 10.0))
717
+ tvl_profile_kind = "random_shape"
718
+ tvl_profile_params = params
719
+
720
+ pv_systems_out = []
721
+ for s in pv_systems_base:
722
+ if is_broad and randomize_pv_profile:
723
+ p_kind, p_params = _sample_pv_profile(rng)
724
+ else:
725
+ p_kind, p_params = "default", None
726
+ pv_systems_out.append(
727
+ PVSystemSpec(
728
+ bus=s.bus,
729
+ bus_kv=s.bus_kv,
730
+ peak_kw=s.peak_kw * pv_scale,
731
+ peak_t_shift_s=pv_t_shift,
732
+ time_warp=pv_warp,
733
+ profile_kind=p_kind,
734
+ profile_params=p_params,
735
+ )
736
+ )
737
+ tvl = [
738
+ TimeVaryingLoadSpec(
739
+ bus=s.bus,
740
+ bus_kv=s.bus_kv,
741
+ peak_kw=s.peak_kw * load_scale,
742
+ peak_t_shift_s=tvl_t_shift,
743
+ time_warp=tvl_warp,
744
+ profile_kind=tvl_profile_kind,
745
+ profile_params=tvl_profile_params,
746
+ )
747
+ for s in tvl_base
748
+ ]
749
+
750
+ batch_choices = [32, 64, 128]
751
+ initial_batch_map: dict[str, int] = {}
752
+ new_sites: dict[str, DCSite] = {}
753
+ for sid, site in sites.items():
754
+ new_models: list[tuple[ModelDeployment, ReplicaSchedule]] = []
755
+ for md, sched in site.models:
756
+ bs = int(rng.choice(batch_choices))
757
+ initial_batch_map[md.spec.model_label] = bs
758
+ new_models.append(
759
+ (ModelDeployment(spec=md.spec, initial_batch_size=bs), sched),
760
+ )
761
+ new_sites[sid] = DCSite(
762
+ bus=site.bus,
763
+ bus_kv=site.bus_kv,
764
+ base_kw_per_phase=site.base_kw_per_phase,
765
+ total_gpu_capacity=site.total_gpu_capacity,
766
+ models=tuple(new_models),
767
+ seed=site.seed,
768
+ connection_type=site.connection_type,
769
+ )
770
+
771
+ return {
772
+ "seed": int(seed),
773
+ "dc_sites": new_sites,
774
+ "pv_systems": pv_systems_out,
775
+ "tvl": tvl,
776
+ "training_run": training_run,
777
+ "params": {
778
+ "pv_scale": pv_scale,
779
+ "load_scale": load_scale,
780
+ "pv_t_shift_s": pv_t_shift,
781
+ "tvl_t_shift_s": tvl_t_shift,
782
+ "pv_warp": pv_warp,
783
+ "tvl_warp": tvl_warp,
784
+ "training_overlay": train_overlay_meta,
785
+ "initial_batch_sizes": initial_batch_map,
786
+ "randomization_profile": randomization_profile,
787
+ "tvl_profile_kind": tvl_profile_kind,
788
+ "tvl_profile_params": tvl_profile_params,
789
+ },
790
+ }
791
+
792
+
793
+ def save_library(
794
+ library_dir: Path,
795
+ scenarios: list[ScenarioRecord],
796
+ config: dict,
797
+ ) -> None:
798
+ """Save a scenario library to `library_dir` as `metadata.json` + `traces.npz`.
799
+
800
+ The two files together capture everything needed to replay a library:
801
+ `metadata.json` is human-inspectable and stores the build config plus
802
+ every `ScenarioRecord`'s scalar fields; `traces.npz` stores the per-step
803
+ voltage penalty arrays as numpy arrays (`ofo_<i>`, `baseline_<i>`).
804
+ """
805
+ import json
806
+
807
+ library_dir.mkdir(parents=True, exist_ok=True)
808
+ metadata: dict = {
809
+ "config": config,
810
+ "scenarios": [],
811
+ }
812
+ traces: dict[str, np.ndarray] = {}
813
+ for i, rec in enumerate(scenarios):
814
+ scalar_fields: dict = {}
815
+ for f in ScenarioRecord.__dataclass_fields__:
816
+ if f in {"ofo_voltage_pen_per_step", "baseline_voltage_pen_per_step"}:
817
+ continue
818
+ scalar_fields[f] = getattr(rec, f)
819
+ metadata["scenarios"].append(scalar_fields)
820
+ traces[f"ofo_{i}"] = rec.ofo_voltage_pen_per_step
821
+ traces[f"baseline_{i}"] = rec.baseline_voltage_pen_per_step
822
+ (library_dir / "metadata.json").write_text(json.dumps(metadata, indent=2))
823
+ np.savez(library_dir / "traces.npz", **traces)
824
+
825
+
826
+ def load_library_data(library_dir: Path) -> tuple[list[ScenarioRecord], dict]:
827
+ """Read a library directory written by `save_library`.
828
+
829
+ Returns `(scenarios, config)`. Does not construct materialization base
830
+ components: `ScenarioLibrary` does that on top.
831
+ """
832
+ import json
833
+
834
+ metadata = json.loads((library_dir / "metadata.json").read_text())
835
+ traces = np.load(library_dir / "traces.npz")
836
+ scenarios: list[ScenarioRecord] = []
837
+ for i, fields in enumerate(metadata["scenarios"]):
838
+ scenarios.append(
839
+ ScenarioRecord(
840
+ ofo_voltage_pen_per_step=traces[f"ofo_{i}"],
841
+ baseline_voltage_pen_per_step=traces[f"baseline_{i}"],
842
+ **fields,
843
+ )
844
+ )
845
+ return scenarios, metadata["config"]
846
+
847
+
848
+ def materialize_scenario(
849
+ rec: ScenarioRecord,
850
+ *,
851
+ dc_sites_base: dict[str, DCSite],
852
+ pv_systems_base: list[PVSystemSpec],
853
+ tvl_base: list[TimeVaryingLoadSpec],
854
+ training_base: dict | None,
855
+ randomize_kwargs: dict,
856
+ ) -> dict:
857
+ """Replay `randomize_scenario` from a record's seed to obtain the same
858
+ per-episode scenario dict the build pipeline saw.
859
+
860
+ This is `randomize_scenario(seed=rec.seed, ...)` with the
861
+ `*_base` configurations and `randomize_kwargs` that the library was built
862
+ with: the RNG is seeded so the replay is bit-identical to the build-time
863
+ output (including `dc_sites`, `pv_systems`, `tvl`, and the chosen scalar
864
+ parameters). `ScenarioLibrary.materialize` wires the base components in
865
+ automatically; call this directly only when constructing a scenario
866
+ outside a library (e.g. evaluate.py's seed-driven test set).
867
+ """
868
+ return randomize_scenario(
869
+ seed=rec.seed,
870
+ dc_sites_base=dc_sites_base,
871
+ pv_systems_base=pv_systems_base,
872
+ tvl_base=tvl_base,
873
+ training_base=training_base,
874
+ **randomize_kwargs,
875
+ )
876
+
877
+
878
+ def ieee13_experiment(training_trace: TrainingTrace | None = None) -> dict:
879
+ """IEEE 13-bus: single DC at bus 671 with 5 LLM models."""
880
+ sys = SYSTEMS["ieee13"]()
881
+ ramp_targets = {
882
+ "Llama-3.1-8B": 144,
883
+ "Llama-3.1-70B": 36,
884
+ "Llama-3.1-405B": 18,
885
+ "Qwen3-30B-A3B": 96,
886
+ "Qwen3-235B-A22B": 42,
887
+ }
888
+ base_models = (
889
+ deploy("Llama-3.1-8B", 720),
890
+ deploy("Llama-3.1-70B", 180),
891
+ deploy("Llama-3.1-405B", 90),
892
+ deploy("Qwen3-30B-A3B", 480),
893
+ deploy("Qwen3-235B-A22B", 210),
894
+ )
895
+ models = tuple(
896
+ (md, sched.ramp_to(ramp_targets[md.spec.model_label], t_start=2500, t_end=3000)) for md, sched in base_models
897
+ )
898
+ training_base = (
899
+ {
900
+ "trace": training_trace,
901
+ "n_gpus": 2400,
902
+ "target_peak_W_per_gpu": 400.0,
903
+ "t_start": 1000.0,
904
+ "t_end": 2000.0,
905
+ }
906
+ if training_trace is not None
907
+ else None
908
+ )
909
+ return dict(
910
+ sys=sys,
911
+ dc_sites={
912
+ "default": DCSite(
913
+ bus="671",
914
+ bus_kv=sys["bus_kv"],
915
+ base_kw_per_phase=500.0,
916
+ total_gpu_capacity=7200,
917
+ models=models,
918
+ seed=0,
919
+ ),
920
+ },
921
+ pv_systems=[PVSystemSpec(bus="675", bus_kv=4.16, peak_kw=300.0)],
922
+ time_varying_loads=[TimeVaryingLoadSpec(bus="680", bus_kv=4.16, peak_kw=300.0)],
923
+ training_base=training_base,
924
+ ofo_config=OFOConfig(
925
+ primal_step_size=0.05,
926
+ w_throughput=0.00001,
927
+ w_switch=1.0,
928
+ voltage_gradient_scale=1e6,
929
+ v_min=V_MIN,
930
+ v_max=V_MAX,
931
+ voltage_dual_step_size=1.0,
932
+ latency_dual_step_size=1.0,
933
+ sensitivity_update_interval=300,
934
+ sensitivity_perturbation_kw=100.0,
935
+ ),
936
+ tap_schedule=TapSchedule(
937
+ (
938
+ (1500, TapPosition(regulators={"creg1a": tap(16), "creg1b": tap(6), "creg1c": tap(17)})),
939
+ (3300, TapPosition(regulators={"creg1a": tap(10), "creg1b": tap(6), "creg1c": tap(10)})),
940
+ )
941
+ ),
942
+ )
943
+
944
+
945
+ def ieee34_experiment(training_trace: TrainingTrace | None = None) -> dict:
946
+ """IEEE 34-bus: two DC sites (upstream/downstream)."""
947
+ sys = SYSTEMS["ieee34"]()
948
+ return dict(
949
+ sys=sys,
950
+ dc_sites={
951
+ "upstream": DCSite(
952
+ bus="850",
953
+ bus_kv=24.9,
954
+ base_kw_per_phase=250.0,
955
+ models=(deploy("Llama-3.1-8B", 320), deploy("Llama-3.1-70B", 80), deploy("Llama-3.1-405B", 40)),
956
+ seed=0,
957
+ total_gpu_capacity=1200,
958
+ ),
959
+ "downstream": DCSite(
960
+ bus="834",
961
+ bus_kv=24.9,
962
+ base_kw_per_phase=300.0,
963
+ models=(deploy("Qwen3-30B-A3B", 216), deploy("Qwen3-235B-A22B", 96)),
964
+ seed=42,
965
+ total_gpu_capacity=1440,
966
+ ),
967
+ },
968
+ pv_systems=[
969
+ PVSystemSpec(bus="858", bus_kv=24.9, peak_kw=130.0),
970
+ PVSystemSpec(bus="852", bus_kv=24.9, peak_kw=65.0),
971
+ ],
972
+ time_varying_loads=[
973
+ TimeVaryingLoadSpec(bus="860", bus_kv=24.9, peak_kw=80.0),
974
+ TimeVaryingLoadSpec(bus="844", bus_kv=24.9, peak_kw=120.0),
975
+ TimeVaryingLoadSpec(bus="858", bus_kv=24.9, peak_kw=50.0),
976
+ ],
977
+ training_base=None,
978
+ ofo_config=OFOConfig(
979
+ primal_step_size=0.05,
980
+ w_throughput=0.0001,
981
+ w_switch=1.0,
982
+ voltage_gradient_scale=1e6,
983
+ voltage_dual_step_size=1.0,
984
+ latency_dual_step_size=1.0,
985
+ sensitivity_update_interval=300,
986
+ sensitivity_perturbation_kw=50.0,
987
+ v_min=V_MIN,
988
+ v_max=V_MAX,
989
+ ),
990
+ tap_schedule=TapSchedule(
991
+ (
992
+ (
993
+ 1800,
994
+ TapPosition(
995
+ regulators={
996
+ "creg2a": tap(10),
997
+ "creg2b": tap(10),
998
+ "creg2c": tap(10),
999
+ }
1000
+ ),
1001
+ ),
1002
+ )
1003
+ ),
1004
+ )
1005
+
1006
+
1007
+ def ieee123_experiment(training_trace: TrainingTrace | None = None) -> dict:
1008
+ """IEEE 123-bus: four DC sites across zones."""
1009
+ sys = SYSTEMS["ieee123"]()
1010
+ return dict(
1011
+ sys=sys,
1012
+ dc_sites={
1013
+ "z1_sw": DCSite(
1014
+ bus="8",
1015
+ bus_kv=4.16,
1016
+ base_kw_per_phase=280.0,
1017
+ models=(with_ramp(deploy("Llama-3.1-8B", 800), 1200, t_start=500, t_end=1000),),
1018
+ seed=0,
1019
+ total_gpu_capacity=1200,
1020
+ ),
1021
+ "z2_nw": DCSite(
1022
+ bus="23",
1023
+ bus_kv=4.16,
1024
+ base_kw_per_phase=280.0,
1025
+ models=(with_ramp(deploy("Qwen3-30B-A3B", 460), 600, t_start=1500, t_end=2500),),
1026
+ seed=17,
1027
+ total_gpu_capacity=1200,
1028
+ ),
1029
+ "z3_se": DCSite(
1030
+ bus="60",
1031
+ bus_kv=4.16,
1032
+ base_kw_per_phase=224.0,
1033
+ models=(
1034
+ with_ramp(deploy("Llama-3.1-70B", 64), 96, t_start=700, t_end=1100),
1035
+ deploy("Llama-3.1-405B", 72),
1036
+ ),
1037
+ seed=34,
1038
+ total_gpu_capacity=960,
1039
+ ),
1040
+ "z4_ne": DCSite(
1041
+ bus="105",
1042
+ bus_kv=4.16,
1043
+ base_kw_per_phase=224.0,
1044
+ models=(with_ramp(deploy("Qwen3-235B-A22B", 96), 56, t_start=2000, t_end=2500),),
1045
+ seed=51,
1046
+ total_gpu_capacity=960,
1047
+ ),
1048
+ },
1049
+ pv_systems=[
1050
+ PVSystemSpec(bus="18", bus_kv=4.16, peak_kw=100.0),
1051
+ PVSystemSpec(bus="48", bus_kv=4.16, peak_kw=250.0),
1052
+ PVSystemSpec(bus="57", bus_kv=4.16, peak_kw=200.0),
1053
+ ],
1054
+ time_varying_loads=[
1055
+ TimeVaryingLoadSpec(bus="13", bus_kv=4.16, peak_kw=20.0),
1056
+ TimeVaryingLoadSpec(bus="86", bus_kv=4.16, peak_kw=20.0),
1057
+ TimeVaryingLoadSpec(bus="114", bus_kv=4.16, peak_kw=20.0),
1058
+ ],
1059
+ training_base=None,
1060
+ ofo_config=OFOConfig(
1061
+ primal_step_size=0.05,
1062
+ w_throughput=0.0001,
1063
+ w_switch=1.0,
1064
+ voltage_gradient_scale=1e6,
1065
+ voltage_dual_step_size=0.3,
1066
+ latency_dual_step_size=1.0,
1067
+ sensitivity_update_interval=300,
1068
+ sensitivity_perturbation_kw=10.0,
1069
+ v_min=V_MIN,
1070
+ v_max=V_MAX,
1071
+ ),
1072
+ tap_schedule=None,
1073
+ )
1074
+
1075
+
1076
+ EXPERIMENTS: dict[str, Callable[..., dict]] = {
1077
+ "ieee13": ieee13_experiment,
1078
+ "ieee34": ieee34_experiment,
1079
+ "ieee123": ieee123_experiment,
1080
+ }
examples/rl_controller/systems.py ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """IEEE test feeder constants, shared simulation constants, model-spec presets.
2
+
3
+ Feeder definitions (`ieee13`, `ieee34`, `ieee123`), the `SYSTEMS` lookup
4
+ dict, the regulator-tap helper (`tap`, `TAP_STEP`), shared simulation
5
+ constants (`DT_*`, `V_MIN`/`V_MAX`, `TOTAL_DURATION_S`, `POWER_AUG`),
6
+ hardcoded `InferenceModelSpec` presets (`LLAMA_*`, `QWEN_*`,
7
+ `MODEL_SPECS`), and the `deploy` / `with_ramp` deployment shortcuts. The
8
+ RL-specific scenario randomization, experiment factories, and
9
+ `ScenarioOpenDSSGrid` live in `scenarios.py`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from fractions import Fraction
16
+ from pathlib import Path
17
+
18
+ from openg2g.datacenter.config import (
19
+ InferenceModelSpec,
20
+ ModelDeployment,
21
+ PowerAugmentationConfig,
22
+ ReplicaSchedule,
23
+ )
24
+ from openg2g.grid.config import TapPosition
25
+
26
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
27
+ GRID_DATA_DIR = PROJECT_ROOT / "examples"
28
+
29
+ TAP_STEP = 0.00625 # Standard 32-step regulator: ±10% in 32 steps
30
+
31
+
32
+ def tap(steps: int) -> float:
33
+ """Convert integer tap step to per-unit ratio. E.g., ``tap(14)`` → 1.0875."""
34
+ return 1.0 + steps * TAP_STEP
35
+
36
+
37
+ # ── Shared simulation constants ──────────────────────────────────────────────
38
+
39
+ DT_DC = Fraction(1)
40
+ DT_GRID = Fraction(1)
41
+ DT_CTRL = Fraction(1)
42
+ V_MIN, V_MAX = 0.95, 1.05
43
+ TOTAL_DURATION_S = 3600
44
+ POWER_AUG = PowerAugmentationConfig(amplitude_scale_range=(0.98, 1.02), noise_fraction=0.005)
45
+
46
+
47
+ # ── Model specs (hardcoded; replaces config.json which master removed) ──────
48
+
49
+ LLAMA_8B = InferenceModelSpec(
50
+ model_label="Llama-3.1-8B",
51
+ model_id="meta-llama/Llama-3.1-8B-Instruct",
52
+ gpu_model="H100",
53
+ task="lm-arena-chat",
54
+ precision="bfloat16",
55
+ gpus_per_replica=1,
56
+ tensor_parallel=1,
57
+ itl_deadline_s=0.08,
58
+ batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024),
59
+ feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512),
60
+ )
61
+ LLAMA_70B = InferenceModelSpec(
62
+ model_label="Llama-3.1-70B",
63
+ model_id="meta-llama/Llama-3.1-70B-Instruct",
64
+ gpu_model="H100",
65
+ task="lm-arena-chat",
66
+ precision="bfloat16",
67
+ gpus_per_replica=4,
68
+ tensor_parallel=4,
69
+ itl_deadline_s=0.10,
70
+ batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048),
71
+ feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512),
72
+ )
73
+ LLAMA_405B = InferenceModelSpec(
74
+ model_label="Llama-3.1-405B",
75
+ model_id="meta-llama/Llama-3.1-405B-Instruct-FP8",
76
+ gpu_model="H100",
77
+ task="lm-arena-chat",
78
+ precision="fp8",
79
+ gpus_per_replica=8,
80
+ tensor_parallel=8,
81
+ itl_deadline_s=0.12,
82
+ batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512),
83
+ feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512),
84
+ )
85
+ QWEN_30B = InferenceModelSpec(
86
+ model_label="Qwen3-30B-A3B",
87
+ model_id="Qwen/Qwen3-30B-A3B-Thinking-2507",
88
+ gpu_model="H100",
89
+ task="gpqa",
90
+ precision="bfloat16",
91
+ gpus_per_replica=2,
92
+ tensor_parallel=2,
93
+ itl_deadline_s=0.06,
94
+ batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512),
95
+ feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512),
96
+ )
97
+ QWEN_235B = InferenceModelSpec(
98
+ model_label="Qwen3-235B-A22B",
99
+ model_id="Qwen/Qwen3-235B-A22B-Thinking-2507",
100
+ gpu_model="H100",
101
+ task="gpqa",
102
+ precision="bfloat16",
103
+ gpus_per_replica=8,
104
+ tensor_parallel=8,
105
+ itl_deadline_s=0.14,
106
+ batch_sizes=(8, 16, 32, 64, 96, 128, 192, 256, 384, 512),
107
+ feasible_batch_sizes=(8, 16, 32, 64, 128, 256, 512),
108
+ )
109
+ ALL_MODEL_SPECS: tuple[InferenceModelSpec, ...] = (LLAMA_8B, LLAMA_70B, LLAMA_405B, QWEN_30B, QWEN_235B)
110
+ MODEL_SPECS: dict[str, InferenceModelSpec] = {s.model_label: s for s in ALL_MODEL_SPECS}
111
+
112
+ SPECS_CACHE_DIR = PROJECT_ROOT / "data" / "specs"
113
+ TRAINING_TRACE_PATH = PROJECT_ROOT / "data" / "training_trace.csv"
114
+
115
+
116
+ def deploy(
117
+ label: str,
118
+ num_replicas: int,
119
+ initial_batch_size: int = 128,
120
+ ) -> tuple[ModelDeployment, ReplicaSchedule]:
121
+ """Shorthand: ``deploy("Llama-3.1-8B", 720, 128)`` -> (ModelDeployment, ReplicaSchedule)."""
122
+ return (
123
+ ModelDeployment(spec=MODEL_SPECS[label], initial_batch_size=initial_batch_size),
124
+ ReplicaSchedule(initial=num_replicas),
125
+ )
126
+
127
+
128
+ def with_ramp(
129
+ deployment: tuple[ModelDeployment, ReplicaSchedule],
130
+ target: int,
131
+ *,
132
+ t_start: float,
133
+ t_end: float,
134
+ ) -> tuple[ModelDeployment, ReplicaSchedule]:
135
+ """Inject a single ramp into a deploy() result. Convenience for ieee*_experiment factories."""
136
+ md, sched = deployment
137
+ return (md, sched.ramp_to(target, t_start=t_start, t_end=t_end))
138
+
139
+
140
+ # ── IEEE test feeder constants ───────────────────────────────────────────────
141
+
142
+
143
+ def ieee13() -> dict:
144
+ """IEEE 13-bus test feeder constants."""
145
+ return dict(
146
+ dss_case_dir=GRID_DATA_DIR / "ieee13",
147
+ dss_master_file="IEEE13Bus.dss",
148
+ bus_kv=4.16,
149
+ source_pu=1.0,
150
+ initial_taps=TapPosition(
151
+ regulators={
152
+ "creg1a": tap(14),
153
+ "creg1b": tap(6),
154
+ "creg1c": tap(15),
155
+ }
156
+ ),
157
+ exclude_buses=("sourcebus", "650", "rg60"),
158
+ )
159
+
160
+
161
+ def ieee34() -> dict:
162
+ """IEEE 34-bus (half-line variant) test feeder constants."""
163
+ return dict(
164
+ dss_case_dir=GRID_DATA_DIR / "ieee34",
165
+ dss_master_file="IEEE34Bus.dss",
166
+ bus_kv=24.9,
167
+ source_pu=1.09,
168
+ initial_taps=TapPosition(
169
+ regulators={
170
+ "creg1a": tap(11),
171
+ "creg1b": tap(6),
172
+ "creg1c": tap(8),
173
+ "creg2a": tap(8),
174
+ "creg2b": tap(8),
175
+ "creg2c": tap(8),
176
+ }
177
+ ),
178
+ exclude_buses=(
179
+ "sourcebus",
180
+ "800",
181
+ "802",
182
+ "806",
183
+ "808",
184
+ "810",
185
+ "812",
186
+ "814",
187
+ "888",
188
+ "890",
189
+ ),
190
+ regulator_zones={
191
+ "creg1": ["814r", "850", "816", "824", "828", "830", "854"],
192
+ "creg2": [
193
+ "852r",
194
+ "832",
195
+ "858",
196
+ "834",
197
+ "860",
198
+ "836",
199
+ "840",
200
+ "862",
201
+ "842",
202
+ "844",
203
+ "846",
204
+ "848",
205
+ ],
206
+ },
207
+ )
208
+
209
+
210
+ def ieee123() -> dict:
211
+ """IEEE 123-bus test feeder constants."""
212
+ return dict(
213
+ dss_case_dir=GRID_DATA_DIR / "ieee123",
214
+ dss_master_file="IEEE123Bus.dss",
215
+ bus_kv=4.16,
216
+ source_pu=1.0,
217
+ initial_taps=TapPosition(
218
+ regulators={
219
+ "creg1a": tap(9),
220
+ "creg2a": tap(5),
221
+ "creg3a": tap(5),
222
+ "creg3c": tap(5),
223
+ "creg4a": tap(14),
224
+ "creg4b": tap(1),
225
+ "creg4c": tap(4),
226
+ }
227
+ ),
228
+ exclude_buses=(
229
+ "sourcebus",
230
+ "150",
231
+ "150r",
232
+ "149",
233
+ "9r",
234
+ "25r",
235
+ "160r",
236
+ "61s",
237
+ "610",
238
+ "300_open",
239
+ "94_open",
240
+ "135",
241
+ ),
242
+ zones={
243
+ "z1_sw": [
244
+ "1",
245
+ "2",
246
+ "3",
247
+ "4",
248
+ "5",
249
+ "6",
250
+ "7",
251
+ "8",
252
+ "9",
253
+ "10",
254
+ "11",
255
+ "12",
256
+ "13",
257
+ "14",
258
+ "15",
259
+ "16",
260
+ "17",
261
+ "18",
262
+ "19",
263
+ "20",
264
+ "34",
265
+ ],
266
+ "z2_nw": [
267
+ "21",
268
+ "22",
269
+ "23",
270
+ "24",
271
+ "25",
272
+ "26",
273
+ "27",
274
+ "28",
275
+ "29",
276
+ "30",
277
+ "31",
278
+ "32",
279
+ "33",
280
+ "35",
281
+ "36",
282
+ "37",
283
+ "38",
284
+ "39",
285
+ "40",
286
+ "41",
287
+ "42",
288
+ "43",
289
+ "44",
290
+ "45",
291
+ "46",
292
+ "47",
293
+ "48",
294
+ "49",
295
+ "50",
296
+ "51",
297
+ ],
298
+ "z3_se": [
299
+ "52",
300
+ "53",
301
+ "54",
302
+ "55",
303
+ "56",
304
+ "57",
305
+ "58",
306
+ "59",
307
+ "60",
308
+ "61",
309
+ "62",
310
+ "63",
311
+ "64",
312
+ "65",
313
+ "66",
314
+ "67",
315
+ "68",
316
+ "69",
317
+ "70",
318
+ "71",
319
+ "72",
320
+ "73",
321
+ "74",
322
+ "75",
323
+ "76",
324
+ "77",
325
+ "78",
326
+ "79",
327
+ "80",
328
+ "81",
329
+ "82",
330
+ "83",
331
+ "84",
332
+ "85",
333
+ "86",
334
+ "87",
335
+ "88",
336
+ "89",
337
+ "90",
338
+ "91",
339
+ "92",
340
+ "93",
341
+ "94",
342
+ "95",
343
+ "96",
344
+ ],
345
+ "z4_ne": [
346
+ "97",
347
+ "98",
348
+ "99",
349
+ "100",
350
+ "101",
351
+ "102",
352
+ "103",
353
+ "104",
354
+ "105",
355
+ "106",
356
+ "107",
357
+ "108",
358
+ "109",
359
+ "110",
360
+ "111",
361
+ "112",
362
+ "113",
363
+ "114",
364
+ "115",
365
+ "116",
366
+ "117",
367
+ "118",
368
+ "119",
369
+ "120",
370
+ "121",
371
+ "122",
372
+ "123",
373
+ ],
374
+ },
375
+ )
376
+
377
+
378
+ SYSTEMS = {"ieee13": ieee13, "ieee34": ieee34, "ieee123": ieee123}
379
+
380
+
381
+ # ── Profile helpers ───────────────────────────────────────────────────────────
382
+
383
+
384
+ def _smooth_bump(t: float, t_center: float, half_width: float) -> float:
385
+ dt = abs(t - t_center)
386
+ if dt >= half_width:
387
+ return 0.0
388
+ x = dt / half_width
389
+ return (1 - x * x) ** 2
390
+
391
+
392
+ def _smoothstep(t: float, t_start: float, t_end: float) -> float:
393
+ """Cubic Hermite smoothstep: zero-derivative at both endpoints."""
394
+ if t_end <= t_start:
395
+ return 1.0 if t >= t_start else 0.0
396
+ if t <= t_start:
397
+ return 0.0
398
+ if t >= t_end:
399
+ return 1.0
400
+ x = (t - t_start) / (t_end - t_start)
401
+ return x * x * (3.0 - 2.0 * x)
402
+
403
+
404
+ def _irregular_fluct(t: float, seed: float = 0.0) -> float:
405
+ """Irregular fluctuation via superposition of incommensurate frequencies."""
406
+ s = seed
407
+ f1 = 0.06 * math.sin(2 * math.pi * t / 173.0 + s)
408
+ f2 = 0.05 * math.sin(2 * math.pi * t / 97.3 + s * 2.3)
409
+ f3 = 0.04 * math.sin(2 * math.pi * t / 251.7 + s * 0.7)
410
+ f4 = 0.03 * math.sin(2 * math.pi * t / 41.9 + s * 4.1)
411
+ f5 = 0.02 * math.sin(2 * math.pi * t / 317.3 + s * 1.9)
412
+ return 1.0 + f1 + f2 + f3 + f4 + f5
413
+
414
+
415
+ def pv_profile_kw(t: float, peak_kw: float, site_idx: int = 0) -> float:
416
+ """Solar PV output (kW per phase) with per-site cloud patterns."""
417
+ T = TOTAL_DURATION_S
418
+ if site_idx == 0:
419
+ trend = 0.85 - 0.30 * (t / T)
420
+ cloud = 1.0
421
+ cloud -= 0.55 * _smooth_bump(t, 600, 120)
422
+ cloud -= 0.40 * _smooth_bump(t, 2100, 180)
423
+ fluct = _irregular_fluct(t, seed=0.3)
424
+ return max(0.0, peak_kw * trend * max(cloud, 0.05) * fluct)
425
+ elif site_idx == 1:
426
+ ramp = 0.55 + 0.40 * _smooth_bump(t, 1200, 900)
427
+ cloud = 1.0
428
+ cloud -= 0.60 * _smooth_bump(t, 1680, 240)
429
+ cloud -= 0.25 * _smooth_bump(t, 2400, 150)
430
+ fluct = _irregular_fluct(t, seed=2.1)
431
+ return max(0.0, peak_kw * ramp * max(cloud, 0.05) * fluct)
432
+ else:
433
+ ramp = 0.30 + 0.65 * min(1.0, t / 900.0)
434
+ cloud = 1.0
435
+ cloud -= 0.70 * _smooth_bump(t, 2700, 300)
436
+ cloud -= 0.30 * _smooth_bump(t, 1200, 100)
437
+ fluct = _irregular_fluct(t, seed=2.0 + site_idx * 3.7)
438
+ return max(0.0, peak_kw * ramp * max(cloud, 0.05) * fluct)
439
+
440
+
441
+ def load_profile_kw(t: float, peak_kw: float, site_idx: int = 0) -> float:
442
+ fluct_period = 130.0 + site_idx * 37
443
+ fluct = 1.0 + 0.06 * math.sin(2 * math.pi * t / fluct_period + site_idx * 1.4)
444
+
445
+ if site_idx == 0:
446
+ base = 0.15 + 0.85 * _smooth_bump(t, 2280, 1400)
447
+ surge = 0.20 * _smooth_bump(t, 2280, 180)
448
+ return max(0.0, peak_kw * (base + surge) * fluct)
449
+ elif site_idx == 1:
450
+ base = 0.10
451
+ base += 0.50 * _smooth_bump(t, 1500, 600)
452
+ base += 0.80 * _smooth_bump(t, 2880, 500)
453
+ return max(0.0, peak_kw * base * fluct)
454
+ elif site_idx == 2:
455
+ base = 0.80 - 0.55 * _smooth_bump(t, 1800, 1200)
456
+ surge = 0.70 * _smooth_bump(t, 2520, 400)
457
+ return max(0.0, peak_kw * (base + surge) * fluct)
458
+ elif site_idx == 3:
459
+ base = 0.10 + 0.90 * _smooth_bump(t, 3120, 800)
460
+ return max(0.0, peak_kw * base * fluct)
461
+ else:
462
+ base = 0.10
463
+ base += 0.60 * _smooth_bump(t, 1080, 300)
464
+ base += 0.75 * _smooth_bump(t, 2100, 350)
465
+ base += 0.90 * _smooth_bump(t, 3300, 300)
466
+ return max(0.0, peak_kw * base * fluct)
examples/rl_controller/train_ppo.py ADDED
@@ -0,0 +1,850 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train a PPO controller for batch-size voltage regulation.
2
+
3
+ Trains one PPO model per datacenter site. For multi-DC systems (ieee34,
4
+ ieee123), each site gets its own policy while other sites use fixed
5
+ mid-range batch sizes during that site's training.
6
+
7
+ Usage:
8
+ python examples/rl_controller/train_ppo.py --system ieee13 \\
9
+ --scenario-library examples/rl_controller/outputs/ieee13/scenario_library/train_n500 \\
10
+ --total-timesteps 2000000
11
+ python examples/rl_controller/train_ppo.py --system ieee13 \\
12
+ --scenario-library .../train_n500 --obs-mode system-summary-only
13
+ python examples/rl_controller/train_ppo.py --system ieee13 \\
14
+ --scenario-library .../train_n500 --hidden-dims 256 256 256 --n-envs 8
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import csv
20
+ import logging
21
+ import sys
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ import tyro
27
+ from env import (
28
+ BatchSizeEnv,
29
+ ObservationConfig,
30
+ RewardConfig,
31
+ ScenarioLibrary,
32
+ SharedBatchSizeEnv,
33
+ compute_bus_phase_groups,
34
+ compute_zone_mask,
35
+ )
36
+ from scenarios import (
37
+ EXPERIMENTS,
38
+ DCSite,
39
+ ScenarioOpenDSSGrid,
40
+ )
41
+
42
+ from openg2g.controller.tap_schedule import TapScheduleController
43
+ from openg2g.datacenter.config import (
44
+ DatacenterConfig,
45
+ InferenceModelSpec,
46
+ ReplicaSchedule,
47
+ TrainingRun,
48
+ )
49
+ from openg2g.datacenter.offline import OfflineDatacenter, OfflineWorkload
50
+ from openg2g.datacenter.workloads.inference import InferenceData
51
+ from openg2g.datacenter.workloads.training import TrainingTrace
52
+ from openg2g.grid.config import TapSchedule
53
+
54
+ from systems import (
55
+ DT_CTRL,
56
+ DT_DC,
57
+ DT_GRID,
58
+ POWER_AUG,
59
+ SPECS_CACHE_DIR,
60
+ TRAINING_TRACE_PATH,
61
+ V_MAX,
62
+ V_MIN,
63
+ )
64
+
65
+ logger = logging.getLogger(__name__)
66
+
67
+
68
+ def make_sim_factory(
69
+ exp: dict,
70
+ inference_data: InferenceData,
71
+ ):
72
+ """Return a callable that builds fresh simulation components.
73
+
74
+ Returns `(make_sim, all_site_specs, all_replica_counts, all_initial_batch_sizes)`
75
+ where `make_sim(scenario_override=None)` produces
76
+ `(dict[str, DatacenterBackend], grid, tap_ctrl)`. When the env is sampling
77
+ from a `ScenarioLibrary`, it passes the already-materialized scenario dict
78
+ as `scenario_override`; otherwise `make_sim()` falls back to the
79
+ experiment's defaults.
80
+ """
81
+ sys = exp["sys"]
82
+ dc_sites: dict[str, DCSite] = exp["dc_sites"]
83
+ pv_systems_base = exp.get("pv_systems", [])
84
+ tvl_base = exp.get("time_varying_loads", [])
85
+ training_base = exp.get("training_base")
86
+
87
+ is_single_dc = len(dc_sites) == 1
88
+ if is_single_dc:
89
+ orig_sid = next(iter(dc_sites))
90
+ dc_sites = {"_default": dc_sites[orig_sid]}
91
+
92
+ all_site_specs: dict[str, tuple[InferenceModelSpec, ...]] = {}
93
+ all_replica_counts: dict[str, dict[str, int]] = {}
94
+ all_initial_batch_sizes: dict[str, dict[str, int]] = {}
95
+ site_inference: dict[str, InferenceData] = {}
96
+ for sid, site in dc_sites.items():
97
+ specs = tuple(md.spec for md, _ in site.models)
98
+ all_site_specs[sid] = specs
99
+ all_replica_counts[sid] = {md.spec.model_label: sched.initial for md, sched in site.models}
100
+ all_initial_batch_sizes[sid] = {md.spec.model_label: md.initial_batch_size for md, _ in site.models}
101
+ site_inference[sid] = inference_data.filter_models(specs)
102
+
103
+ _episode_counter = [0]
104
+
105
+ def make_sim(scenario_override: dict | None = None):
106
+ _episode_counter[0] += 1
107
+
108
+ if scenario_override is not None:
109
+ sites = scenario_override["dc_sites"]
110
+ # Library was built keyed by the experiment's DC site id (e.g. "default");
111
+ # the grid expects "_default" for single-DC. Remap once here.
112
+ if is_single_dc and "_default" not in sites:
113
+ orig = next(iter(sites))
114
+ sites = {"_default": sites[orig]}
115
+ pv_systems = scenario_override["pv_systems"]
116
+ tvl = scenario_override["tvl"]
117
+ training = scenario_override["training_run"]
118
+ else:
119
+ sites = dc_sites
120
+ pv_systems = pv_systems_base
121
+ tvl = tvl_base
122
+ if training_base is not None:
123
+ training = TrainingRun(
124
+ n_gpus=training_base["n_gpus"],
125
+ trace=training_base["trace"],
126
+ target_peak_W_per_gpu=training_base["target_peak_W_per_gpu"],
127
+ ).at(t_start=training_base["t_start"], t_end=training_base["t_end"])
128
+ else:
129
+ training = None
130
+
131
+ datacenters: dict[str, OfflineDatacenter] = {}
132
+ for sid, site in sites.items():
133
+ dc_config = DatacenterConfig(gpus_per_server=8, base_kw_per_phase=site.base_kw_per_phase)
134
+ replica_schedules: dict[str, ReplicaSchedule] = {md.spec.model_label: sched for md, sched in site.models}
135
+ initial_bs = {md.spec.model_label: md.initial_batch_size for md, _ in site.models}
136
+ wl_kwargs: dict = {
137
+ "inference_data": site_inference[sid],
138
+ "replica_schedules": replica_schedules,
139
+ "initial_batch_sizes": initial_bs,
140
+ }
141
+ if training is not None:
142
+ wl_kwargs["training"] = training
143
+ workload = OfflineWorkload(**wl_kwargs)
144
+ datacenters[sid] = OfflineDatacenter(
145
+ dc_config,
146
+ workload,
147
+ name=sid,
148
+ dt_s=DT_DC,
149
+ seed=site.seed,
150
+ power_augmentation=POWER_AUG,
151
+ total_gpu_capacity=site.total_gpu_capacity,
152
+ )
153
+
154
+ dc_config_pf = DatacenterConfig(base_kw_per_phase=0).power_factor
155
+ exclude = tuple(sys.get("exclude_buses", ()))
156
+ grid = ScenarioOpenDSSGrid(
157
+ pv_systems=pv_systems,
158
+ time_varying_loads=tvl,
159
+ source_pu=sys["source_pu"],
160
+ dss_case_dir=sys["dss_case_dir"],
161
+ dss_master_file=sys["dss_master_file"],
162
+ dt_s=DT_GRID,
163
+ initial_tap_position=sys["initial_taps"],
164
+ exclude_buses=exclude,
165
+ )
166
+ for sid, dc in datacenters.items():
167
+ site = sites[sid]
168
+ grid.attach_dc(
169
+ dc,
170
+ bus=site.bus,
171
+ connection_type=site.connection_type,
172
+ power_factor=dc_config_pf,
173
+ )
174
+
175
+ tap_ctrl = TapScheduleController(schedule=TapSchedule(()), dt_s=DT_CTRL)
176
+ return datacenters, grid, tap_ctrl
177
+
178
+ return make_sim, all_site_specs, all_replica_counts, all_initial_batch_sizes
179
+
180
+
181
+ def _new_episode_acc() -> dict:
182
+ return {
183
+ "voltage": 0.0,
184
+ "throughput": 0.0,
185
+ "latency": 0.0,
186
+ "switch": 0.0,
187
+ "safe": 0.0,
188
+ "max_under": 0.0,
189
+ "max_over": 0.0,
190
+ "viol_frac_sum": 0.0,
191
+ "n_steps": 0,
192
+ }
193
+
194
+
195
+ class TrainingMetricsCallback:
196
+ """SB3 BaseCallback that aggregates per-episode reward components and voltage stats.
197
+
198
+ Writes one CSV row per completed episode and mirrors the same metrics to
199
+ the SB3 TensorBoard logger so they show up alongside built-in PPO metrics.
200
+ Imported lazily inside `main` so SB3 isn't a hard import for tooling that
201
+ only wants the experiment definitions.
202
+ """
203
+
204
+ def __new__(cls, csv_path: Path):
205
+ # Late-bind to BaseCallback so this module is importable without SB3.
206
+ from stable_baselines3.common.callbacks import BaseCallback
207
+
208
+ class _Impl(BaseCallback):
209
+ def __init__(self, csv_path: Path):
210
+ super().__init__(verbose=0)
211
+ self.csv_path = csv_path
212
+ self._per_env: dict[int, dict] = {}
213
+ self._ep_count = 0
214
+ self._fp = None
215
+ self._writer = None
216
+
217
+ def _on_training_start(self) -> None:
218
+ self._fp = open(self.csv_path, "w", buffering=1, newline="") # noqa: SIM115
219
+ self._writer = csv.writer(self._fp)
220
+ self._writer.writerow(
221
+ [
222
+ "episode",
223
+ "timestep",
224
+ "ep_reward",
225
+ "ep_length",
226
+ "voltage",
227
+ "throughput",
228
+ "latency",
229
+ "switch",
230
+ "safe",
231
+ "max_undervoltage",
232
+ "max_overvoltage",
233
+ "mean_violation_frac",
234
+ ]
235
+ )
236
+
237
+ def _on_step(self) -> bool:
238
+ infos = self.locals.get("infos", []) or []
239
+ for env_idx, info in enumerate(infos):
240
+ acc = self._per_env.setdefault(env_idx, _new_episode_acc())
241
+ rc = info.get("reward_components")
242
+ if rc is not None:
243
+ acc["voltage"] += rc.get("voltage", 0.0)
244
+ acc["throughput"] += rc.get("throughput", 0.0)
245
+ acc["latency"] += rc.get("latency", 0.0)
246
+ acc["switch"] += rc.get("switch", 0.0)
247
+ acc["safe"] += rc.get("safe", 0.0)
248
+ vs = info.get("voltage_stats")
249
+ if vs is not None:
250
+ if vs.get("max_under", 0.0) > acc["max_under"]:
251
+ acc["max_under"] = vs["max_under"]
252
+ if vs.get("max_over", 0.0) > acc["max_over"]:
253
+ acc["max_over"] = vs["max_over"]
254
+ acc["viol_frac_sum"] += vs.get("violation_frac", 0.0)
255
+ acc["n_steps"] += 1
256
+
257
+ # Monitor wrapper injects an "episode" key on done
258
+ ep = info.get("episode")
259
+ if ep is not None:
260
+ self._ep_count += 1
261
+ n = max(acc["n_steps"], 1)
262
+ row = [
263
+ self._ep_count,
264
+ self.num_timesteps,
265
+ float(ep["r"]),
266
+ int(ep["l"]),
267
+ acc["voltage"],
268
+ acc["throughput"],
269
+ acc["latency"],
270
+ acc["switch"],
271
+ acc["safe"],
272
+ acc["max_under"],
273
+ acc["max_over"],
274
+ acc["viol_frac_sum"] / n,
275
+ ]
276
+ self._writer.writerow(row)
277
+ # Mirror to TB
278
+ self.logger.record("custom/voltage_pen", acc["voltage"])
279
+ self.logger.record("custom/throughput_bonus", acc["throughput"])
280
+ self.logger.record("custom/latency_pen", acc["latency"])
281
+ self.logger.record("custom/switch_pen", acc["switch"])
282
+ self.logger.record("custom/safe_bonus", acc["safe"])
283
+ self.logger.record("custom/max_undervoltage", acc["max_under"])
284
+ self.logger.record("custom/max_overvoltage", acc["max_over"])
285
+ self.logger.record("custom/violation_frac", acc["viol_frac_sum"] / n)
286
+ self._per_env[env_idx] = _new_episode_acc()
287
+ return True
288
+
289
+ def _on_training_end(self) -> None:
290
+ if self._fp is not None:
291
+ self._fp.close()
292
+ self._fp = None
293
+
294
+ return _Impl(csv_path)
295
+
296
+
297
+ def plot_training_progress(csv_path: Path, output_path: Path, label: str) -> Path | None:
298
+ """Read the per-episode metrics CSV and emit a 2x2 PNG dashboard.
299
+
300
+ Returns the output path on success, or `None` if the CSV is empty.
301
+ """
302
+ import matplotlib
303
+
304
+ matplotlib.use("Agg")
305
+ import matplotlib.pyplot as plt
306
+
307
+ rows: list[dict] = []
308
+ with open(csv_path, newline="") as fp:
309
+ for r in csv.DictReader(fp):
310
+ if any(v is None for v in r.values()):
311
+ continue # skip partial rows from interrupted buffered writes
312
+ rows.append({k: float(v) if k not in ("episode", "ep_length") else int(float(v)) for k, v in r.items()})
313
+ if not rows:
314
+ return None
315
+
316
+ eps = np.array([r["episode"] for r in rows])
317
+ ep_reward = np.array([r["ep_reward"] for r in rows])
318
+ voltage = np.array([r["voltage"] for r in rows])
319
+ throughput = np.array([r["throughput"] for r in rows])
320
+ latency = np.array([r["latency"] for r in rows])
321
+ switch = np.array([r["switch"] for r in rows])
322
+ max_under = np.array([r["max_undervoltage"] for r in rows])
323
+ max_over = np.array([r["max_overvoltage"] for r in rows])
324
+ viol_frac = np.array([r["mean_violation_frac"] for r in rows])
325
+
326
+ def smooth(arr: np.ndarray, window: int) -> np.ndarray:
327
+ if window <= 1 or len(arr) < 2:
328
+ return arr
329
+ kernel = np.ones(window) / window
330
+ return np.convolve(arr, kernel, mode="same")
331
+
332
+ window = max(1, len(rows) // 20)
333
+
334
+ fig, axes = plt.subplots(2, 2, figsize=(12, 9))
335
+
336
+ ax = axes[0, 0]
337
+ ax.plot(eps, ep_reward, alpha=0.3, label="raw")
338
+ ax.plot(eps, smooth(ep_reward, window), label=f"smooth (w={window})", linewidth=2)
339
+ ax.set_xlabel("Episode")
340
+ ax.set_ylabel("Episode reward")
341
+ ax.set_title(f"Learning curve: {label}")
342
+ ax.legend()
343
+ ax.grid(alpha=0.3)
344
+
345
+ ax = axes[0, 1]
346
+ ax.plot(eps, smooth(voltage, window), label="voltage", color="C3")
347
+ ax.plot(eps, smooth(throughput, window), label="throughput", color="C2")
348
+ ax.plot(eps, smooth(latency, window), label="latency", color="C1")
349
+ ax.plot(eps, smooth(switch, window), label="switch", color="C0")
350
+ ax.set_xlabel("Episode")
351
+ ax.set_ylabel("Component reward (per episode)")
352
+ ax.set_title("Reward decomposition")
353
+ ax.axhline(0, color="k", linewidth=0.5)
354
+ ax.legend()
355
+ ax.grid(alpha=0.3)
356
+
357
+ ax = axes[1, 0]
358
+ ax.plot(eps, smooth(max_under, window), label="max undervoltage", color="C0")
359
+ ax.plot(eps, smooth(max_over, window), label="max overvoltage", color="C3")
360
+ ax.set_xlabel("Episode")
361
+ ax.set_ylabel("Worst per-step deviation (pu)")
362
+ ax.set_title("Voltage violation magnitude")
363
+ ax.legend()
364
+ ax.grid(alpha=0.3)
365
+
366
+ ax = axes[1, 1]
367
+ ax.plot(eps, smooth(viol_frac, window), color="C4")
368
+ ax.set_xlabel("Episode")
369
+ ax.set_ylabel("Mean fraction of bus-phases violating")
370
+ ax.set_title("Violation prevalence")
371
+ ax.set_ylim(0, max(0.05, float(viol_frac.max()) * 1.1))
372
+ ax.grid(alpha=0.3)
373
+
374
+ fig.suptitle(f"PPO training progress: {label}", fontsize=14)
375
+ fig.tight_layout()
376
+ fig.savefig(output_path, dpi=110)
377
+ plt.close(fig)
378
+ return output_path
379
+
380
+
381
+ @dataclass
382
+ class Args:
383
+ system: str = "ieee13"
384
+ """System name (ieee13, ieee34, ieee123)."""
385
+ total_timesteps: int = 200_000
386
+ """Total environment timesteps for training (per site). Counted across ALL parallel envs."""
387
+ learning_rate: float = 1e-4
388
+ """PPO learning rate (initial value if lr_schedule != 'constant')."""
389
+ lr_schedule: str = "constant"
390
+ """Learning rate schedule: 'constant' or 'linear' (decays to 0 over training)."""
391
+ n_steps: int = 3600
392
+ """Rollout length per environment (one full simulated hour)."""
393
+ checkpoint_interval_rollouts: int = 10
394
+ """Checkpoint cadence in rollouts. With the defaults (n_steps=3600, n_envs=8), 10 rollouts ≈ 288000 env transitions, so a 2M-step run produces ~7 snapshots.""" # noqa: E501
395
+ batch_size: int = 128
396
+ """Minibatch size for PPO updates."""
397
+ n_epochs: int = 10
398
+ """Number of PPO epochs per update."""
399
+ gamma: float = 0.99
400
+ """Discount factor."""
401
+ gae_lambda: float = 0.95
402
+ """GAE lambda."""
403
+ clip_range: float = 0.2
404
+ """PPO clipping range."""
405
+ ent_coef: float = 0.01
406
+ """Entropy coefficient."""
407
+ hidden_dims: tuple[int, ...] = (128, 128)
408
+ """Hidden layer widths for the MLP policy/value network. Pass multiple values for a deeper net, e.g. --hidden-dims 256 256 256.""" # noqa: E501
409
+ w_voltage: float = 1000.0
410
+ """Reward weight for voltage violations."""
411
+ w_throughput: float = 0.0
412
+ """Reward weight for throughput. Default 0 to isolate the voltage-control objective."""
413
+ w_latency: float = 0.0
414
+ """Reward weight for latency violations. Default 0 to isolate the voltage-control objective."""
415
+ w_switch: float = 0.01
416
+ """Reward weight for switching cost (penalizes |log2(batch_t) - log2(batch_{t-1})| summed over models). Without this, randomized-scenario runs converge to a near-uniform action distribution and the deterministic eval policy ends up flipping batch sizes on every step. 0.01 is a gentle prior: much smaller than voltage_pen so it acts as a tie-breaker, not a co-equal objective.""" # noqa: E501
417
+ w_safe: float = 0.0
418
+ """Small positive reward for staying in the safe voltage range. Each step adds +w_safe * (fraction of bus-phases within [v_min, v_max]). Default 0 (disabled). Recommended: 0.01.""" # noqa: E501
419
+ switch_mode: str = "magnitude"
420
+ """Switch penalty mode: 'magnitude' (original log-ratio), 'binary' (fixed cost per change), or 'cooldown' (decaying cost, recent changes expensive).""" # noqa: E501
421
+ switch_cooldown_tau: float = 30.0
422
+ """Time constant (steps) for cooldown switch penalty. Only used with --switch-mode cooldown."""
423
+ action_mode: str = "delta"
424
+ """Action space mode: 'delta' (per-model {-1,0,+1}, 3^N actions) or
425
+ 'coupled' (all models move by the same delta, 13 actions)."""
426
+ reward_clip: float = 0.0
427
+ """If > 0, clip per-step reward to [-reward_clip, +inf). Prevents catastrophic scenarios from dominating PPO updates. Recommended: 1.0 (affects ~4% of episodes, leaving normal training signal intact).""" # noqa: E501
428
+ vec_normalize: bool = True
429
+ """Wrap the vec env with SB3 VecNormalize (running obs/reward normalization). Strongly recommended: voltage_pen variance across scenarios is huge and tanks value-function learning without it.""" # noqa: E501
430
+ obs_mode: str = "full-voltage"
431
+ """Voltage observation mode. Choices:
432
+ - "full-voltage": all bus-phase raw voltages + per-system summary (3 global scalars).
433
+ - "per-bus-summary": per-bus [min,max] phase voltage + per-zone-summary (if zones exist) or per-system summary.
434
+ - "per-zone-summary": per-zone summary only (3 scalars/zone, no raw voltages). Requires zones.
435
+ - "system-summary-only": 3 global scalars only (no raw voltages, no zone breakdown).
436
+ """
437
+ shared: bool = True
438
+ """Train one shared PPO for all sites (instead of separate per-site)."""
439
+ total_duration_s: int = 3600
440
+ """Episode length in simulated seconds. Lower for fast smoke tests (e.g. 300 = 5 simulated minutes)."""
441
+ n_envs: int = 1
442
+ """Number of parallel rollout environments. >1 uses SubprocVecEnv (each subprocess builds its own OpenDSS instance to avoid global-state conflicts).""" # noqa: E501
443
+ tensorboard: bool = True
444
+ """Write TensorBoard logs to <output_dir>/tb. View with `tensorboard --logdir <output_dir>/tb`."""
445
+ plot: bool = True
446
+ """Generate matplotlib training-progress plots after each model finishes."""
447
+ output_dir: str = ""
448
+ """Output directory (default: outputs/<system>/ppo)."""
449
+ log_level: str = "INFO"
450
+ """Logging verbosity."""
451
+ scenario_library: str = ""
452
+ """Path to a scenario library directory built by build_library.py (containing metadata.json + traces.npz). When set, episodes are sampled from this library.""" # noqa: E501
453
+ ofo_baseline: bool = False
454
+ """Subtract the OFO oracle's per-step voltage penalty from PPO's reward (requires --scenario-library). Disabling gives the raw voltage penalty as reward.""" # noqa: E501
455
+ truncate_episode: bool = True
456
+ """Fast-forward past the initial quiet period and terminate after the last violation (requires --scenario-library with t_control_start/end). Disabling uses full 3600s episodes.""" # noqa: E501
457
+ seed: int = 42
458
+ """Random seed."""
459
+ init_from: str = ""
460
+ """Path to a PPO checkpoint .zip to warm-start from (e.g. ppo_1152000_steps.zip). If a sibling ppo_vecnormalize_<steps>.pkl exists and --vec-normalize is set, its stats are loaded too. Hyperparameters stored in the checkpoint (lr, ent_coef, clip_range, …) are preserved; pass CLI flags only to change the env-side reward weights.""" # noqa: E501
461
+
462
+
463
+ def main() -> None:
464
+ args = tyro.cli(Args)
465
+
466
+ logging.basicConfig(
467
+ level=getattr(logging, args.log_level),
468
+ format="%(levelname)s %(asctime)s [%(name)s:%(lineno)d] %(message)s",
469
+ datefmt="%H:%M:%S",
470
+ )
471
+ logging.getLogger("openg2g.coordinator").setLevel(logging.WARNING)
472
+ logging.getLogger("openg2g.datacenter").setLevel(logging.WARNING)
473
+ logging.getLogger("openg2g.grid").setLevel(logging.WARNING)
474
+
475
+ if args.system not in EXPERIMENTS:
476
+ logger.error("Unknown system: %s. Available: %s", args.system, list(EXPERIMENTS.keys()))
477
+ sys.exit(1)
478
+
479
+ training_trace = TrainingTrace.ensure(TRAINING_TRACE_PATH)
480
+
481
+ exp = EXPERIMENTS[args.system](training_trace)
482
+ script_dir = Path(__file__).resolve().parent
483
+ output_dir = script_dir / "outputs" / args.system / (args.output_dir or "ppo")
484
+ output_dir.mkdir(parents=True, exist_ok=True)
485
+
486
+ # Collect all model specs across sites
487
+ dc_sites: dict[str, DCSite] = exp["dc_sites"]
488
+ all_specs: list[InferenceModelSpec] = []
489
+ for site in dc_sites.values():
490
+ all_specs.extend(md.spec for md, _ in site.models)
491
+ all_specs_tuple = tuple({s.model_label: s for s in all_specs}.values())
492
+
493
+ # Load data via the per-spec content-addressed cache under SPECS_CACHE_DIR.
494
+ # InferenceData.ensure regenerates only specs whose manifest is missing.
495
+ logger.info("Loading data for %s...", args.system)
496
+ inference_data = InferenceData.ensure(
497
+ SPECS_CACHE_DIR,
498
+ all_specs_tuple,
499
+ plot=False,
500
+ dt_s=float(DT_DC),
501
+ )
502
+
503
+ from openg2g.controller.ofo import LogisticModelStore
504
+
505
+ logistic_models = LogisticModelStore.ensure(
506
+ SPECS_CACHE_DIR,
507
+ all_specs_tuple,
508
+ plot=False,
509
+ )
510
+
511
+ scenario_lib = None
512
+ if args.scenario_library:
513
+ scenario_lib = ScenarioLibrary(args.scenario_library)
514
+ logger.info(
515
+ "Loaded scenario library with %d scenarios from %s (ofo_baseline=%s, truncate=%s)",
516
+ len(scenario_lib),
517
+ args.scenario_library,
518
+ args.ofo_baseline,
519
+ args.truncate_episode,
520
+ )
521
+
522
+ make_sim, all_site_specs, all_replica_counts, all_initial_batch_sizes = make_sim_factory(
523
+ exp,
524
+ inference_data,
525
+ )
526
+
527
+ # Probe grid for v_index and n_bus_phases
528
+ probe_dcs, probe_grid, _ = make_sim()
529
+ for dc in probe_dcs.values():
530
+ dc.do_reset()
531
+ dc.start()
532
+ probe_grid.do_reset()
533
+ probe_grid.start()
534
+ v_index = probe_grid.v_index
535
+ n_bus_phases_full = len(v_index)
536
+ probe_grid.stop()
537
+ for dc in probe_dcs.values():
538
+ dc.stop()
539
+
540
+ _VALID_OBS_MODES = {"full-voltage", "per-bus-summary", "per-zone-summary", "system-summary-only"}
541
+ if args.obs_mode not in _VALID_OBS_MODES:
542
+ raise ValueError(f"--obs-mode must be one of {sorted(_VALID_OBS_MODES)}, got {args.obs_mode!r}")
543
+
544
+ if args.action_mode not in ("delta", "coupled"):
545
+ raise ValueError(f"--action-mode must be 'delta' or 'coupled', got {args.action_mode!r}")
546
+
547
+ # Zone info needed early for per-zone-summary validation
548
+ zones: dict[str, list[str]] | None = exp.get("sys", {}).get("zones")
549
+
550
+ if args.obs_mode == "per-zone-summary" and zones is None:
551
+ raise ValueError("--obs-mode per-zone-summary requires the system to have zones defined (e.g. ieee123)")
552
+
553
+ if args.obs_mode == "full-voltage":
554
+ n_bus_phases = n_bus_phases_full
555
+ bus_phase_groups = None
556
+ elif args.obs_mode == "per-bus-summary":
557
+ bus_phase_groups = compute_bus_phase_groups(v_index)
558
+ n_bus_phases = 2 * len(bus_phase_groups)
559
+ else: # per-zone-summary or system-summary-only
560
+ n_bus_phases = 0
561
+ bus_phase_groups = None
562
+
563
+ logger.info(
564
+ "Grid has %d bus-phase pairs across %d buses; obs_mode=%s, n_bus_phases=%d",
565
+ n_bus_phases_full,
566
+ len(set(b for b, _ in v_index)),
567
+ args.obs_mode,
568
+ n_bus_phases,
569
+ )
570
+
571
+ reward_config = RewardConfig(
572
+ w_voltage=args.w_voltage,
573
+ w_throughput=args.w_throughput,
574
+ w_latency=args.w_latency,
575
+ w_switch=args.w_switch,
576
+ w_safe=args.w_safe,
577
+ v_min=V_MIN,
578
+ v_max=V_MAX,
579
+ reward_clip=args.reward_clip,
580
+ switch_mode=args.switch_mode,
581
+ switch_cooldown_tau=args.switch_cooldown_tau,
582
+ )
583
+
584
+ site_ids = list(all_site_specs.keys())
585
+
586
+ from stable_baselines3 import PPO
587
+ from stable_baselines3.common.callbacks import CallbackList, CheckpointCallback
588
+ from stable_baselines3.common.monitor import Monitor
589
+ from stable_baselines3.common.vec_env import DummyVecEnv, SubprocVecEnv, VecNormalize
590
+
591
+ def _train_and_save(env_factory, label: str, save_name: str) -> None:
592
+ """Build a (possibly vectorized) env from `env_factory` and train one PPO model.
593
+
594
+ `env_factory` is a zero-arg callable returning a fresh `BatchSizeEnv`
595
+ (or subclass). It is invoked once per parallel environment, wrapped with
596
+ `Monitor`, and stitched into a vec-env. `SubprocVecEnv` is used when
597
+ `args.n_envs > 1` because each rollout needs its own OpenDSS instance
598
+ (OpenDSS holds global state, so multiple envs in one process collide).
599
+ """
600
+
601
+ def _make_one():
602
+ env = env_factory()
603
+ return Monitor(env)
604
+
605
+ if args.n_envs > 1:
606
+ vec_env = SubprocVecEnv([_make_one for _ in range(args.n_envs)])
607
+ else:
608
+ vec_env = DummyVecEnv([_make_one])
609
+
610
+ if args.vec_normalize:
611
+ vn_init_ckpt = None
612
+ if args.init_from:
613
+ _p = Path(args.init_from)
614
+ _cand = _p.with_name(_p.name.replace("ppo_", "ppo_vecnormalize_", 1).replace(".zip", ".pkl"))
615
+ if _cand.exists():
616
+ vn_init_ckpt = _cand
617
+ if vn_init_ckpt is not None:
618
+ vec_env = VecNormalize.load(str(vn_init_ckpt), vec_env)
619
+ vec_env.training = True
620
+ vec_env.norm_reward = True
621
+ logger.info("Loaded VecNormalize stats from %s", vn_init_ckpt)
622
+ else:
623
+ if args.init_from:
624
+ logger.warning(
625
+ "--init-from set but no VecNormalize sibling pkl found; starting VecNormalize stats fresh."
626
+ )
627
+ vec_env = VecNormalize(
628
+ vec_env,
629
+ norm_obs=True,
630
+ norm_reward=True,
631
+ clip_obs=10.0,
632
+ clip_reward=10.0,
633
+ gamma=args.gamma,
634
+ )
635
+
636
+ obs_dim = int(vec_env.observation_space.shape[0])
637
+ if hasattr(vec_env.action_space, "nvec"):
638
+ n_act = int(len(vec_env.action_space.nvec))
639
+ elif hasattr(vec_env.action_space, "n"):
640
+ n_act = int(vec_env.action_space.n)
641
+ else:
642
+ n_act = int(vec_env.action_space.shape[0])
643
+ logger.info("")
644
+ logger.info("=" * 60)
645
+ logger.info("Training '%s': obs_dim=%d, n_actions=%d", label, obs_dim, n_act)
646
+ logger.info(
647
+ " shared=%s, n_envs=%d, hidden_dims=%s, vec_normalize=%s",
648
+ args.shared,
649
+ args.n_envs,
650
+ tuple(args.hidden_dims),
651
+ args.vec_normalize,
652
+ )
653
+ logger.info(
654
+ " reward weights: voltage=%s throughput=%s latency=%s switch=%s safe=%s reward_clip=%s",
655
+ args.w_voltage,
656
+ args.w_throughput,
657
+ args.w_latency,
658
+ args.w_switch,
659
+ args.w_safe,
660
+ args.reward_clip,
661
+ )
662
+ logger.info(" ofo_baseline=%s", args.ofo_baseline)
663
+ logger.info(
664
+ " switch_mode=%s switch_cooldown_tau=%s action_mode=%s",
665
+ args.switch_mode,
666
+ args.switch_cooldown_tau,
667
+ args.action_mode,
668
+ )
669
+ logger.info("=" * 60)
670
+
671
+ checkpoint_cb = CheckpointCallback(
672
+ save_freq=max(args.n_steps * args.checkpoint_interval_rollouts, 1),
673
+ save_path=str(output_dir / "checkpoints" / label),
674
+ name_prefix="ppo",
675
+ save_vecnormalize=args.vec_normalize,
676
+ )
677
+ metrics_csv = output_dir / f"metrics_{label}.csv"
678
+ metrics_cb = TrainingMetricsCallback(metrics_csv)
679
+ callbacks = CallbackList([checkpoint_cb, metrics_cb])
680
+
681
+ tb_log = str(output_dir / "tb") if args.tensorboard else None
682
+
683
+ if args.lr_schedule == "linear":
684
+ _lr_init = float(args.learning_rate)
685
+
686
+ def lr_arg(progress_remaining):
687
+ return progress_remaining * _lr_init
688
+
689
+ elif args.lr_schedule == "constant":
690
+ lr_arg = args.learning_rate
691
+ else:
692
+ raise ValueError(f"Unknown --lr-schedule: {args.lr_schedule!r} (expected 'constant' or 'linear')")
693
+
694
+ if args.init_from:
695
+ model = PPO.load(
696
+ args.init_from,
697
+ env=vec_env,
698
+ device="auto",
699
+ tensorboard_log=tb_log,
700
+ )
701
+ model.set_env(vec_env)
702
+ logger.info(
703
+ "Warm-started PPO from %s (num_timesteps=%d).",
704
+ args.init_from,
705
+ getattr(model, "num_timesteps", 0),
706
+ )
707
+ else:
708
+ model = PPO(
709
+ "MlpPolicy",
710
+ vec_env,
711
+ learning_rate=lr_arg,
712
+ n_steps=args.n_steps,
713
+ batch_size=args.batch_size,
714
+ n_epochs=args.n_epochs,
715
+ gamma=args.gamma,
716
+ gae_lambda=args.gae_lambda,
717
+ clip_range=args.clip_range,
718
+ ent_coef=args.ent_coef,
719
+ verbose=1,
720
+ seed=args.seed,
721
+ tensorboard_log=tb_log,
722
+ policy_kwargs=dict(net_arch=list(args.hidden_dims)),
723
+ )
724
+ model.learn(
725
+ total_timesteps=args.total_timesteps,
726
+ callback=callbacks,
727
+ tb_log_name=label,
728
+ reset_num_timesteps=not bool(args.init_from),
729
+ )
730
+ model_path = output_dir / save_name
731
+ model.save(str(model_path))
732
+ logger.info("Saved '%s' model to %s.zip", label, model_path)
733
+ if args.vec_normalize:
734
+ # VecNormalize running stats MUST be reloaded at inference time,
735
+ # otherwise the policy sees unnormalized obs and acts nonsensically.
736
+ vn_path = output_dir / f"{save_name}_vecnormalize.pkl"
737
+ vec_env.save(str(vn_path))
738
+ logger.info("Saved VecNormalize stats to %s", vn_path)
739
+ vec_env.close()
740
+
741
+ if args.plot:
742
+ try:
743
+ plot_path = plot_training_progress(metrics_csv, output_dir / f"training_progress_{label}.png", label)
744
+ if plot_path is not None:
745
+ logger.info("Wrote training plot to %s", plot_path)
746
+ else:
747
+ logger.warning("No metrics rows in %s: skipping plot", metrics_csv)
748
+ except Exception as e:
749
+ logger.warning("Plotting failed for '%s': %s", label, e)
750
+
751
+ if args.shared and len(site_ids) > 1:
752
+ # ── Shared multi-site PPO ──
753
+ logger.info("Training SHARED PPO for %d sites: %s", len(site_ids), site_ids)
754
+
755
+ site_model_mapping = {sid: [s.model_label for s in all_site_specs[sid]] for sid in site_ids}
756
+ all_initial_bs_flat = {label: bs for sid in site_ids for label, bs in all_initial_batch_sizes[sid].items()}
757
+ zone_summary = (
758
+ {zname: tuple(zbuses) for zname, zbuses in zones.items()}
759
+ if zones is not None and args.obs_mode in ("per-zone-summary", "per-bus-summary")
760
+ else None
761
+ )
762
+ obs_config = ObservationConfig.from_multi_site(
763
+ all_site_specs,
764
+ all_replica_counts,
765
+ n_bus_phases=n_bus_phases,
766
+ initial_batch_sizes=all_initial_bs_flat,
767
+ zone_summary=zone_summary,
768
+ bus_phase_groups=bus_phase_groups,
769
+ v_min=V_MIN,
770
+ v_max=V_MAX,
771
+ )
772
+
773
+ def shared_env_factory():
774
+ return SharedBatchSizeEnv(
775
+ make_sim_fn=make_sim,
776
+ obs_config=obs_config,
777
+ site_model_mapping=site_model_mapping,
778
+ reward_config=reward_config,
779
+ action_mode=args.action_mode,
780
+ logistic_models=logistic_models,
781
+ dt_ctrl=DT_CTRL,
782
+ total_duration_s=args.total_duration_s,
783
+ scenario_library=scenario_lib,
784
+ ofo_baseline=args.ofo_baseline and scenario_lib is not None,
785
+ truncate_episode=args.truncate_episode and scenario_lib is not None,
786
+ )
787
+
788
+ _train_and_save(shared_env_factory, "shared", "ppo_model_shared")
789
+
790
+ else:
791
+ # ── Per-site PPO ──
792
+ logger.info("Training %d separate PPO(s): %s", len(site_ids), site_ids)
793
+
794
+ for sid in site_ids:
795
+ specs = all_site_specs[sid]
796
+ replica_counts = all_replica_counts[sid]
797
+
798
+ # Zone-local voltage filtering for systems with zone definitions
799
+ zone_buses = None
800
+ n_bp = n_bus_phases
801
+ if zones is not None and sid in zones and args.obs_mode == "full-voltage":
802
+ zone_buses = tuple(zones[sid])
803
+ zone_mask = compute_zone_mask(v_index, zone_buses)
804
+ n_bp = int(np.sum(zone_mask))
805
+ logger.info("Site '%s': using zone-local obs with %d/%d bus-phases", sid, n_bp, n_bus_phases_full)
806
+
807
+ site_initial_bs = all_initial_batch_sizes[sid]
808
+ obs_config = ObservationConfig.from_model_specs(
809
+ specs,
810
+ replica_counts,
811
+ n_bus_phases=n_bp,
812
+ initial_batch_sizes=site_initial_bs,
813
+ zone_buses=zone_buses,
814
+ v_min=V_MIN,
815
+ v_max=V_MAX,
816
+ )
817
+
818
+ def site_env_factory(_obs_config=obs_config, _sid=sid, _lib=scenario_lib):
819
+ return BatchSizeEnv(
820
+ make_sim_fn=make_sim,
821
+ obs_config=_obs_config,
822
+ agent_site_id=_sid,
823
+ reward_config=reward_config,
824
+ action_mode=args.action_mode,
825
+ logistic_models=logistic_models,
826
+ dt_ctrl=DT_CTRL,
827
+ total_duration_s=args.total_duration_s,
828
+ scenario_library=_lib,
829
+ ofo_baseline=args.ofo_baseline and _lib is not None,
830
+ truncate_episode=args.truncate_episode and _lib is not None,
831
+ )
832
+
833
+ _train_and_save(site_env_factory, sid, f"ppo_model_{sid}")
834
+
835
+ # Single-site runs alias the per-site output to the canonical ppo_model.zip
836
+ # path that the docs + evaluate.py default to.
837
+ if len(site_ids) == 1:
838
+ import shutil
839
+
840
+ for suffix in (".zip", "_vecnormalize.pkl"):
841
+ src = output_dir / f"ppo_model_{site_ids[0]}{suffix}"
842
+ dst = output_dir / f"ppo_model{suffix}"
843
+ if src.exists():
844
+ shutil.copy2(src, dst)
845
+
846
+ logger.info("All done. Models saved to %s", output_dir)
847
+
848
+
849
+ if __name__ == "__main__":
850
+ main()
generate_heatmap.py CHANGED
@@ -1,212 +1,212 @@
1
- import sys
2
- import numpy as np
3
- import matplotlib
4
- matplotlib.use('Agg')
5
- import matplotlib.pyplot as plt
6
- import matplotlib.image as mpimg
7
- import matplotlib.patches as mpatches
8
- from matplotlib.lines import Line2D
9
- import matplotlib.cm as cm
10
- import matplotlib.colors as mcolors
11
- import logging
12
- logger = logging.getLogger(__name__)
13
-
14
-
15
-
16
- BUS_NAMES = {
17
- 1:'650', 2:'632', 3:'633', 4:'645', 5:'646', 6:'671',
18
- 7:'684', 8:'611', 9:'634', 10:'675', 11:'652', 12:'680', 13:'692',
19
- }
20
-
21
- BUS_COORDS = {
22
- 1: (500, 650),
23
- 2: (500, 480),
24
- 3: (680, 480),
25
- 9: (860, 480),
26
- 4: (320, 480),
27
- 5: (140, 480),
28
- 6: (500, 280),
29
- 13: (680, 280),
30
- 10: (860, 280),
31
- 7: (320, 280),
32
- 8: (140, 280),
33
- 11: (320, 120),
34
- 12: (500, 120),
35
- }
36
-
37
-
38
- LINES = [
39
- (1, 2, '650-632'),
40
- (2, 4, '632-645'),
41
- (4, 5, '645-646'),
42
- (2, 3, '632-633'),
43
- (3, 9, '633-634'),
44
- (2, 6, '632-671'),
45
- (6, 13, '671-692'),
46
- (13, 10, '692-675'),
47
- (6, 7, '671-684'),
48
- (7, 8, '684-611'),
49
- (7, 11, '684-652'),
50
- (6, 12, '671-680'),
51
- ]
52
-
53
- TRANSFORMER_LINES = set()
54
-
55
-
56
- def generate_heatmap(voltages, output_path, vmin=0.92, vmax=1.06, map_image="13busmap.png", dc_bus_idx=None):
57
-
58
- xs = np.array([BUS_COORDS[i][0] for i in range(1, 14)])
59
- ys = np.array([BUS_COORDS[i][1] for i in range(1, 14)])
60
- zs = np.array(voltages)
61
-
62
- xmin_d, xmax_d = -10, 1005
63
- ymin_d, ymax_d = -10, 705
64
-
65
-
66
-
67
-
68
-
69
- fig, ax = plt.subplots(figsize=(12, 8), frameon=False)
70
- ax.set_xlim(xmin_d, xmax_d)
71
- ax.set_ylim(ymin_d, ymax_d)
72
- ax.set_aspect('equal')
73
- ax.set_axis_off()
74
-
75
- # heatmap
76
-
77
-
78
- try:
79
- img = mpimg.imread(map_image)
80
- ax.imshow(img[::-1], origin='lower', alpha=0.12, zorder=3,
81
- extent=[0, 1005, 0, 705])
82
- except FileNotFoundError:
83
- pass
84
-
85
- for (bus_a, bus_b, label) in LINES:
86
- x1, y1 = BUS_COORDS[bus_a]
87
- x2, y2 = BUS_COORDS[bus_b]
88
- is_xfmr = label in TRANSFORMER_LINES
89
- ls = '--' if is_xfmr else '-'
90
-
91
- ax.plot([x1, x2], [y1, y2],
92
- color='white', linewidth=7,
93
- solid_capstyle='round', zorder=4)
94
-
95
- ax.plot([x1, x2], [y1, y2],
96
- color='#1e293b', linewidth=3,
97
- linestyle=ls, solid_capstyle='round', zorder=5)
98
-
99
- mx, my = (x1 + x2) / 2, (y1 + y2) / 2
100
- ax.text(mx, my, label, fontsize=5.5, color='#334155',
101
- ha='center', va='center', zorder=6,
102
- bbox=dict(fc='white', alpha=0.8, ec='#cbd5e1', pad=1.5))
103
-
104
- # bus nodes on map
105
- for bus_id, (bx, by) in BUS_COORDS.items():
106
- v = voltages[bus_id - 1]
107
-
108
- # violation outliens
109
- if v < 0.95:
110
- ax.scatter(bx, by, s=500, color='#ef4444', alpha=0.35, zorder=7)
111
- elif v > 1.05:
112
- ax.scatter(bx, by, s=500, color='#f59e0b', alpha=0.35, zorder=7)
113
-
114
- #fill color
115
- t = np.clip((v - vmin) / (vmax - vmin), 0, 1)
116
- bus_color = plt.cm.RdYlGn(t)
117
-
118
- marker = 's' if bus_id == 1 else 'o'
119
- path_collection = ax.scatter(bx, by, s=200, color=bus_color,
120
- marker=marker, edgecolors='#1e293b',
121
- linewidths=2, zorder=8)
122
- path_collection.set_gid(f"bus-node-{bus_id}")
123
-
124
- v_color = '#dc2626' if v < 0.95 else '#d97706' if v > 1.05 else '#166534'
125
- ax.text(bx, by + 30, f'Bus {bus_id}',
126
- fontsize=7.5, fontweight='bold', color='#1e293b',
127
- ha='center', va='bottom', zorder=9,
128
- bbox=dict(fc='white', alpha=0.8, ec='none', pad=1.5))
129
- ax.text(bx, by - 30, f'{v:.3f} p.u.',
130
- fontsize=7.5, fontweight='bold', color=v_color,
131
- ha='center', va='top', zorder=9,
132
- bbox=dict(fc='white', alpha=0.8, ec='none', pad=1.5))
133
-
134
- #data center marker
135
- if dc_bus_idx is not None and dc_bus_idx in BUS_COORDS:
136
- dcx, dcy = BUS_COORDS[dc_bus_idx]
137
-
138
- ax.scatter(dcx, dcy, s=700, color='none',
139
- edgecolors='#0891b2', linewidths=3, zorder=10)
140
- ax.scatter(dcx, dcy, s=900, color='none',
141
- edgecolors='#0891b2', linewidths=1, alpha=0.4, zorder=10)
142
- ax.annotate(
143
- 'DATA CENTER',
144
- xy=(dcx, dcy), xytext=(dcx, dcy + 65),
145
- fontsize=8, fontweight='bold', color='#0891b2',
146
- ha='center', va='bottom', zorder=11,
147
- bbox=dict(boxstyle='round,pad=0.3', fc='#ecfeff', ec='#0891b2', lw=1.5),
148
- arrowprops=dict(arrowstyle='->', color='#0891b2', lw=1.5),
149
- )
150
-
151
- norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
152
- sm = plt.cm.ScalarMappable(cmap='RdYlGn', norm=norm)
153
- sm.set_array([])
154
-
155
-
156
- cbar = fig.colorbar(sm, ax=ax, shrink=0.6, pad=0.02, aspect=25)
157
- cbar.set_label('Voltage (p.u.)', fontsize=10, labelpad=8)
158
- cbar.ax.tick_params(labelsize=8)
159
-
160
-
161
- cbar.ax.axhline(y=(0.95 - vmin) / (vmax - vmin), color='#ef4444', linewidth=1.5, linestyle='--')
162
- cbar.ax.axhline(y=(1.05 - vmin) / (vmax - vmin), color='#f59e0b', linewidth=1.5, linestyle='--')
163
-
164
- ax.set_facecolor('white')
165
- fig.patch.set_facecolor('white')
166
-
167
-
168
-
169
-
170
- legend_elements = [
171
- Line2D([0],[0], marker='o', color='w', markerfacecolor='none',
172
- markersize=12, markeredgecolor='#0891b2', markeredgewidth=2.5,
173
- label='Data center bus'),
174
-
175
- mpatches.Patch(color='#ef4444', alpha=0.5, label='Under-voltage < 0.95 p.u.'),
176
- mpatches.Patch(color='#f59e0b', alpha=0.5, label='Over-voltage > 1.05 p.u.'),
177
- Line2D([0],[0], marker='s', color='w', markerfacecolor='#6b7280',
178
- markersize=9, markeredgecolor='#1e293b', label='Substation (Bus 1 = 650)'),
179
- Line2D([0],[0], marker='o', color='w', markerfacecolor='#6b7280',
180
- markersize=9, markeredgecolor='#1e293b', label='Load bus'),
181
-
182
-
183
-
184
- mpatches.Patch(color='none', label=' '),
185
- mpatches.Patch(color='none', label='Bus index:'),
186
- mpatches.Patch(color='none', label='1=650 2=632 3=633'),
187
- mpatches.Patch(color='none', label='4=645 5=646 6=671'),
188
- mpatches.Patch(color='none', label='7=684 8=611 9=634'),
189
- mpatches.Patch(color='none', label='10=675 11=652 12=680'),
190
-
191
- mpatches.Patch(color='none', label='13=692'),
192
- ]
193
- ax.legend(handles=legend_elements, loc='lower right',
194
- fontsize=6.5, framealpha=0.92, edgecolor='#cbd5e1',
195
- handlelength=1.5, handleheight=1.0)
196
-
197
- plt.tight_layout(pad=0.3)
198
- plt.rcParams['svg.fonttype'] = 'none'
199
- plt.savefig(output_path, format='svg', bbox_inches='tight', dpi=150, facecolor='white')
200
- plt.close()
201
- logger.info(f"Saved heatmap: {output_path}")
202
-
203
-
204
- if __name__ == "__main__":
205
- if len(sys.argv) < 15:
206
- logger.info(f"Usage: generate_heatmap.py <output.png> <v1> <v2> ... <v13> [dc_bus_idx]")
207
-
208
- sys.exit(1)
209
- out = sys.argv[1]
210
- volts = [float(v) for v in sys.argv[2:15]]
211
- dc_bus = int(sys.argv[15]) if len(sys.argv) > 15 else None
212
- generate_heatmap(volts, out, dc_bus_idx=dc_bus)
 
1
+ """
2
+ generate_heatmap.py v2
3
+ -----------------------
4
+ Pure-SVG topology heatmap. No matplotlib.
5
+ Coords arrive already scaled to pixel space from topology_coords.py.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import xml.etree.ElementTree as ET
10
+ import math
11
+
12
+
13
+ def _voltage_color(v: float) -> str:
14
+ if v >= 1.05: return "#22c55e"
15
+ elif v >= 1.00: return "#4ade80"
16
+ elif v >= 0.97: return "#facc15"
17
+ elif v >= 0.95: return "#fb923c"
18
+ else: return "#ef4444"
19
+
20
+
21
+ def _sizes(num_buses: int) -> dict:
22
+ if num_buses > 100:
23
+ return dict(r=5, lw=1.2, fs=8, fs_edge=6, label_offset=14)
24
+ elif num_buses > 34:
25
+ return dict(r=7, lw=1.8, fs=10, fs_edge=8, label_offset=18)
26
+ else:
27
+ return dict(r=9, lw=2.2, fs=11, fs_edge=9, label_offset=20)
28
+
29
+
30
+ def generate_heatmap(
31
+ voltages: list[float],
32
+ bus_names: list[str],
33
+ coords: dict,
34
+ lines: list,
35
+ output_path: str,
36
+ canvas_w: int = 900,
37
+ canvas_h: int = 750,
38
+ dc_bus: str | None = None,
39
+ substation_bus: str = "650",
40
+ topology: str = "ieee13",
41
+ ):
42
+ # ── 1. Build lookups ──────────────────────────────────────────────────────
43
+ coords_lc = {str(k).lower().strip(): v for k, v in coords.items()}
44
+
45
+ name_to_v: dict[str, float] = {}
46
+ name_to_pos: dict[str, tuple[int,int]] = {}
47
+ for i, name in enumerate(bus_names):
48
+ key = str(name).lower().strip()
49
+ name_to_v[key] = voltages[i] if i < len(voltages) else 1.0
50
+ if key in coords_lc:
51
+ name_to_pos[key] = coords_lc[key]
52
+
53
+ # ── 2. Virtual regulator coord patch (IEEE-13: rg60 between 650 and 632) ─
54
+ if "650" in coords_lc and "rg60" not in coords_lc and "632" in coords_lc:
55
+ x0, y0 = coords_lc["650"]
56
+ x1, y1 = coords_lc["632"]
57
+ coords_lc["rg60"] = (int(x0 + 0.3*(x1-x0)), int(y0 + 0.3*(y1-y0)))
58
+
59
+ sz = _sizes(len(bus_names))
60
+ r = sz["r"]
61
+ lw = sz["lw"]
62
+ fs = sz["fs"]
63
+ fse = sz["fs_edge"]
64
+ loff = sz["label_offset"]
65
+
66
+ # Skip edge labels for dense topologies
67
+ skip_edge_labels = len(bus_names) > 50
68
+
69
+ # ── 3. SVG root ───────────────────────────────────────────────────────────
70
+ svg = ET.Element("svg", {
71
+ "xmlns": "http://www.w3.org/2000/svg",
72
+ "width": str(canvas_w),
73
+ "height": str(canvas_h),
74
+ "viewBox": f"0 0 {canvas_w} {canvas_h}",
75
+ })
76
+ ET.SubElement(svg, "rect", {
77
+ "width": str(canvas_w), "height": str(canvas_h), "fill": "#f8fafc",
78
+ })
79
+
80
+ # ── 4. Lines ──────────────────────────────────────────────────────────────
81
+ for entry in lines:
82
+ b1 = str(entry[0]).split(".")[0].lower().strip()
83
+ b2 = str(entry[1]).split(".")[0].lower().strip()
84
+ label = str(entry[2]) if len(entry) > 2 else ""
85
+
86
+ # Skip open-point switches (bus names ending in _open or _open.N)
87
+ if "_open" in b1 or "_open" in b2:
88
+ continue
89
+
90
+ p1 = coords_lc.get(b1)
91
+ p2 = coords_lc.get(b2)
92
+ if p1 is None and p2 is None:
93
+ continue
94
+ if p1 is None: p1 = p2
95
+ if p2 is None: p2 = p1
96
+ x1c, y1c = p1
97
+ x2c, y2c = p2
98
+ if x1c == x2c and y1c == y2c:
99
+ continue
100
+
101
+ # Halo + core line
102
+ ET.SubElement(svg, "line", {
103
+ "x1": str(x1c), "y1": str(y1c), "x2": str(x2c), "y2": str(y2c),
104
+ "stroke": "white", "stroke-width": str(lw * 2.8),
105
+ "stroke-linecap": "round",
106
+ })
107
+ ET.SubElement(svg, "line", {
108
+ "x1": str(x1c), "y1": str(y1c), "x2": str(x2c), "y2": str(y2c),
109
+ "stroke": "#1e293b", "stroke-width": str(lw),
110
+ "stroke-linecap": "round",
111
+ })
112
+
113
+ # Edge label — only for sparse topologies, placed along line
114
+ if not skip_edge_labels and label:
115
+ mx, my = (x1c + x2c) // 2, (y1c + y2c) // 2
116
+ tw = len(label) * fse * 0.58
117
+ th = fse + 2
118
+ ET.SubElement(svg, "rect", {
119
+ "x": str(mx - tw/2 - 2), "y": str(my - th/2 - 1),
120
+ "width": str(tw + 4), "height": str(th + 2),
121
+ "rx": "2", "fill": "white", "opacity": "0.9",
122
+ "stroke": "#cbd5e1", "stroke-width": "0.6",
123
+ })
124
+ t = ET.SubElement(svg, "text", {
125
+ "x": str(mx), "y": str(my + fse*0.35),
126
+ "text-anchor": "middle", "font-size": str(fse),
127
+ "font-family": "monospace", "fill": "#64748b",
128
+ })
129
+ t.text = label
130
+
131
+ # ── 5. Nodes ──────────────────────────────────────────────────────────────
132
+ # Collect all positions to detect overlapping labels
133
+ placed_labels: list[tuple[float,float,float,float]] = [] # (x,y,w,h) bboxes
134
+
135
+ def _overlaps(x: float, y: float, w: float, h: float) -> bool:
136
+ for bx, by, bw, bh in placed_labels:
137
+ if abs(x - bx) < (w + bw)/2 + 2 and abs(y - by) < (h + bh)/2 + 2:
138
+ return True
139
+ return False
140
+
141
+ for i, name in enumerate(bus_names):
142
+ key = str(name).lower().strip()
143
+ if key not in name_to_pos:
144
+ continue
145
+
146
+ px, py = name_to_pos[key]
147
+ v = name_to_v.get(key, 1.0)
148
+ color = _voltage_color(v)
149
+ is_sub = key == str(substation_bus).lower().strip()
150
+ is_dc = dc_bus is not None and key == str(dc_bus).lower().strip()
151
+
152
+ # DC bus highlight ring
153
+ if is_dc:
154
+ ET.SubElement(svg, "circle", {
155
+ "cx": str(px), "cy": str(py), "r": str(r + 5),
156
+ "fill": "none", "stroke": "#7c3aed", "stroke-width": "2",
157
+ "stroke-dasharray": "4 2",
158
+ })
159
+
160
+ # Node shape
161
+ if is_sub:
162
+ ET.SubElement(svg, "rect", {
163
+ "x": str(px - r), "y": str(py - r),
164
+ "width": str(r*2), "height": str(r*2),
165
+ "fill": color, "stroke": "#0f172a", "stroke-width": "1.8",
166
+ })
167
+ else:
168
+ ET.SubElement(svg, "circle", {
169
+ "cx": str(px), "cy": str(py), "r": str(r),
170
+ "fill": color, "stroke": "#0f172a", "stroke-width": "1.4",
171
+ })
172
+
173
+ # Label — try above first, fall back to below if overlapping
174
+ label_text = f"{name} {v:.3f} p.u."
175
+ tw = len(label_text) * fs * 0.60
176
+ th = fs + 2
177
+
178
+ # Candidate positions: above, below, right, left
179
+ candidates = [
180
+ (px, py - r - loff), # above (preferred)
181
+ (px, py + r + loff + 2), # below
182
+ (px + r + tw/2 + 4, py), # right
183
+ (px - r - tw/2 - 4, py), # left
184
+ ]
185
+ tx, ty = candidates[0] # default: above
186
+ for cx, cy in candidates:
187
+ if not _overlaps(cx, cy, tw, th):
188
+ tx, ty = cx, cy
189
+ break
190
+
191
+ placed_labels.append((tx, ty, tw, th))
192
+
193
+ # White backing rect
194
+ ET.SubElement(svg, "rect", {
195
+ "x": str(tx - tw/2 - 2), "y": str(ty - th/2 - 1),
196
+ "width": str(tw + 4), "height": str(th + 2),
197
+ "rx": "2", "fill": "white", "opacity": "0.88",
198
+ })
199
+ ET.SubElement(svg, "text", {
200
+ "x": str(tx), "y": str(ty + fs*0.35),
201
+ "text-anchor": "middle",
202
+ "font-size": str(fs),
203
+ "font-family": "Arial, sans-serif",
204
+ "font-weight": "bold" if is_sub else "normal",
205
+ "fill": "#0f172a",
206
+ }).text = label_text
207
+
208
+ # ── 6. Write ──────────────────────────────────────────────────────────────
209
+ tree = ET.ElementTree(svg)
210
+ ET.indent(tree, space=" ")
211
+ tree.write(output_path, encoding="unicode", xml_declaration=False)
212
+ print(f"[{topology}] SVG written → {output_path} ({len(bus_names)} buses)")
server.py CHANGED
@@ -8,9 +8,11 @@ Uses GPU power traces and workloads to model howAI inference/training affects g
8
 
9
 
10
 
 
 
11
  from fractions import Fraction
12
  from pathlib import Path
13
- import subprocess, tempfile, os, uvicorn, threading, math, json, hashlib
14
 
15
  import pandas as pd
16
  from fastapi import FastAPI, HTTPException, Response
@@ -18,6 +20,7 @@ from fastapi.middleware.cors import CORSMiddleware
18
  from pydantic import BaseModel
19
  from typing import Optional
20
  from fastapi import WebSocket, WebSocketDisconnect
 
21
 
22
 
23
  from openg2g.coordinator import Coordinator
@@ -37,87 +40,375 @@ from openg2g.grid.opendss import OpenDSSGrid
37
  from openg2g.grid.config import TapPosition
38
  from openg2g.controller.tap_schedule import TapScheduleController
39
  from openg2g.metrics.voltage import compute_allbus_voltage_stats
 
 
 
 
 
 
 
 
 
40
 
41
  import logging
42
 
 
 
 
 
43
  logging.basicConfig(
44
  level=logging.INFO,
45
  format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
46
  )
47
  logger = logging.getLogger(__name__)
48
 
49
-
50
-
51
  import asyncio, time
52
  from concurrent.futures import ProcessPoolExecutor
53
 
54
- import json
 
55
 
 
56
 
57
- #currently set to 2 for free tier at hf
58
- _pool = ProcessPoolExecutor(max_workers=2)
59
 
60
- _start_time = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
 
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  DSS_DIR = Path(__file__).parent / "examples/ieee13"
65
  DSS_MASTER = "IEEE13Nodeckt.dss"
66
  CONFIG_PATH = Path(__file__).parent / "examples/offline/config.json"
67
 
68
-
69
- # Maps IEEE 13-bus indices to OpenDSS bus names
70
  BUS_INDEX_TO_NAME = {
71
  1:"650", 2:"632", 3:"633", 4:"645", 5:"646", 6:"671",
72
  7:"684", 8:"611", 9:"634", 10:"675", 11:"652", 12:"680", 13:"692",
73
  }
74
  BUSES_ORDERED = [BUS_INDEX_TO_NAME[i] for i in range(1, 14)]
75
 
 
 
 
 
 
 
76
 
77
- #read files
78
  _config_raw = json.loads(CONFIG_PATH.read_text())
79
  _MODELS = tuple(InferenceModelSpec(**m) for m in _config_raw["models"])
80
- _DC_CONFIG = DatacenterConfig(gpus_per_server=8, base_kw_per_phase=500.0)
81
-
82
 
83
  if _config_raw.get("data_dir"):
84
  _DATA_DIR = Path(_config_raw["data_dir"])
85
  else:
86
  _DATA_DIR = Path(__file__).parent / "data/specs"
87
-
88
-
89
- # Load traces_summary.csv once at startup so we can quickly look up trace files
90
- _TRACES_SUMMARY_PATH = _DATA_DIR / "traces_summary.csv"
91
 
92
- #Cached dataframe of available GPU power traces
93
  _traces_df: pd.DataFrame | None = None
94
 
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  TAP_STEP = 0.00625
 
97
  INITIAL_TAPS = TapPosition(
98
  a=1.0 + 14 * TAP_STEP,
99
  b=1.0 + 6 * TAP_STEP,
100
  c=1.0 + 15 * TAP_STEP,
101
  )
102
 
103
- # Rescaled to fit in 300s window (original is 3600s, we compress ~12x)
104
  TAP_CHANGE_SCHEDULE = (
105
  TapPosition(
106
  a=1.0 + 16 * TAP_STEP,
107
  b=1.0 + 6 * TAP_STEP,
108
  c=1.0 + 17 * TAP_STEP,
109
- ).at(t=75) # was 1500s → 75s at 12x compression
110
  | TapPosition(
111
  a=1.0 + 10 * TAP_STEP,
112
  b=1.0 + 6 * TAP_STEP,
113
  c=1.0 + 10 * TAP_STEP,
114
- ).at(t=200) # was 3300s → 200s at 12x compression
115
  )
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  """
119
  Load trace index CSV and cache it.
120
  """
 
 
121
  def _load_traces_index() -> pd.DataFrame:
122
  global _traces_df
123
  if _traces_df is None:
@@ -133,9 +424,9 @@ Lookup GPU power trace and scale by replica count.
133
  Returns a list of per-timestep total power values in watts.
134
 
135
  """
 
136
  def _get_trace_power(model_label: str, num_gpus: int, max_num_seqs: int,
137
  num_replicas: int = 1) -> list[float]:
138
-
139
  df = _load_traces_index()
140
  row = df[
141
  (df["model_label"] == model_label) &
@@ -143,28 +434,19 @@ def _get_trace_power(model_label: str, num_gpus: int, max_num_seqs: int,
143
  (df["max_num_seqs"]== max_num_seqs)
144
  ]
145
  if row.empty:
146
- raise ValueError(
147
- f"No trace found for model={model_label} num_gpus={num_gpus} "
148
- f"max_num_seqs={max_num_seqs}. "
149
- f"Available: {df[['model_label','num_gpus','max_num_seqs']].to_dict('records')}"
150
- )
151
  trace_file = _DATA_DIR / row.iloc[0]["trace_file"]
152
  trace_df = pd.read_csv(trace_file)
153
-
154
  power_W = trace_df["power_total_W"].tolist()
155
  return [p * num_replicas for p in power_W]
156
 
157
-
158
- logger.info(f"Data dir: {_DATA_DIR} exists={_DATA_DIR.exists()}")
159
- _load_traces_index() # load at startup
160
 
161
 
162
  """Datacenter workload (baseline)"""
163
  def _build_dc(scale: float = 1.0, duration_s: int = 300) -> OfflineDatacenter:
164
-
165
  df = _load_traces_index()
166
  first_row = df.iloc[0]
167
-
168
  first_model = tuple(m for m in _MODELS if m.model_label == first_row["model_label"])
169
  inference_data = InferenceData.load(_DATA_DIR, first_model)
170
 
@@ -172,53 +454,27 @@ def _build_dc(scale: float = 1.0, duration_s: int = 300) -> OfflineDatacenter:
172
  _DATA_DIR / "training_trace.csv",
173
  TrainingTraceParams(),
174
  )
175
-
176
  t0 = min(40.0, duration_s * 0.13)
177
  t1 = min(140.0, duration_s * 0.47)
178
-
179
  replica_schedules = {}
180
 
181
  for m in _MODELS:
182
-
183
  initial_replicas = max(1, int(scale * 8))
184
-
185
  reduced_replicas = max(1, int(initial_replicas * 0.25))
186
-
187
  replica_schedules[m.model_label] = (
188
  ReplicaSchedule(initial=initial_replicas)
189
- .ramp_to(
190
- reduced_replicas,
191
- t_start=min(150.0, duration_s * 0.50),
192
- t_end=min(220.0, duration_s * 0.73),
193
- )
194
  )
195
 
196
  workload = OfflineWorkload(
197
  inference_data=inference_data,
198
  replica_schedules=replica_schedules,
199
-
200
- training=TrainingRun(
201
- n_gpus=max(1, int(24 * scale)),
202
- trace=training_trace,
203
- target_peak_W_per_gpu=400.0,
204
- ).at(
205
- t_start=t0,
206
- t_end=t1,
207
- ),
208
  )
209
-
210
  return OfflineDatacenter(
211
- _DC_CONFIG,
212
- workload,
213
- dt_s=Fraction(1, 10),
214
- seed=0,
215
- name="baseline",
216
- total_gpu_capacity=1000,
217
- power_augmentation=PowerAugmentationConfig(
218
- amplitude_scale_range=(0.88, 1.12),
219
- noise_fraction=0.04,
220
- ),
221
- )
222
 
223
 
224
  """
@@ -226,16 +482,10 @@ def _build_dc(scale: float = 1.0, duration_s: int = 300) -> OfflineDatacenter:
226
  Returns (datacenter, raw_power_W_list)
227
 
228
  """
229
- def _build_dc_from_real_trace(
230
- model_label: str,
231
- num_gpus: int,
232
- max_num_seqs: int,
233
- num_replicas: int,
234
- duration_s: int,
235
- ) -> tuple[OfflineDatacenter, list[float]]:
236
-
237
  power_W = _get_trace_power(model_label, num_gpus, max_num_seqs, num_replicas)
238
-
239
  target_steps = int(duration_s / 0.1)
240
  if len(power_W) < target_steps:
241
  repeats = math.ceil(target_steps / len(power_W))
@@ -243,82 +493,164 @@ def _build_dc_from_real_trace(
243
  else:
244
  power_W = power_W[:target_steps]
245
 
246
- df = _load_traces_index()
247
- row = df[df["model_label"] == model_label].iloc[0]
248
-
249
  model_tuple = tuple(m for m in _MODELS if m.model_label == model_label)
250
  inference_data = InferenceData.load(_DATA_DIR, model_tuple)
251
 
 
252
  workload = OfflineWorkload(
253
  inference_data=inference_data,
254
- replica_schedules={
255
- model_label: ReplicaSchedule(initial=num_replicas)
256
- },
257
  )
258
-
259
- # ← compute actual GPU count and add headroom
260
  actual_gpu_count = num_replicas * num_gpus
 
 
261
  gpu_capacity = max(1000, actual_gpu_count * 2)
262
 
263
  dc = OfflineDatacenter(
264
- _DC_CONFIG, workload, dt_s=Fraction(1, 10), seed=0,
265
- name=model_label.replace(".", "-"),
266
- total_gpu_capacity=gpu_capacity, # ← was hardcoded 1000
267
- power_augmentation=PowerAugmentationConfig(
268
- amplitude_scale_range=(1.0, 1.0),
269
- noise_fraction=0.0,
270
- ),
271
  )
272
  return dc, power_W
273
 
 
 
274
  """Create IEEE 13-bus grid with datacenter connection."""
275
- def _build_grid(tap_pu: float, dc_bus: str) -> OpenDSSGrid:
276
- return OpenDSSGrid(
277
- dss_case_dir=str(DSS_DIR),
278
- dss_master_file=DSS_MASTER,
279
- dt_s=Fraction(1),
280
- source_pu=tap_pu,
281
- initial_tap_position=INITIAL_TAPS,
282
- )
283
 
 
284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
 
286
- def _make_tap(v: float):
287
- return TapPosition(a=v, b=v, c=v).at(t=0)
288
 
289
  """Run datacenter + grid simulation."""
290
- def _run(dc, grid, tap_pu, dc_bus, duration_s):
 
 
 
 
 
 
291
  grid.attach_dc(dc, bus=dc_bus, connection_type="wye", power_factor=_DC_CONFIG.power_factor)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  coord = Coordinator(
293
- datacenters=[dc],
294
- grid=grid,
295
- controllers=[TapScheduleController(
296
- schedule=TAP_CHANGE_SCHEDULE, # ← real schedule
297
- dt_s=Fraction(1)
298
- )],
299
  total_duration_s=duration_s,
300
  )
301
  return coord.run()
302
 
 
303
  """
304
  Runs one full simulation job (datacenter + grid) in a worker process
305
  and returns results for the API.
306
  """
 
307
  def _run_full(req_dict: dict) -> dict:
 
 
308
 
 
 
 
 
 
309
 
310
- dc_bus = BUS_INDEX_TO_NAME.get(req_dict["targetBus"], "671")
311
  replicas = max(1, req_dict["numReplicas"])
 
 
 
 
 
312
 
313
  dc, raw_power_W = _build_dc_from_real_trace(
314
- model_label = req_dict["modelLabel"],
315
- num_gpus = req_dict["numGpus"],
316
- max_num_seqs = req_dict["maxNumSeqs"],
317
- num_replicas = replicas,
318
- duration_s = req_dict["durationS"],
319
  )
320
- grid = _build_grid(req_dict["substationVoltage"], dc_bus)
321
- log = _run(dc, grid, req_dict["substationVoltage"], dc_bus, req_dict["durationS"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
 
323
  step = max(1, req_dict["sampleInterval"])
324
  gs_sampled = log.grid_states[::step]
@@ -327,15 +659,17 @@ def _run_full(req_dict: dict) -> dict:
327
 
328
  results = []
329
  for i, (t, gs) in enumerate(zip(t_sampled, gs_sampled)):
330
- vs = _voltages(gs)
331
  dc_i = min(range(len(dc_states)), key=lambda j: abs(dc_states[j].time_s - t))
332
  ds = dc_states[dc_i]
333
  kw = float((ds.power_w.a + ds.power_w.b + ds.power_w.c) / 1000)
334
-
335
-
336
  if math.isnan(kw): kw = 0.0
337
  trace_idx = min(int(t / 0.1), len(raw_power_W) - 1) if raw_power_W else 0
338
  raw_kw = raw_power_W[trace_idx] / 1000.0 if raw_power_W else kw
 
 
 
339
  results.append({
340
  "time": float(t),
341
  "gpu_power_W": kw * 1000,
@@ -344,73 +678,54 @@ def _run_full(req_dict: dict) -> dict:
344
  "gpu_reactive_kVAR": kw * 0.329,
345
  "active_gpus": replicas * req_dict["numGpus"],
346
  "voltages": vs,
347
- "min_voltage": min(vs),
348
- "max_voltage": max(vs),
349
- "target_bus_voltage": vs[req_dict["targetBus"] - 1],
350
  "total_load_kW": kw,
 
351
  })
352
 
353
  return {
354
- "numSamples": len(results),
355
- "targetBus": req_dict["targetBus"],
356
- "modelLabel": req_dict["modelLabel"],
357
- "numGpus": req_dict["numGpus"],
358
-
359
-
360
- "maxNumSeqs": req_dict["maxNumSeqs"],
361
- "numReplicas": replicas,
362
- "duration": float(max(r["time"] for r in results) if results else 0),
363
- "minVoltage": float(min(r["min_voltage"] for r in results) if results else 1.0),
364
- "maxVoltage": float(max(r["max_voltage"] for r in results) if results else 1.0),
365
- "avgGpuPower": float(sum(r["gpu_power_W"] for r in results) / len(results) if results else 0),
366
  "peakGpuPower": float(max(r["gpu_power_W"] for r in results) if results else 0),
367
- "timeSeries": results,
368
  }
369
 
370
 
371
 
372
-
373
  """Get per-bus voltage (worst phase per bus)."""
374
- def _voltages(gs) -> list[float]:
375
  result = []
376
- none_count = 0
377
- for name in BUSES_ORDERED:
378
  try:
379
  tp = gs.voltages[name]
380
- vals = [float(v) for v in [tp.a, tp.b, tp.c]
381
- if not math.isnan(float(v)) and 0.5 < float(v) < 1.5]
382
  result.append(min(vals) if vals else None)
383
- except Exception as e:
384
- logger.debug(f"Bus {name} voltage unavailable: {e}")
385
  result.append(None)
386
-
387
- none_count = sum(1 for v in result if v is None)
388
- if none_count > 3:
389
- logger.warning(f"OpenDSS convergence failure: {none_count}/13 buses returned None")
390
-
391
  known = [v for v in result if v is not None]
392
  avg = sum(known) / len(known) if known else 1.0
393
  return [v if v is not None else avg for v in result]
394
 
395
 
396
  # ── FastAPI────────────────────────────────────────────────────────────────
397
-
398
  app = FastAPI()
399
- app.add_middleware(
400
- CORSMiddleware,
401
- allow_origins=["*"],
402
- allow_credentials=False,
403
- allow_methods=["*"],
404
- allow_headers=["*"],
405
- )
406
-
407
-
408
 
409
  class PowerflowRequest(BaseModel):
410
  substationVoltage: float = 1.05
411
  numBuses: int = 13
412
  baseVoltage: float = 4.16
413
  targetBus: int = 0
 
414
 
415
  class LLMImpactRequest(BaseModel):
416
  targetBus: int = 9
@@ -419,124 +734,103 @@ class LLMImpactRequest(BaseModel):
419
  modelLabel: str = "Llama-3.1-8B"
420
  numGpus: int = 1
421
  maxNumSeqs: int = 128
422
- numReplicas: int = 1
423
  durationS: int = 300
 
 
 
 
424
 
425
  class HeatmapRequest(BaseModel):
426
  voltages: list[float]
427
- dataCenterBus: Optional[int] = None
428
-
 
 
429
 
430
  @app.get("/api/health")
431
  def health():
432
- return {"status": "ok", "data_ready": _DATA_DIR.exists(),
433
- "message": "gpu2grid OpenDSS server"}
434
-
435
-
436
-
437
-
438
-
439
-
440
 
441
 
442
  """Return available traces"""
443
  @app.get("/api/traces")
444
  def list_traces():
445
-
446
  df = _load_traces_index()
447
- if df.empty:
448
- return {"traces": [], "models": [], "trainingAvailable": False}
449
-
450
  traces = df[["model_label","num_gpus","max_num_seqs"]].to_dict("records")
451
-
452
- models = []
453
- for model_label, group in df.groupby("model_label"):
454
- models.append({
455
- "modelLabel": model_label,
456
- "numGpus": int(group["num_gpus"].iloc[0]),
457
- "batchSizes": sorted(group["max_num_seqs"].tolist()),
458
- })
459
-
460
- training_available = (_DATA_DIR / "training_trace.csv").exists()
461
-
462
- return {
463
- "traces": traces,
464
- "models": models,
465
- "trainingAvailable": training_available,
466
- "dataDir": str(_DATA_DIR),
467
- }
468
 
469
 
470
  """Baseline grid simulation, no workload"""
471
  @app.post("/api/powerflow")
472
  async def powerflow(req: PowerflowRequest):
473
- logger.info(f"Powerflow request v={req.substationVoltage}")
474
- try:
475
- dc = _build_dc(scale=0.001, duration_s=5)
476
- grid = _build_grid(req.substationVoltage, "671")
477
- log = _run(dc, grid, req.substationVoltage, "671", 5)
478
- vs = _voltages(log.grid_states[-1])
479
- logger.info(f"Powerflow result min={min(vs):.4f} max={max(vs):.4f}")
480
- return {"buses": [{"id": i+1, "voltage": v, "activePower": 0.0,
481
- "reactivePower": 0.0} for i, v in enumerate(vs)],
482
- "lines": []}
483
- except Exception as e:
484
- logger.exception("Powerflow failed")
485
- raise HTTPException(status_code=500, detail=str(e))
486
-
487
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
 
489
- """Simulate AI workload impact on grid using GPU traces."""
490
- @app.post("/api/llm-impact")
491
- async def llm_impact(req: LLMImpactRequest):
492
- logger.info(f"Simulation request: {req.modelLabel} bus={req.targetBus}")
493
-
494
- try:
495
- loop = asyncio.get_event_loop()
496
- result = await loop.run_in_executor(_pool, _run_full, req.dict())
497
- return result
498
 
499
- except Exception as e:
500
- logger.exception("Simulation failed")
501
- raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
502
 
 
 
 
503
 
504
 
505
- @app.post("/api/heatmap")
506
- async def heatmap(req: HeatmapRequest):
507
- if len(req.voltages) != 13:
508
- raise HTTPException(400, f"Need 13 voltages, got {len(req.voltages)}")
509
- script = str(Path(__file__).parent / "generate_heatmap.py")
510
- with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
511
- out = f.name
512
- subprocess.run(
513
- ["python3", script, out] + [str(v) for v in req.voltages] +
514
- ([str(req.dataCenterBus)] if req.dataCenterBus else []),
515
- check=True,
516
- )
517
- png = open(out, "rb").read()
518
- os.unlink(out)
519
- return Response(content=png, media_type="image/png")
520
-
521
-
522
-
523
-
524
-
525
-
526
  def _serialize_tick(tick, req_dict: dict, raw_power_W: list[float]) -> dict:
527
- """Serialize one TickOutput to a small JSON-safe dict for the frontend."""
528
  t = tick.t_s
529
-
530
- # Grid voltages (may be None if grid didn't tick this step)
531
  voltages = None
532
  min_v = max_v = target_v = None
 
533
  if tick.grid_state is not None:
534
- voltages = _voltages(tick.grid_state)
535
  min_v = min(voltages)
536
  max_v = max(voltages)
537
- target_v = voltages[req_dict["targetBus"] - 1]
 
538
 
539
- # DC power (sum all sites)
540
  kw = 0.0
541
  batch_by_model: dict[str, int] = {}
542
  for dc_name, ds in tick.dc_states.items():
@@ -544,98 +838,96 @@ def _serialize_tick(tick, req_dict: dict, raw_power_W: list[float]) -> dict:
544
  kw += float((pw.a + pw.b + pw.c) / 1000)
545
  if hasattr(ds, "batch_size_by_model"):
546
  batch_by_model.update(ds.batch_size_by_model)
547
- if math.isnan(kw):
548
- kw = 0.0
549
 
550
  trace_idx = min(int(t / 0.1), len(raw_power_W) - 1) if raw_power_W else 0
551
  raw_kw = raw_power_W[trace_idx] / 1000.0 if raw_power_W else kw
552
-
553
  events = [{"type": e.event_type, "data": e.data} for e in tick.sim_events]
554
 
555
  return {
556
- "time": float(t),
557
- "gpu_power_kW": kw,
558
- "gpu_power_raw_kW": raw_kw,
559
- "active_gpus": req_dict["numReplicas"] * req_dict["numGpus"],
560
- "batch_by_model": batch_by_model,
561
- "voltages": voltages,
562
- "min_voltage": min_v,
563
- "max_voltage": max_v,
564
- "target_bus_voltage": target_v,
565
- "sim_events": events,
566
  }
567
 
568
-
569
- def _run_streaming(req_dict: dict):
570
- """Generator: builds coordinator and yields serialized tick dicts."""
571
- dc_bus = BUS_INDEX_TO_NAME.get(req_dict["targetBus"], "671")
572
- replicas = max(1, req_dict["numReplicas"])
573
-
574
- dc, raw_power_W = _build_dc_from_real_trace(
575
- model_label = req_dict["modelLabel"],
576
- num_gpus = req_dict["numGpus"],
577
- max_num_seqs = req_dict["maxNumSeqs"],
578
- num_replicas = replicas,
579
- duration_s = req_dict["durationS"],
580
- )
581
- grid = _build_grid(req_dict["substationVoltage"], dc_bus)
582
- grid.attach_dc(dc, bus=dc_bus, connection_type="wye",
583
- power_factor=_DC_CONFIG.power_factor)
584
-
585
- coord = Coordinator(
586
- datacenters=[dc],
587
- grid=grid,
588
-
589
- controllers=[TapScheduleController(
590
- schedule=TAP_CHANGE_SCHEDULE,
591
- dt_s=Fraction(1),
592
- )],
593
- total_duration_s=req_dict["durationS"],
594
- )
595
-
596
- step = max(1, req_dict["sampleInterval"])
597
- tick_num = 0
598
- try:
599
- for tick in coord.run_iter():
600
- if tick_num % step == 0:
601
- yield _serialize_tick(tick, req_dict, raw_power_W)
602
- tick_num += 1
603
- finally:
604
- coord.stop()
605
-
606
  @app.websocket("/ws/sim-stream")
607
  async def sim_stream(ws: WebSocket):
608
  await ws.accept()
609
  try:
610
  req_dict = await ws.receive_json()
611
  req = LLMImpactRequest(**req_dict)
612
- logger.info(f"WS stream: {req.modelLabel} bus={req.targetBus}")
613
-
614
- # Run full simulation in process pool (separate process = safe for OpenDSS)
615
  loop = asyncio.get_event_loop()
616
  result = await loop.run_in_executor(_pool, _run_full, req.dict())
617
-
618
- # Stream results tick by tick from the completed result
619
  for row in result["timeSeries"]:
620
  await ws.send_json(row)
621
-
622
  await ws.send_json({"done": True})
623
-
624
  except WebSocketDisconnect:
625
  logger.info("WS client disconnected")
626
  except Exception as e:
627
  logger.exception("WS stream failed")
628
- try:
629
- await ws.send_json({"error": str(e)})
630
- except Exception:
631
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
632
  if __name__ == "__main__":
633
- logger.info("=" * 70)
634
- logger.info(f"Data dir: {_DATA_DIR} ready={_DATA_DIR.exists()}")
635
- df = _load_traces_index()
636
- if not df.empty:
637
- models = df["model_label"].unique().tolist()
638
- logger.info(f"Models: {models}")
639
- logger.info(f"Traces: {len(df)} configurations")
640
- logger.info("=" * 70)
641
- uvicorn.run("server:app", host="0.0.0.0", port=8080, workers=1, log_level="info", ws_ping_interval=None)
 
8
 
9
 
10
 
11
+
12
+ from dataclasses import dataclass
13
  from fractions import Fraction
14
  from pathlib import Path
15
+ import subprocess, tempfile, os, uvicorn, threading, math, json, hashlib, sys, pickle
16
 
17
  import pandas as pd
18
  from fastapi import FastAPI, HTTPException, Response
 
20
  from pydantic import BaseModel
21
  from typing import Optional
22
  from fastapi import WebSocket, WebSocketDisconnect
23
+ from openg2g.controller.ofo import OFOBatchSizeController, OFOConfig, LogisticModelStore
24
 
25
 
26
  from openg2g.coordinator import Coordinator
 
40
  from openg2g.grid.config import TapPosition
41
  from openg2g.controller.tap_schedule import TapScheduleController
42
  from openg2g.metrics.voltage import compute_allbus_voltage_stats
43
+ from openg2g.controller.base import Controller
44
+ from openg2g.datacenter.base import LLMBatchSizeControlledDatacenter, LLMDatacenterState
45
+ from openg2g.clock import SimulationClock
46
+ from openg2g.controller.base import Controller
47
+ from openg2g.datacenter.base import LLMBatchSizeControlledDatacenter, LLMDatacenterState
48
+ from openg2g.datacenter.command import DatacenterCommand, SetBatchSize
49
+ from openg2g.events import EventEmitter
50
+ from openg2g.grid.command import GridCommand
51
+ from openg2g.grid.opendss import OpenDSSGrid
52
 
53
  import logging
54
 
55
+ logger = logging.getLogger(__name__)
56
+
57
+ _INTERNAL_BUSES = {"814r", "852r", "sourcebus"}
58
+
59
  logging.basicConfig(
60
  level=logging.INFO,
61
  format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
62
  )
63
  logger = logging.getLogger(__name__)
64
 
 
 
65
  import asyncio, time
66
  from concurrent.futures import ProcessPoolExecutor
67
 
68
+ from topology_coords import load_all_coords, get_lines_from_dss, CANVAS
69
+ from generate_heatmap import generate_heatmap
70
 
71
+ EXAMPLES_DIR = Path(__file__).parent / "examples"
72
 
 
 
73
 
74
+ _TOPO_COORDS: dict = {}
75
+ _TOPO_LINES: dict = {}
76
+ _TOPO_MASTERS: dict = {}
77
+
78
+ def _init_topology_data():
79
+ global _TOPO_COORDS, _TOPO_LINES, _TOPO_MASTERS
80
+ _TOPO_COORDS = load_all_coords(EXAMPLES_DIR)
81
+
82
+ topologies = ['ieee13', 'ieee34', 'ieee123']
83
+
84
+ for topo in topologies:
85
+ coords = _TOPO_COORDS.get(topo, {})
86
+ topo_dir = EXAMPLES_DIR / topo
87
+
88
+ if not topo_dir.exists():
89
+ logger.warning(f"[topology] Directory missing: {topo_dir}")
90
+ _TOPO_LINES[topo] = []
91
+ continue
92
+
93
+ dss_files = list(topo_dir.glob("*.dss"))
94
+ master_path = None
95
+
96
+ for f in dss_files:
97
+ fname = f.name.lower()
98
+ if "master" in fname or "ckt" in fname or "bus" in fname:
99
+ master_path = f
100
+ break
101
 
102
+ if not master_path and dss_files:
103
+ master_path = dss_files[0]
104
 
105
+ if not master_path:
106
+ logger.warning(f"[topology] No .dss files found in {topo_dir}")
107
+ _TOPO_LINES[topo] = []
108
+ continue
109
+
110
+ logger.info(f"[topology] Found master file for {topo}: {master_path.name}")
111
+ _TOPO_MASTERS[topo] = (topo_dir, master_path.name)
112
+ lines = get_lines_from_dss(coords, master_path)
113
+
114
+ if not lines:
115
+ combined_lines = []
116
+ seen_lines = set()
117
+ for dss_file in dss_files:
118
+ try:
119
+ ext_lines = get_lines_from_dss(coords, dss_file)
120
+ for l in ext_lines:
121
+ edge_key = tuple(sorted([l[0].lower(), l[1].lower()]))
122
+ if edge_key not in seen_lines:
123
+ seen_lines.add(edge_key)
124
+ combined_lines.append(l)
125
+ except Exception:
126
+ continue
127
+ lines = combined_lines
128
+
129
+ _TOPO_LINES[topo] = lines
130
+ logger.info(f"[topology] {topo}: {len(_TOPO_LINES[topo])} lines loaded")
131
+
132
+
133
+ _init_topology_data()
134
+
135
+ _pool = ProcessPoolExecutor(max_workers=2)
136
+ _start_time = time.time()
137
 
138
  DSS_DIR = Path(__file__).parent / "examples/ieee13"
139
  DSS_MASTER = "IEEE13Nodeckt.dss"
140
  CONFIG_PATH = Path(__file__).parent / "examples/offline/config.json"
141
 
 
 
142
  BUS_INDEX_TO_NAME = {
143
  1:"650", 2:"632", 3:"633", 4:"645", 5:"646", 6:"671",
144
  7:"684", 8:"611", 9:"634", 10:"675", 11:"652", 12:"680", 13:"692",
145
  }
146
  BUSES_ORDERED = [BUS_INDEX_TO_NAME[i] for i in range(1, 14)]
147
 
148
+ def _get_topo_buses(topology: str) -> list[str]:
149
+ topo = topology.lower()
150
+ if topo == "ieee13":
151
+ return BUSES_ORDERED
152
+ coords = _TOPO_COORDS.get(topo, {})
153
+ return [b for b in coords.keys() if b.lower() not in _INTERNAL_BUSES]
154
 
 
155
  _config_raw = json.loads(CONFIG_PATH.read_text())
156
  _MODELS = tuple(InferenceModelSpec(**m) for m in _config_raw["models"])
157
+ _DC_CONFIG = DatacenterConfig(gpus_per_server=8, base_kw_per_phase=5.0) # was 500.0
 
158
 
159
  if _config_raw.get("data_dir"):
160
  _DATA_DIR = Path(_config_raw["data_dir"])
161
  else:
162
  _DATA_DIR = Path(__file__).parent / "data/specs"
 
 
 
 
163
 
164
+ _TRACES_SUMMARY_PATH = _DATA_DIR / "traces_summary.csv"
165
  _traces_df: pd.DataFrame | None = None
166
 
167
+ _LOGISTIC_STORE: LogisticModelStore | None = None
168
 
169
+ def _get_logistic_store() -> LogisticModelStore:
170
+ global _LOGISTIC_STORE
171
+ if _LOGISTIC_STORE is None:
172
+ _LOGISTIC_STORE = LogisticModelStore.ensure(_DATA_DIR / "specs", _MODELS)
173
+ return _LOGISTIC_STORE
174
+
175
+
176
+ _RL_DIR = Path(__file__).parent / "examples" / "rl_controller"
177
+ if str(_RL_DIR) not in sys.path:
178
+ sys.path.insert(0, str(_RL_DIR))
179
+
180
+ from env import ObservationConfig as _PPOObservationConfig
181
+
182
+ try:
183
+ from env import build_observation as _ppo_free_build_observation
184
+ except ImportError:
185
+ _ppo_free_build_observation = None
186
+
187
+ _RL_OUTPUTS_DIR = Path(__file__).parent / "examples" / "rl_controller" / "outputs"
188
+
189
+
190
+ def _ppo_checkpoint_paths(topology: str) -> tuple[Path, Path]:
191
+ """Resolve (model.zip, vecnormalize.pkl) for a topology's trained PPO run.
192
+
193
+ Matches train_ppo.py's documented output layout:
194
+ examples/rl_controller/outputs/<system>/ppo/ppo_model.zip
195
+ """
196
+ ppo_dir = _RL_OUTPUTS_DIR / topology.lower() / "ppo"
197
+ return ppo_dir / "ppo_model.zip", ppo_dir / "ppo_model_vecnormalize.pkl"
198
+
199
+
200
+ @dataclass
201
+ class PPOControllerConfig:
202
+ model_path: str
203
+ vecnormalize_path: Optional[str] = None
204
+ v_min: float = 0.95
205
+ v_max: float = 1.05
206
+ deterministic: bool = True
207
+
208
+
209
+ _PPO_MODEL_CACHE: dict[str, tuple] = {}
210
+
211
+
212
+ def _get_ppo_model(config: PPOControllerConfig):
213
+ """Process-wide cache so a checkpoint isn't reloaded on every WS request."""
214
+ key = f"{config.model_path}|{config.vecnormalize_path}"
215
+ if key not in _PPO_MODEL_CACHE:
216
+ from stable_baselines3 import PPO
217
+ model = PPO.load(config.model_path, device="auto")
218
+ vecnorm_stats = None
219
+ if config.vecnormalize_path and Path(config.vecnormalize_path).exists():
220
+ with open(config.vecnormalize_path, "rb") as fh:
221
+ vecnorm_stats = pickle.load(fh)
222
+ _PPO_MODEL_CACHE[key] = (model, vecnorm_stats)
223
+ return _PPO_MODEL_CACHE[key]
224
+
225
+
226
+
227
+ class PPOBatchSizeController(Controller[LLMBatchSizeControlledDatacenter, OpenDSSGrid]):
228
+ def __init__(self, inference_models, datacenter, grid, config: PPOControllerConfig,
229
+ dt_s: Fraction, initial_batch_sizes: dict[str, int] | None = None, zone_summary=None,
230
+ bus_phase_groups=None,):
231
+ self.inference_models = inference_models
232
+ self.datacenter = datacenter
233
+ self.grid = grid
234
+ self.config = config
235
+ self._dt_s = dt_s
236
+ self.model_labels = [m.model_label for m in inference_models]
237
+ self._model, self._vecnorm = _get_ppo_model(config)
238
+
239
+ self._feasible: dict[str, list[int]] = {
240
+ m.model_label: sorted(getattr(m, "feasible_batch_sizes", []) or [])
241
+ for m in inference_models
242
+ }
243
+ self._initial_batch_sizes = dict(initial_batch_sizes or {})
244
+ self._current_bs: dict[str, int] = {}
245
+ self._replica_counts = {m.model_label: getattr(m, "initial_replicas", 1) for m in inference_models}
246
+ self.zone_summary = zone_summary
247
+ self.bus_phase_groups = bus_phase_groups
248
+ self._obs_config = None
249
+ self._control_step_count = 0
250
+
251
+ self._init_batch_sizes()
252
+
253
+
254
+ def _init_batch_sizes(self) -> None:
255
+ self._current_bs = dict(self._initial_batch_sizes)
256
+ for m in self.inference_models:
257
+ feas = self._feasible[m.model_label]
258
+ self._current_bs.setdefault(m.model_label, feas[len(feas) // 2] if feas else 1)
259
+
260
+ @property
261
+ def dt_s(self) -> Fraction:
262
+ return self._dt_s
263
+
264
+ def reset(self) -> None:
265
+ self._init_batch_sizes()
266
+ self._obs_config = None
267
+ self._control_step_count = 0
268
+
269
+ def _ensure_obs_config(self):
270
+ if self._obs_config is None:
271
+ self._obs_config = _PPOObservationConfig.from_multi_site(
272
+ site_specs={"site0": tuple(self.inference_models)},
273
+ site_replica_counts={"site0": self._replica_counts},
274
+ n_bus_phases=len(getattr(self.grid, "v_index", []) or []),
275
+ initial_batch_sizes=self._current_bs,
276
+ zone_summary=self.zone_summary,
277
+ bus_phase_groups=self.bus_phase_groups,
278
+ v_min=self.config.v_min,
279
+ v_max=self.config.v_max,
280
+ )
281
+
282
+ def _build_observation(self):
283
+ import numpy as np
284
+ if _ppo_free_build_observation is None:
285
+ raise RuntimeError(
286
+ "env.py's build_observation() could not be imported — check "
287
+ "_RL_DIR / sys.path setup near the top of server.py."
288
+ )
289
+ obs = _ppo_free_build_observation(
290
+ grid=self.grid,
291
+ datacenter=self.datacenter,
292
+ obs_config=self._obs_config,
293
+ prev_batch=self._current_bs,
294
+ )
295
+ obs = np.asarray(obs, dtype=np.float32).reshape(1, -1)
296
+ if self._vecnorm is not None:
297
+ expected_dim = self._vecnorm.obs_rms.mean.shape[0]
298
+
299
+ if obs.shape[1] < expected_dim:
300
+ pad = expected_dim - obs.shape[1]
301
+ obs = np.pad(
302
+ obs,
303
+ ((0,0),(0,pad)),
304
+ mode="constant"
305
+ )
306
+ logger.warning(
307
+ "Padded PPO observation from %d -> %d",
308
+ obs.shape[1]-pad,
309
+ expected_dim,
310
+ )
311
+
312
+ elif obs.shape[1] > expected_dim:
313
+ obs = obs[:, :expected_dim]
314
+ logger.warning(
315
+ "Truncated PPO observation from %d -> %d",
316
+ obs.shape[1],
317
+ expected_dim,
318
+ )
319
+ return obs
320
+
321
+ def _decode_action(self, action) -> dict[str, int]:
322
+ import numpy as np
323
+ action = np.asarray(action).reshape(-1)
324
+ for i, label in enumerate(self.model_labels):
325
+ feasible = self._feasible[label]
326
+ if not feasible:
327
+ continue
328
+ delta = int(action[i]) - 1
329
+ cur = self._current_bs[label]
330
+ idx = feasible.index(cur) if cur in feasible else len(feasible) // 2
331
+ self._current_bs[label] = feasible[max(0, min(len(feasible) - 1, idx + delta))]
332
+ return dict(self._current_bs)
333
+
334
+ def step(
335
+ self,
336
+ clock: SimulationClock,
337
+ events: EventEmitter,
338
+ ) -> list[DatacenterCommand | GridCommand]:
339
+ self._ensure_obs_config()
340
+ obs = self._build_observation()
341
+ action, _ = self._model.predict(obs, deterministic=self.config.deterministic)
342
+ batch_next = self._decode_action(action)
343
+
344
+ self._control_step_count += 1
345
+ logger.debug(
346
+ "PPO step %d (t=%.1f s): batch=%s",
347
+ self._control_step_count,
348
+ clock.time_s,
349
+ batch_next,
350
+ )
351
+ events.emit(
352
+ "controller.ppo.step",
353
+ {"batch_size_by_model": batch_next},
354
+ )
355
+ return [SetBatchSize(batch_size_by_model=batch_next, target=self.datacenter)]
356
+
357
+ @property
358
+ def batch_size_by_model(self) -> dict[str, int]:
359
+ return dict(self._current_bs)
360
  TAP_STEP = 0.00625
361
+
362
  INITIAL_TAPS = TapPosition(
363
  a=1.0 + 14 * TAP_STEP,
364
  b=1.0 + 6 * TAP_STEP,
365
  c=1.0 + 15 * TAP_STEP,
366
  )
367
 
368
+
369
  TAP_CHANGE_SCHEDULE = (
370
  TapPosition(
371
  a=1.0 + 16 * TAP_STEP,
372
  b=1.0 + 6 * TAP_STEP,
373
  c=1.0 + 17 * TAP_STEP,
374
+ ).at(t=75)
375
  | TapPosition(
376
  a=1.0 + 10 * TAP_STEP,
377
  b=1.0 + 6 * TAP_STEP,
378
  c=1.0 + 10 * TAP_STEP,
379
+ ).at(t=200)
380
  )
381
 
382
 
383
+ INITIAL_TAPS_BY_TOPO = {
384
+ "ieee13": TapPosition(
385
+ a=1.0 + 14 * TAP_STEP,
386
+ b=1.0 + 6 * TAP_STEP,
387
+ c=1.0 + 15 * TAP_STEP,
388
+ ),
389
+ "ieee34": TapPosition(regulators={
390
+ "creg1a": 1.0, "creg1b": 1.0, "creg1c": 1.0,
391
+ "creg2a": 1.0, "creg2b": 1.0, "creg2c": 1.0,
392
+ }),
393
+ "ieee123": TapPosition(regulators={
394
+ "creg1a": 1.0,
395
+ "creg2a": 1.0,
396
+ "creg3a": 1.0, "creg3c": 1.0,
397
+ "creg4a": 1.0, "creg4b": 1.0, "creg4c": 1.0,
398
+ }),
399
+ }
400
+
401
+
402
+
403
+
404
+
405
+
406
+
407
  """
408
  Load trace index CSV and cache it.
409
  """
410
+
411
+
412
  def _load_traces_index() -> pd.DataFrame:
413
  global _traces_df
414
  if _traces_df is None:
 
424
  Returns a list of per-timestep total power values in watts.
425
 
426
  """
427
+
428
  def _get_trace_power(model_label: str, num_gpus: int, max_num_seqs: int,
429
  num_replicas: int = 1) -> list[float]:
 
430
  df = _load_traces_index()
431
  row = df[
432
  (df["model_label"] == model_label) &
 
434
  (df["max_num_seqs"]== max_num_seqs)
435
  ]
436
  if row.empty:
437
+ raise ValueError(f"No trace found for model={model_label}")
 
 
 
 
438
  trace_file = _DATA_DIR / row.iloc[0]["trace_file"]
439
  trace_df = pd.read_csv(trace_file)
 
440
  power_W = trace_df["power_total_W"].tolist()
441
  return [p * num_replicas for p in power_W]
442
 
443
+ _load_traces_index()
 
 
444
 
445
 
446
  """Datacenter workload (baseline)"""
447
  def _build_dc(scale: float = 1.0, duration_s: int = 300) -> OfflineDatacenter:
 
448
  df = _load_traces_index()
449
  first_row = df.iloc[0]
 
450
  first_model = tuple(m for m in _MODELS if m.model_label == first_row["model_label"])
451
  inference_data = InferenceData.load(_DATA_DIR, first_model)
452
 
 
454
  _DATA_DIR / "training_trace.csv",
455
  TrainingTraceParams(),
456
  )
 
457
  t0 = min(40.0, duration_s * 0.13)
458
  t1 = min(140.0, duration_s * 0.47)
 
459
  replica_schedules = {}
460
 
461
  for m in _MODELS:
 
462
  initial_replicas = max(1, int(scale * 8))
 
463
  reduced_replicas = max(1, int(initial_replicas * 0.25))
 
464
  replica_schedules[m.model_label] = (
465
  ReplicaSchedule(initial=initial_replicas)
466
+ .ramp_to(reduced_replicas, t_start=min(150.0, duration_s * 0.50), t_end=min(220.0, duration_s * 0.73))
 
 
 
 
467
  )
468
 
469
  workload = OfflineWorkload(
470
  inference_data=inference_data,
471
  replica_schedules=replica_schedules,
472
+ training=TrainingRun(n_gpus=max(1, int(24 * scale)), trace=training_trace, target_peak_W_per_gpu=400.0).at(t_start=t0, t_end=t1),
 
 
 
 
 
 
 
 
473
  )
 
474
  return OfflineDatacenter(
475
+ _DC_CONFIG, workload, dt_s=Fraction(1, 10), seed=0, name="baseline", total_gpu_capacity=1000,
476
+ power_augmentation=PowerAugmentationConfig(amplitude_scale_range=(0.88, 1.12), noise_fraction=0.04),
477
+ )
 
 
 
 
 
 
 
 
478
 
479
 
480
  """
 
482
  Returns (datacenter, raw_power_W_list)
483
 
484
  """
485
+
486
+ def _build_dc_from_real_trace(model_label: str, num_gpus: int, max_num_seqs: int,
487
+ num_replicas: int, duration_s: int) -> tuple[OfflineDatacenter, list[float]]:
 
 
 
 
 
488
  power_W = _get_trace_power(model_label, num_gpus, max_num_seqs, num_replicas)
 
489
  target_steps = int(duration_s / 0.1)
490
  if len(power_W) < target_steps:
491
  repeats = math.ceil(target_steps / len(power_W))
 
493
  else:
494
  power_W = power_W[:target_steps]
495
 
 
 
 
496
  model_tuple = tuple(m for m in _MODELS if m.model_label == model_label)
497
  inference_data = InferenceData.load(_DATA_DIR, model_tuple)
498
 
499
+
500
  workload = OfflineWorkload(
501
  inference_data=inference_data,
502
+ replica_schedules={model_label: ReplicaSchedule(initial=num_replicas)},
503
+ initial_batch_sizes={model_label: max_num_seqs},
 
504
  )
 
 
505
  actual_gpu_count = num_replicas * num_gpus
506
+
507
+
508
  gpu_capacity = max(1000, actual_gpu_count * 2)
509
 
510
  dc = OfflineDatacenter(
511
+ _DC_CONFIG, workload, dt_s=Fraction(1, 10), seed=0, name=model_label.replace(".", "-"), total_gpu_capacity=gpu_capacity,
512
+ power_augmentation=PowerAugmentationConfig(amplitude_scale_range=(1.0, 1.0), noise_fraction=0.0),
 
 
 
 
 
513
  )
514
  return dc, power_W
515
 
516
+
517
+
518
  """Create IEEE 13-bus grid with datacenter connection."""
519
+ def _build_grid(tap_pu: float, dc_bus: str, topology: str = "ieee13") -> OpenDSSGrid:
520
+ topo = topology.lower()
521
+ if topo in _TOPO_MASTERS:
522
+ case_dir, master_file = _TOPO_MASTERS[topo]
523
+ else:
524
+ case_dir, master_file = EXAMPLES_DIR / "ieee13", "IEEE13Nodeckt.dss"
 
 
525
 
526
+ initial_taps = INITIAL_TAPS_BY_TOPO.get(topo, INITIAL_TAPS_BY_TOPO["ieee13"])
527
 
528
+ old_dir = os.getcwd()
529
+ os.chdir(case_dir)
530
+ try:
531
+ grid = OpenDSSGrid(
532
+ dss_case_dir=str(case_dir),
533
+ dss_master_file=master_file,
534
+ dt_s=Fraction(1),
535
+ source_pu=tap_pu,
536
+ initial_tap_position=initial_taps,
537
+ )
538
+ finally:
539
+ os.chdir(old_dir)
540
+ return grid
541
 
 
 
542
 
543
  """Run datacenter + grid simulation."""
544
+ def _run(dc, grid, tap_pu, dc_bus, duration_s, control_mode: str = "baseline",
545
+ active_model_labels: tuple[str, ...] | None = None,
546
+ initial_batch_sizes: dict[str, int] | None = None,
547
+ topology: str = "ieee13",
548
+ zone_summary=None,
549
+ bus_phase_groups=None):
550
+
551
  grid.attach_dc(dc, bus=dc_bus, connection_type="wye", power_factor=_DC_CONFIG.power_factor)
552
+
553
+ controllers = []
554
+ if control_mode == "ofo":
555
+ active_models = tuple(m for m in _MODELS if m.model_label in (active_model_labels or ()))
556
+ if not active_models:
557
+ raise ValueError(
558
+ f"OFO mode requires at least one of _MODELS to match the running "
559
+ f"datacenter's model(s); got active_model_labels={active_model_labels!r}"
560
+ )
561
+ controllers.append(
562
+ OFOBatchSizeController(
563
+ inference_models=active_models,
564
+ datacenter=dc,
565
+ grid=grid,
566
+ models=_get_logistic_store(),
567
+ config=OFOConfig(),
568
+ dt_s=Fraction(1),
569
+ initial_batch_sizes=initial_batch_sizes,
570
+
571
+ )
572
+ )
573
+ elif control_mode == "tap_schedule":
574
+ controllers.append(TapScheduleController(schedule=TAP_CHANGE_SCHEDULE, dt_s=Fraction(1)))
575
+ elif control_mode == "ppo":
576
+ active_models = tuple(m for m in _MODELS if m.model_label in (active_model_labels or ()))
577
+ if not active_models:
578
+ raise ValueError(
579
+ f"PPO mode requires at least one of _MODELS to match the running "
580
+ f"datacenter's model(s); got active_model_labels={active_model_labels!r}"
581
+ )
582
+ model_path, vecnorm_path = _ppo_checkpoint_paths(topology)
583
+ if not model_path.exists():
584
+ raise FileNotFoundError(
585
+ f"No trained PPO checkpoint at {model_path}. Train one first with "
586
+ f"`python examples/rl_controller/train_ppo.py --system {topology}`."
587
+ )
588
+ controllers.append(
589
+ PPOBatchSizeController(
590
+ inference_models=active_models,
591
+ datacenter=dc,
592
+ grid=grid,
593
+ config=PPOControllerConfig(
594
+ model_path=str(model_path),
595
+ vecnormalize_path=str(vecnorm_path) if vecnorm_path.exists() else None,
596
+ ),
597
+ dt_s=Fraction(1),
598
+ initial_batch_sizes=initial_batch_sizes,
599
+ zone_summary=zone_summary,
600
+ bus_phase_groups=bus_phase_groups,
601
+ )
602
+ )
603
+
604
  coord = Coordinator(
605
+ datacenters=[dc], grid=grid,
606
+ controllers=controllers,
 
 
 
 
607
  total_duration_s=duration_s,
608
  )
609
  return coord.run()
610
 
611
+
612
  """
613
  Runs one full simulation job (datacenter + grid) in a worker process
614
  and returns results for the API.
615
  """
616
+
617
  def _run_full(req_dict: dict) -> dict:
618
+ topo = req_dict.get("topology", "ieee13").lower()
619
+ buses = _get_topo_buses(topo)
620
 
621
+ target_idx = req_dict["targetBus"] - 1
622
+ if 0 <= target_idx < len(buses):
623
+ dc_bus = buses[target_idx]
624
+ else:
625
+ dc_bus = buses[0] if buses else "671"
626
 
 
627
  replicas = max(1, req_dict["numReplicas"])
628
+ control_mode = req_dict.get("controlMode", "baseline")
629
+ if req_dict.get("ofoEnabled"):
630
+ control_mode = "ofo"
631
+ if req_dict.get("ppoEnabled"):
632
+ control_mode = "ppo"
633
 
634
  dc, raw_power_W = _build_dc_from_real_trace(
635
+ model_label = req_dict["modelLabel"], num_gpus = req_dict["numGpus"],
636
+ max_num_seqs = req_dict["maxNumSeqs"], num_replicas = replicas, duration_s = req_dict["durationS"],
 
 
 
637
  )
638
+ grid = _build_grid(req_dict["substationVoltage"], dc_bus, topo)
639
+ log = _run(
640
+ dc,
641
+ grid,
642
+ req_dict["substationVoltage"],
643
+ dc_bus,
644
+ req_dict["durationS"],
645
+ control_mode=control_mode,
646
+ active_model_labels=(req_dict["modelLabel"],),
647
+ initial_batch_sizes={
648
+ req_dict["modelLabel"]: req_dict["maxNumSeqs"]
649
+ },
650
+ topology=topo,
651
+ zone_summary=None,
652
+ bus_phase_groups=None,
653
+ )
654
 
655
  step = max(1, req_dict["sampleInterval"])
656
  gs_sampled = log.grid_states[::step]
 
659
 
660
  results = []
661
  for i, (t, gs) in enumerate(zip(t_sampled, gs_sampled)):
662
+ vs = _voltages(gs, topo)
663
  dc_i = min(range(len(dc_states)), key=lambda j: abs(dc_states[j].time_s - t))
664
  ds = dc_states[dc_i]
665
  kw = float((ds.power_w.a + ds.power_w.b + ds.power_w.c) / 1000)
666
+
 
667
  if math.isnan(kw): kw = 0.0
668
  trace_idx = min(int(t / 0.1), len(raw_power_W) - 1) if raw_power_W else 0
669
  raw_kw = raw_power_W[trace_idx] / 1000.0 if raw_power_W else kw
670
+
671
+ target_v = vs[target_idx] if 0 <= target_idx < len(vs) else (vs[0] if vs else 1.0)
672
+
673
  results.append({
674
  "time": float(t),
675
  "gpu_power_W": kw * 1000,
 
678
  "gpu_reactive_kVAR": kw * 0.329,
679
  "active_gpus": replicas * req_dict["numGpus"],
680
  "voltages": vs,
681
+ "min_voltage": min(vs) if vs else 1.0,
682
+ "max_voltage": max(vs) if vs else 1.0,
683
+ "target_bus_voltage": target_v,
684
  "total_load_kW": kw,
685
+ "batch_size_by_model": dict(getattr(ds, "batch_size_by_model", {}) or {}),
686
  })
687
 
688
  return {
689
+ "numSamples": len(results), "targetBus": req_dict["targetBus"],
690
+ "modelLabel": req_dict["modelLabel"], "numGpus": req_dict["numGpus"],
691
+ "maxNumSeqs": req_dict["maxNumSeqs"], "numReplicas": replicas,
692
+ "controlMode": control_mode,
693
+ "duration": float(max(r["time"] for r in results) if results else 0),
694
+ "minVoltage": float(min(r["min_voltage"] for r in results) if results else 1.0),
695
+ "maxVoltage": float(max(r["max_voltage"] for r in results) if results else 1.0),
696
+ "avgGpuPower": float(sum(r["gpu_power_W"] for r in results) / len(results) if results else 0),
 
 
 
 
697
  "peakGpuPower": float(max(r["gpu_power_W"] for r in results) if results else 0),
698
+ "timeSeries": results,
699
  }
700
 
701
 
702
 
 
703
  """Get per-bus voltage (worst phase per bus)."""
704
+ def _voltages(gs, topology: str = "ieee13") -> list[float]:
705
  result = []
706
+ buses = _get_topo_buses(topology)
707
+ for name in buses:
708
  try:
709
  tp = gs.voltages[name]
710
+ vals = [float(v) for v in [tp.a, tp.b, tp.c] if not math.isnan(float(v)) and 0.5 < float(v) < 1.5]
 
711
  result.append(min(vals) if vals else None)
712
+ except Exception:
 
713
  result.append(None)
 
 
 
 
 
714
  known = [v for v in result if v is not None]
715
  avg = sum(known) / len(known) if known else 1.0
716
  return [v if v is not None else avg for v in result]
717
 
718
 
719
  # ── FastAPI────────────────────────────────────────────────────────────────
 
720
  app = FastAPI()
721
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"])
 
 
 
 
 
 
 
 
722
 
723
  class PowerflowRequest(BaseModel):
724
  substationVoltage: float = 1.05
725
  numBuses: int = 13
726
  baseVoltage: float = 4.16
727
  targetBus: int = 0
728
+ topology: str = "ieee13"
729
 
730
  class LLMImpactRequest(BaseModel):
731
  targetBus: int = 9
 
734
  modelLabel: str = "Llama-3.1-8B"
735
  numGpus: int = 1
736
  maxNumSeqs: int = 128
737
+ numReplicas: int = 1
738
  durationS: int = 300
739
+ topology: str = "ieee13"
740
+ controlMode: str = "baseline"
741
+ ofoEnabled: bool = False
742
+ ppoEnabled: bool = False
743
 
744
  class HeatmapRequest(BaseModel):
745
  voltages: list[float]
746
+ dataCenterBus: Optional[int] = None
747
+ dataCenterBusName: Optional[str] = None
748
+ topology: str = 'ieee13'
749
+ busNames: Optional[list[str]] = None
750
 
751
  @app.get("/api/health")
752
  def health():
753
+ return {"status": "ok", "data_ready": _DATA_DIR.exists()}
 
 
 
 
 
 
 
754
 
755
 
756
  """Return available traces"""
757
  @app.get("/api/traces")
758
  def list_traces():
 
759
  df = _load_traces_index()
760
+ if df.empty: return {"traces": [], "models": [], "trainingAvailable": False}
 
 
761
  traces = df[["model_label","num_gpus","max_num_seqs"]].to_dict("records")
762
+ models = [{"modelLabel": label, "numGpus": int(grp["num_gpus"].iloc[0]), "batchSizes": sorted(grp["max_num_seqs"].tolist())} for label, grp in df.groupby("model_label")]
763
+ return {"traces": traces, "models": models, "trainingAvailable": (_DATA_DIR / "training_trace.csv").exists(), "dataDir": str(_DATA_DIR)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
764
 
765
 
766
  """Baseline grid simulation, no workload"""
767
  @app.post("/api/powerflow")
768
  async def powerflow(req: PowerflowRequest):
769
+ topo = req.topology.lower()
770
+ logger.info(f"Powerflow request topo={topo} v={req.substationVoltage}")
 
 
 
 
 
 
 
 
 
 
 
 
771
 
772
+ if topo == "ieee13":
773
+ try:
774
+ df = _load_traces_index()
775
+ if df.empty:
776
+ grid = _build_grid(req.substationVoltage, "671", topo)
777
+ grid.dss.text(f"vsource.source.pu={req.substationVoltage}")
778
+ grid.dss.solution.solve()
779
+ class DummyGridState:
780
+ def __init__(self, dss_instance):
781
+ self.voltages = {}
782
+ for name in BUSES_ORDERED:
783
+ dss_instance.circuit.set_active_bus(name)
784
+ v_pu = dss_instance.bus.pu_voltages
785
+ class PhaseVoltages:
786
+ a = v_pu[0] if len(v_pu) > 0 else 1.0
787
+ b = v_pu[2] if len(v_pu) > 2 else 1.0
788
+ c = v_pu[4] if len(v_pu) > 4 else 1.0
789
+ self.voltages[name] = PhaseVoltages()
790
+ vs = _voltages(DummyGridState(grid.dss), topo)
791
+ else:
792
+ dc = _build_dc(scale=0.001, duration_s=5)
793
+ grid = _build_grid(req.substationVoltage, "671", topo)
794
+ log = _run(dc, grid, req.substationVoltage, "671", 5)
795
+ vs = _voltages(log.grid_states[-1], topo)
796
+
797
+ return {
798
+ "buses": [{"id": i + 1, "name": BUSES_ORDERED[i], "voltage": v,
799
+ "activePower": 0.0, "reactivePower": 0.0} for i, v in enumerate(vs)],
800
+ "lines": _TOPO_LINES.get('ieee13', []),
801
+ }
802
+ except Exception as e:
803
+ raise HTTPException(status_code=500, detail=str(e))
804
 
805
+ coords = _TOPO_COORDS.get(topo, {})
806
+ if not coords:
807
+ raise HTTPException(status_code=404, detail=f"Unknown topology: {topo}")
 
 
 
 
 
 
808
 
809
+ bus_list = [b for b in coords.keys() if b.lower() not in _INTERNAL_BUSES]
810
+ n = len(bus_list)
811
+ buses_out = []
812
+ for i, name in enumerate(bus_list):
813
+ drop = (i / max(n - 1, 1)) * 0.04
814
+ v = round(req.substationVoltage - drop, 4)
815
+ buses_out.append({"id": i + 1, "name": name, "voltage": v, "activePower": 0.0, "reactivePower": 0.0})
816
 
817
+ lines_out = _TOPO_LINES.get(topo, [])
818
+ logger.info(f"Powerflow stub {topo}: {len(buses_out)} buses, {len(lines_out)} lines returned")
819
+ return {"buses": buses_out, "lines": lines_out}
820
 
821
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822
  def _serialize_tick(tick, req_dict: dict, raw_power_W: list[float]) -> dict:
 
823
  t = tick.t_s
 
 
824
  voltages = None
825
  min_v = max_v = target_v = None
826
+ topo = req_dict.get("topology", "ieee13").lower()
827
  if tick.grid_state is not None:
828
+ voltages = _voltages(tick.grid_state, topo)
829
  min_v = min(voltages)
830
  max_v = max(voltages)
831
+ target_idx = req_dict["targetBus"] - 1
832
+ target_v = voltages[target_idx] if 0 <= target_idx < len(voltages) else 1.0
833
 
 
834
  kw = 0.0
835
  batch_by_model: dict[str, int] = {}
836
  for dc_name, ds in tick.dc_states.items():
 
838
  kw += float((pw.a + pw.b + pw.c) / 1000)
839
  if hasattr(ds, "batch_size_by_model"):
840
  batch_by_model.update(ds.batch_size_by_model)
841
+ if math.isnan(kw): kw = 0.0
 
842
 
843
  trace_idx = min(int(t / 0.1), len(raw_power_W) - 1) if raw_power_W else 0
844
  raw_kw = raw_power_W[trace_idx] / 1000.0 if raw_power_W else kw
 
845
  events = [{"type": e.event_type, "data": e.data} for e in tick.sim_events]
846
 
847
  return {
848
+ "time": float(t), "gpu_power_kW": kw, "gpu_power_raw_kW": raw_kw,
849
+ "active_gpus": req_dict["numReplicas"] * req_dict["numGpus"], "batch_by_model": batch_by_model,
850
+ "voltages": voltages, "min_voltage": min_v, "max_voltage": max_v, "target_bus_voltage": target_v, "sim_events": events,
 
 
 
 
 
 
 
851
  }
852
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
853
  @app.websocket("/ws/sim-stream")
854
  async def sim_stream(ws: WebSocket):
855
  await ws.accept()
856
  try:
857
  req_dict = await ws.receive_json()
858
  req = LLMImpactRequest(**req_dict)
 
 
 
859
  loop = asyncio.get_event_loop()
860
  result = await loop.run_in_executor(_pool, _run_full, req.dict())
 
 
861
  for row in result["timeSeries"]:
862
  await ws.send_json(row)
 
863
  await ws.send_json({"done": True})
 
864
  except WebSocketDisconnect:
865
  logger.info("WS client disconnected")
866
  except Exception as e:
867
  logger.exception("WS stream failed")
868
+ try: await ws.send_json({"error": str(e)})
869
+ except Exception: pass
870
+
871
+
872
+ @app.post("/api/heatmap")
873
+ async def heatmap(req: HeatmapRequest):
874
+ topo = req.topology.lower()
875
+ coords = _TOPO_COORDS.get(topo, {})
876
+ lines = _TOPO_LINES.get(topo, [])
877
+ cw, ch = CANVAS.get(topo, (900, 750))
878
+
879
+ if req.busNames:
880
+ bus_names = req.busNames
881
+ elif topo == 'ieee13':
882
+ bus_names = BUSES_ORDERED
883
+ else:
884
+ bus_names = [str(i + 1) for i in range(len(req.voltages))]
885
+
886
+ if len(req.voltages) != len(bus_names):
887
+ raise HTTPException(400, f"voltages length {len(req.voltages)} != bus_names length {len(bus_names)}")
888
+
889
+ dc_bus: str | None = None
890
+ if req.dataCenterBusName:
891
+ dc_bus = req.dataCenterBusName.lower()
892
+ elif req.dataCenterBus and topo == 'ieee13':
893
+ idx = req.dataCenterBus - 1
894
+ if 0 <= idx < len(bus_names):
895
+ dc_bus = bus_names[idx].lower()
896
+
897
+ substation_bus = bus_names[0] if bus_names else '650'
898
+
899
+ with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as f:
900
+ out = f.name
901
+
902
+ try:
903
+ generate_heatmap(
904
+ voltages = req.voltages,
905
+ bus_names = bus_names,
906
+ coords = coords,
907
+ lines = lines,
908
+ output_path = out,
909
+ canvas_w = cw,
910
+ canvas_h = ch,
911
+ dc_bus = dc_bus,
912
+ substation_bus = substation_bus,
913
+ topology = topo,
914
+ )
915
+ svg = open(out, "rb").read()
916
+ finally:
917
+ if os.path.exists(out):
918
+ os.unlink(out)
919
+
920
+ return Response(content=svg, media_type="image/svg+xml")
921
+
922
+
923
+ @app.get("/api/topology/{topo}/buses")
924
+ async def topology_buses(topo: str):
925
+ coords = _TOPO_COORDS.get(topo.lower(), {})
926
+ return {
927
+ "topology": topo,
928
+ "buses": list(coords.keys()),
929
+ "coords": {k: list(v) for k, v in coords.items()},
930
+ }
931
+
932
  if __name__ == "__main__":
933
+ uvicorn.run("server:app", host="0.0.0.0", port=8080, workers=1, ws_ping_interval=None)
 
 
 
 
 
 
 
 
topology_coords.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ topology_coords.py
3
+ ------------------
4
+ Parses OpenDSS BusCoords files and scales them to pixel canvas coordinates.
5
+ Supports IEEE 13-bus, 34-bus, and 123-bus test feeders.
6
+
7
+ Expected file layout (relative to backend root):
8
+ examples/ieee13/IEEE13Node_BusXY.csv
9
+ examples/ieee34/ieee34Master.dss (SetBusXY commands inline)
10
+ examples/ieee123/ieee123Master.dss (SetBusXY commands inline)
11
+ """
12
+
13
+ from __future__ import annotations
14
+ import re
15
+ from pathlib import Path
16
+
17
+ # Canvas sizes (w x h pixels) tuned per feeder aspect ratio
18
+ CANVAS = {
19
+ 'ieee13': (900, 750),
20
+ 'ieee34': (1400, 550),
21
+ 'ieee123': (1200, 950),
22
+ }
23
+ PAD = 80 # px padding so labels never clip
24
+
25
+ # Internal/phantom buses to exclude from heatmap rendering
26
+ INTERNAL_BUSES = {
27
+ "sourcebus",
28
+ }
29
+ COORD_FILES = {
30
+ 'ieee13': ('ieee13', 'IEEE13Node_BusXY.csv'),
31
+ 'ieee34': ('ieee34', 'ieee34Master.dss'),
32
+ 'ieee123': ('ieee123', 'ieee123Master.dss'),
33
+ }
34
+
35
+ # Fallback filenames to try if primary not found
36
+ COORD_FILE_FALLBACKS = {
37
+ 'ieee34': ['IEEE34Bus.dss', 'ieee34Mod1.dss', 'ieee34master.dss', 'IEEE34_BusXY.csv'],
38
+ 'ieee123': ['IEEE123Bus.dss', 'ieee123master.dss', 'IEEE123_BusXY.csv'],
39
+ }
40
+
41
+
42
+ def _parse_csv(path: Path) -> dict[str, tuple[float, float]]:
43
+ coords: dict[str, tuple[float, float]] = {}
44
+ with open(path) as f:
45
+ for raw in f:
46
+ line = raw.strip()
47
+ if not line or line.startswith('!') or line.startswith('//'):
48
+ continue
49
+ parts = [p.strip() for p in line.split(',')]
50
+ if len(parts) < 3:
51
+ continue
52
+ try:
53
+ name = parts[0].lower().strip()
54
+ coords[name] = (float(parts[1]), float(parts[2]))
55
+ except ValueError:
56
+ continue
57
+ return coords
58
+
59
+
60
+ def _parse_setbusxy(path: Path) -> dict[str, tuple[float, float]]:
61
+ """
62
+ Parse SetBusXY commands from an OpenDSS .dss file.
63
+ Handles both forms:
64
+ SetBusXY bus=800 X=0 Y=0
65
+ SetBusXY bus=SourceBus X=-300 Y=0
66
+ """
67
+ coords: dict[str, tuple[float, float]] = {}
68
+ # Regex: SetBusXY bus=<name> X=<x> Y=<y> (any order of X/Y after bus=)
69
+ pattern = re.compile(
70
+ r'SetBusXY\s+bus=(\S+)\s+X=([-\d.]+)\s+Y=([-\d.]+)',
71
+ re.IGNORECASE
72
+ )
73
+ with open(path, encoding='utf-8', errors='replace') as f:
74
+ for raw in f:
75
+ m = pattern.search(raw)
76
+ if m:
77
+ name = m.group(1).lower().strip()
78
+ try:
79
+ coords[name] = (float(m.group(2)), float(m.group(3)))
80
+ except ValueError:
81
+ continue
82
+ return coords
83
+
84
+
85
+
86
+
87
+ def _parse_dss_buscoord(path: Path) -> dict[str, tuple[float, float]]:
88
+ """
89
+ Parse a standalone BusCoord .dss file (3 whitespace-separated columns:
90
+ busname x y).
91
+ """
92
+ SKIP = ('new ', 'set ', 'redirect ', 'calcv', 'solve', 'clear', '!',
93
+ '//', 'edit ', 'more ', '~', 'setbusxy')
94
+ coords: dict[str, tuple[float, float]] = {}
95
+ with open(path, encoding='utf-8', errors='replace') as f:
96
+ for raw in f:
97
+ line = raw.strip()
98
+ if not line:
99
+ continue
100
+ if any(line.lower().startswith(p) for p in SKIP):
101
+ continue
102
+ parts = re.split(r'[\s,]+', line)
103
+ if len(parts) >= 3:
104
+ try:
105
+ name = parts[0].lower().strip()
106
+ coords[name] = (float(parts[1]), float(parts[2]))
107
+ except ValueError:
108
+ continue
109
+ return coords
110
+
111
+
112
+ def _parse(path: Path) -> dict[str, tuple[float, float]]:
113
+ """Auto-detect format and parse."""
114
+ suffix = path.suffix.lower()
115
+ if suffix == '.csv':
116
+ return _parse_csv(path)
117
+
118
+ # For .dss files: try SetBusXY first (master files with inline coords),
119
+ # then fall back to 3-column buscoord format.
120
+ coords = _parse_setbusxy(path)
121
+ if coords:
122
+ return coords
123
+ return _parse_dss_buscoord(path)
124
+
125
+
126
+ def scale_coords(
127
+ raw: dict[str, tuple[float, float]],
128
+ canvas_w: int,
129
+ canvas_h: int,
130
+ pad: int = PAD,
131
+ exclude: set[str] = INTERNAL_BUSES,
132
+ ) -> dict[str, tuple[int, int]]:
133
+ """Scale raw feeder coords to pixel coords, flipping Y axis."""
134
+ # Filter out internal buses before scaling
135
+ raw = {k: v for k, v in raw.items() if k.lower() not in exclude}
136
+ if not raw:
137
+ return {}
138
+ xs = [x for x, _ in raw.values()]
139
+ ys = [y for _, y in raw.values()]
140
+ x_min, x_max = min(xs), max(xs)
141
+ y_min, y_max = min(ys), max(ys)
142
+ xr = x_max - x_min or 1.0
143
+ yr = y_max - y_min or 1.0
144
+ dw = canvas_w - 2 * pad
145
+ dh = canvas_h - 2 * pad
146
+ return {
147
+ name: (
148
+ int(pad + (x - x_min) / xr * dw),
149
+ int((canvas_h - pad) - (y - y_min) / yr * dh),
150
+ )
151
+ for name, (x, y) in raw.items()
152
+ }
153
+
154
+
155
+ def _find_coord_file(base: Path, topo: str, subdir: str, primary: str) -> Path | None:
156
+ """Try primary filename then fallbacks."""
157
+ candidates = [primary] + COORD_FILE_FALLBACKS.get(topo, [])
158
+ for name in candidates:
159
+ p = base / subdir / name
160
+ if p.exists():
161
+ return p
162
+ # Also try listing the directory for any .dss or .csv
163
+ d = base / subdir
164
+ if d.is_dir():
165
+ for p in sorted(d.iterdir()):
166
+ if p.suffix.lower() in ('.dss', '.csv'):
167
+ return p
168
+ return None
169
+
170
+
171
+ def load_all_coords(examples_dir: str | Path) -> dict[str, dict[str, tuple[int, int]]]:
172
+ """
173
+ Load + scale bus coords for all three IEEE feeders.
174
+ examples_dir should point to the examples/ folder containing ieee13/, ieee34/, ieee123/.
175
+ """
176
+ base = Path(examples_dir)
177
+ result: dict[str, dict[str, tuple[int, int]]] = {}
178
+ for topo, (subdir, primary) in COORD_FILES.items():
179
+ cw, ch = CANVAS[topo]
180
+ path = _find_coord_file(base, topo, subdir, primary)
181
+ if path and path.exists():
182
+ raw = _parse(path)
183
+ scaled = scale_coords(raw, canvas_w=cw, canvas_h=ch)
184
+ result[topo] = scaled
185
+ print(f"[topology_coords] {topo}: {len(scaled)} buses from {path.name} "
186
+ f"(raw={len(raw)}, filtered {len(raw)-len(scaled)} internal)")
187
+ else:
188
+ import warnings
189
+ warnings.warn(f"[topology_coords] Not found: {base / subdir / primary}")
190
+ result[topo] = {}
191
+ return result
192
+
193
+ def get_lines_from_dss(
194
+ coords: dict[str, tuple[int, int]],
195
+ dss_master: Path,
196
+ ) -> list[tuple[str, str, str]]:
197
+ """
198
+ Extract line AND transformer connections from all OpenDSS files in the case folder.
199
+ Transformers (regulators, substation XFMs) are treated as edges just like lines,
200
+ since they represent physical bus-to-bus connections for topology display.
201
+ """
202
+ if not dss_master.exists():
203
+ return []
204
+
205
+ bus1_re = re.compile(r'bus1=([^\s.]+)', re.IGNORECASE)
206
+ bus2_re = re.compile(r'bus2=([^\s.]+)', re.IGNORECASE)
207
+ name_re = re.compile(r'New\s+Line\.(\S+)', re.IGNORECASE)
208
+ seen: set[tuple[str, str]] = set()
209
+ lines: list[tuple[str, str, str]] = []
210
+
211
+ coords_lc = {str(k).lower().strip(): v for k, v in coords.items()}
212
+ normalized_coords = {str(k).lower().lstrip('0'): v for k, v in coords_lc.items()}
213
+
214
+ dss_dir = dss_master.parent
215
+ dss_files = list(dss_dir.glob("*.dss")) if dss_dir.is_dir() else [dss_master]
216
+
217
+ # ── Stitch all files into one flat list of complete statements ────────────
218
+ all_statements: list[str] = []
219
+ for file_path in dss_files:
220
+ with open(file_path, encoding='utf-8', errors='replace') as f:
221
+ current = ""
222
+ for raw in f:
223
+ stripped = raw.strip()
224
+ if not stripped or stripped.startswith('!') or stripped.startswith('//'):
225
+ if current:
226
+ all_statements.append(current)
227
+ current = ""
228
+ continue
229
+ if stripped.startswith('~'):
230
+ current += " " + stripped[1:].strip()
231
+ else:
232
+ if current:
233
+ all_statements.append(current)
234
+ current = stripped
235
+ if current:
236
+ all_statements.append(current)
237
+
238
+ # ── Pass 1: New Line.* statements ─────────────────────────────────────────
239
+ for stmt in all_statements:
240
+ if 'new line.' not in stmt.lower():
241
+ continue
242
+ _process_normalized_statement(
243
+ stmt, name_re, bus1_re, bus2_re,
244
+ normalized_coords, coords_lc, seen, lines
245
+ )
246
+
247
+ # ── Pass 2: New Transformer.* statements (regulators, XFMs) ──────────────
248
+ # These create the regulator bridge connections (650→rg60, 814→814r, etc.)
249
+ for stmt in all_statements:
250
+ if 'new transformer.' not in stmt.lower():
251
+ continue
252
+ _extract_transformer_edge(stmt, coords_lc, seen, lines)
253
+
254
+ return lines
255
+
256
+
257
+ def _extract_transformer_edge(
258
+ stmt: str,
259
+ coords_lc: dict,
260
+ seen: set,
261
+ lines: list,
262
+ ) -> None:
263
+ """
264
+ Extract a bus-pair edge from a (stitched) transformer statement.
265
+ Handles both:
266
+ - buses=[650.1 RG60.1] / buses=(814.1 814r.1) (inline array)
267
+ - wdg=1 bus=A wdg=2 bus=B (per-winding)
268
+ Skips duplicate phases of the same regulator bank (e.g. Reg1/Reg2/Reg3
269
+ all connect 650→rg60; we only need one edge).
270
+ """
271
+ # Try inline buses=[A B ...] or buses=(A B ...)
272
+ m = re.search(r'buses\s*=\s*[\[\(]([^\]\)]+)[\]\)]', stmt, re.IGNORECASE)
273
+ if m:
274
+ tokens = m.group(1).split()
275
+ cleaned = []
276
+ for t in tokens:
277
+ b = t.split('.')[0].lower().strip()
278
+ if b and b not in cleaned:
279
+ cleaned.append(b)
280
+ if len(cleaned) >= 2:
281
+ _add_edge(cleaned[0], cleaned[1], coords_lc, seen, lines)
282
+ return
283
+
284
+ # Fallback: collect all bus=X occurrences in the statement
285
+ bus_matches = re.findall(r'\bbus=(\S+)', stmt, re.IGNORECASE)
286
+ cleaned = []
287
+ for t in bus_matches:
288
+ b = t.split('.')[0].lower().strip()
289
+ if b and b not in cleaned and b not in ('sourcebus',):
290
+ cleaned.append(b)
291
+ if len(cleaned) >= 2:
292
+ _add_edge(cleaned[0], cleaned[1], coords_lc, seen, lines)
293
+
294
+
295
+ def _add_edge(
296
+ b1: str, b2: str,
297
+ coords_lc: dict,
298
+ seen: set,
299
+ lines: list,
300
+ ) -> None:
301
+ """Add edge if at least one endpoint has coordinates and it's not a duplicate."""
302
+ if b1 not in coords_lc and b2 not in coords_lc:
303
+ return
304
+ key = (min(b1, b2), max(b1, b2))
305
+ if key not in seen:
306
+ seen.add(key)
307
+ lines.append((b1, b2, f"{b1}-{b2}"))
308
+ def _process_normalized_statement(statement: str, name_re, bus1_re, bus2_re, normalized_coords, original_coords, seen, lines):
309
+ """Helper to parse and match lines using zero-resilient lookup normalization."""
310
+ mn = name_re.search(statement)
311
+ m1 = bus1_re.search(statement)
312
+ m2 = bus2_re.search(statement)
313
+
314
+ if mn and m1 and m2:
315
+ # 1. Clean and isolate the base bus names
316
+ b1_raw = m1.group(1).lower().strip()
317
+ b2_raw = m2.group(1).lower().strip()
318
+
319
+ # 2. Normalize key structure by removing leading zeros
320
+ b1_norm = b1_raw.lstrip('0')
321
+ b2_norm = b2_raw.lstrip('0')
322
+
323
+ # 3. Check against our flexible coordinate index
324
+ if b1_norm in normalized_coords or b2_norm in normalized_coords:
325
+ # Reconstruct keys using whatever exact casing/format the original coords array expected
326
+ actual_b1 = next((k for k in original_coords if k.lower().lstrip('0') == b1_norm), b1_raw)
327
+ actual_b2 = next((k for k in original_coords if k.lower().lstrip('0') == b2_norm), b2_raw)
328
+
329
+ key = (min(actual_b1, actual_b2), max(actual_b1, actual_b2))
330
+ if key not in seen:
331
+ seen.add(key)
332
+ lines.append((actual_b1, actual_b2, f"{actual_b1}-{actual_b2}"))
333
+
334
+
335
+ def _process_statement(statement: str, name_re, bus1_re, bus2_re, coords, seen, lines):
336
+ """Helper to parse a fully stitched multi-line statement."""
337
+ mn = name_re.search(statement)
338
+ m1 = bus1_re.search(statement)
339
+ m2 = bus2_re.search(statement)
340
+
341
+ if mn and m1 and m2:
342
+ b1 = m1.group(1).lower().strip()
343
+ b2 = m2.group(1).lower().strip()
344
+
345
+ # Match lines even if they reference a filtered out internal boundary node
346
+ if b1 in coords or b2 in coords:
347
+ key = (min(b1, b2), max(b1, b2))
348
+ if key not in seen:
349
+ seen.add(key)
350
+ lines.append((b1, b2, f"{b1}-{b2}"))