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 +22 -0
- data/specs/specs/4925acb216d43131/logistic_fit.json +22 -0
- data/specs/specs/73691f793f6be469/logistic_fit.json +22 -0
- data/specs/specs/860654eabd3dfebf/logistic_fit.json +22 -0
- data/specs/specs/bc0bde304544a603/logistic_fit.json +22 -0
- data/training_trace.csv +0 -0
- examples/ieee123/IEEE123Bus.dss +467 -0
- examples/ieee123/IEEELineCodes.dss +213 -0
- examples/ieee13/IEEE13Bus.dss +196 -0
- examples/ieee34/IEEE34Bus.dss +289 -0
- examples/ieee34/IEEELineCodes.dss +213 -0
- examples/rl_controller/build_library.py +1435 -0
- examples/rl_controller/controller.py +327 -0
- examples/rl_controller/env.py +819 -0
- examples/rl_controller/evaluate.py +897 -0
- examples/rl_controller/plotting.py +723 -0
- examples/rl_controller/scenarios.py +1080 -0
- examples/rl_controller/systems.py +466 -0
- examples/rl_controller/train_ppo.py +850 -0
- generate_heatmap.py +212 -212
- server.py +602 -310
- topology_coords.py +350 -0
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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
matplotlib.
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
from
|
| 9 |
-
import
|
| 10 |
-
import
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
(
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
]
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
)
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
if
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
| 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
|
|
|
|
| 55 |
|
|
|
|
| 56 |
|
| 57 |
-
#currently set to 2 for free tier at hf
|
| 58 |
-
_pool = ProcessPoolExecutor(max_workers=2)
|
| 59 |
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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)
|
| 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)
|
| 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 |
-
|
| 212 |
-
|
| 213 |
-
|
| 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 |
-
|
| 230 |
-
|
| 231 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 350 |
"total_load_kW": kw,
|
|
|
|
| 351 |
})
|
| 352 |
|
| 353 |
return {
|
| 354 |
-
"numSamples":
|
| 355 |
-
"
|
| 356 |
-
"
|
| 357 |
-
"
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
"
|
| 361 |
-
"
|
| 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":
|
| 368 |
}
|
| 369 |
|
| 370 |
|
| 371 |
|
| 372 |
-
|
| 373 |
"""Get per-bus voltage (worst phase per bus)."""
|
| 374 |
-
def _voltages(gs) -> list[float]:
|
| 375 |
result = []
|
| 376 |
-
|
| 377 |
-
for name in
|
| 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
|
| 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]
|
| 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 |
-
|
| 474 |
-
|
| 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 |
-
|
| 490 |
-
|
| 491 |
-
|
| 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 |
-
|
| 500 |
-
|
| 501 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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":
|
| 557 |
-
"
|
| 558 |
-
"
|
| 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 |
-
|
| 630 |
-
|
| 631 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 632 |
if __name__ == "__main__":
|
| 633 |
-
|
| 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}"))
|