Skip to content

Commit 867c720

Browse files
authored
Merge pull request #101 from dovallev/add-grid-model
Add grid model: event-constrained power flow on IEEE 14-bus
2 parents 07c6ce7 + f490890 commit 867c720

18 files changed

Lines changed: 608 additions & 21 deletions

README.md

Lines changed: 10 additions & 10 deletions
Large diffs are not rendered by default.

gdplib/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@
2424
import gdplib.cstr
2525
import gdplib.reverse_electrodialysis
2626
import gdplib.multiperiod_blending
27+
import gdplib.grid

gdplib/grid/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# grid — Event-Constrained Optimal Power Flow on the IEEE 14-Bus Network
2+
3+
This model describes the optimal capacity expansion design of the IEEE 14-bus power
4+
distribution network under stochastic nodal demands. The objective is to minimize the
5+
total slack added to generator and transmission line capacities so that power balance
6+
constraints are satisfied across a set of sampled demand scenarios.
7+
8+
The key modeling contribution is an event constraint: rather than requiring all
9+
generators and lines to stay within their capacity limits in every scenario (a joint
10+
chance constraint), the formulation uses ATLEAST logic to require that at least
11+
`active_gens` generators and `active_lines` transmission lines simultaneously satisfy
12+
their capacity bounds. This event must hold in at least 90% of scenarios (alpha=0.9),
13+
approximated via Sample Average Approximation. The GDP formulation encodes whether each
14+
capacity constraint is satisfied or violated per scenario using disjunctions, and
15+
connects them to a per-scenario Boolean event variable via a logical equivalence.
16+
17+
Source paper:
18+
19+
> Ovalle, D., Mazzadi, S., Laird, C. D., Grossmann, I. E., & Pulsipher, J. L. (2025).
20+
> Event constrained programming. *Computers & Chemical Engineering*, 199, 109145.
21+
> https://doi.org/10.1016/j.compchemeng.2025.109145
22+
23+
## Problem Details
24+
25+
### Solution
26+
27+
The objective is to minimize total generator and line capacity slack subject to the
28+
event constraint at alpha=0.9. Optimal objective values for various ATLEAST
29+
configurations are reported in the reference above.
30+
31+
### Size
32+
33+
Problem size for the default instance with `active_gens=4`, `active_lines=20`, and
34+
`num_samples=500`:
35+
36+
| Problem | vars | Bool | bin | int | cont | cons | nl | disj | disjtn |
37+
|---------|-------|-------|-----|-----|-------|-------|----|-------|--------|
38+
| grid | 47525 | 35000 | 0 | 0 | 12525 | 52000 | 0 | 35000 | 12500 |
39+
40+
- ``vars``: variables
41+
- ``Bool``: Boolean variables (disjunct indicator variables)
42+
- ``bin``: binary variables
43+
- ``int``: integer variables
44+
- ``cont``: continuous variables
45+
- ``cons``: constraints
46+
- ``nl``: nonlinear constraints
47+
- ``disj``: disjuncts
48+
- ``disjtn``: disjunctions
49+
50+
The model scales linearly with `num_samples`: each additional scenario adds variables
51+
and constraints proportionally to the number of generators and lines.

gdplib/grid/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .grid import build_model
2+
3+
__all__ = ["build_model"]

gdplib/grid/grid.py

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
"""
2+
grid.py
3+
4+
GDP model for event-constrained optimal power flow on the IEEE 14-bus network.
5+
6+
The model minimizes the total capacity expansion (slack) needed for generators and
7+
transmission lines to satisfy power balance constraints across a set of sampled load
8+
scenarios. An event constraint enforces that, in at least 90% of scenarios (alpha=0.9),
9+
at least a minimum number of generator and line capacity limits are simultaneously
10+
satisfied. The GDP formulation uses disjunctions to model whether each capacity
11+
constraint is satisfied or violated in each scenario, and a logical equivalence to
12+
define the satisfaction event per scenario using ATLEAST logic.
13+
14+
References
15+
----------
16+
[1] Ovalle, D., Mazzadi, S., Laird, C. D., Grossmann, I. E., & Pulsipher, J. L. (2025).
17+
Event constrained programming. Computers & Chemical Engineering, 199, 109145.
18+
https://doi.org/10.1016/j.compchemeng.2025.109145
19+
"""
20+
21+
import numpy as np
22+
from math import ceil
23+
from pyomo.environ import (
24+
ConcreteModel,
25+
RangeSet,
26+
Var,
27+
Objective,
28+
minimize,
29+
BooleanVar,
30+
LogicalConstraint,
31+
Param,
32+
TransformationFactory,
33+
SolverFactory,
34+
atleast,
35+
land,
36+
equivalent,
37+
)
38+
from pyomo.gdp import Disjunct
39+
40+
41+
def build_model(active_gens=4, active_lines=20, num_samples=500): # noqa: C901
42+
"""
43+
Build the GDP model for event-constrained optimal power flow on the IEEE
44+
14-bus network.
45+
46+
The model minimizes the total capacity slack for generators and transmission lines
47+
across a set of Monte Carlo load scenarios drawn from a multivariate normal
48+
distribution. An event constraint enforces that, with probability at least
49+
alpha=0.9 (approximated via Sample Average Approximation), at least
50+
active_gens generators and active_lines transmission lines simultaneously
51+
satisfy their capacity bounds in a given scenario. The event logic uses the
52+
ATLEAST operator from Generalized Disjunctive Programming, which is less
53+
conservative than a joint chance constraint requiring all equipment to be
54+
within limits simultaneously.
55+
56+
Parameters
57+
----------
58+
active_gens : int, optional
59+
Minimum number of generators whose capacity constraints must be satisfied
60+
in a scenario for the event to hold. Default is 4.
61+
active_lines : int, optional
62+
Minimum number of transmission lines whose capacity constraints must be
63+
satisfied in a scenario for the event to hold. Default is 20.
64+
num_samples : int, optional
65+
Number of Monte Carlo samples for the uncertain nodal load demands.
66+
Default is 500.
67+
68+
Returns
69+
-------
70+
m : pyomo.ConcreteModel
71+
Pyomo GDP model of the event-constrained optimal power flow problem,
72+
ready for GDP transformation and solving.
73+
74+
References
75+
----------
76+
[1] Ovalle, D., Mazzadi, S., Laird, C. D., Grossmann, I. E.,
77+
& Pulsipher, J. L. (2025).
78+
Event constrained programming. Computers & Chemical Engineering, 199, 109145.
79+
https://doi.org/10.1016/j.compchemeng.2025.109145
80+
"""
81+
if not isinstance(active_gens, int) or not (0 <= active_gens <= 5):
82+
raise ValueError(
83+
f"active_gens must be an integer between 0 and 5, got {active_gens}"
84+
)
85+
if not isinstance(active_lines, int) or not (0 <= active_lines <= 20):
86+
raise ValueError(
87+
f"active_lines must be an integer between 0 and 20, got {active_lines}"
88+
)
89+
if not isinstance(num_samples, int) or num_samples < 1:
90+
raise ValueError(f"num_samples must be a positive integer, got {num_samples}")
91+
92+
# Nominal loads and covariance for uncertain demand parameters
93+
theta_nom = np.array([87.3, 50.0, 25.0, 28.8, 50.0, 25.0, 0, 0, 0, 0, 0])
94+
covar = np.identity(len(theta_nom)) * 1200.0
95+
covar[covar == 0] = 240.0
96+
97+
# Network parameters
98+
line_cap = 50.0
99+
gen_cap = [332.0, 140.0, 100.0, 100.0, 100.0]
100+
num_lines = 20
101+
num_gens = 5
102+
103+
# Sample uncertain load scenarios using a local RNG
104+
rng = np.random.default_rng(42)
105+
thetas = rng.multivariate_normal(theta_nom, covar, num_samples)
106+
thetas[thetas <= 0.0] = 0.0
107+
108+
m = ConcreteModel()
109+
110+
# Index sets
111+
m.K = RangeSet(0, num_samples - 1)
112+
m.G = RangeSet(1, num_gens)
113+
m.L = RangeSet(1, num_lines)
114+
115+
# Variables
116+
m.z_line = Var(m.L, m.K, bounds=(-150, 150))
117+
m.z_gen = Var(m.G, m.K, bounds=(0, 632))
118+
m.d_line = Var(m.L, bounds=(0, 100))
119+
m.d_gen = Var(m.G, bounds=(0, 300))
120+
121+
# Objective: minimize total capacity slack
122+
m.obj = Objective(
123+
sense=minimize,
124+
expr=sum(m.d_gen[g] for g in m.G) + sum(m.d_line[ln] for ln in m.L),
125+
)
126+
127+
# Power balance constraints (IEEE 14-bus topology)
128+
@m.Constraint(m.K)
129+
def h1(m, k):
130+
return m.z_gen[1, k] - m.z_line[1, k] - m.z_line[6, k] == 0
131+
132+
@m.Constraint(m.K)
133+
def h2(m, k):
134+
return (
135+
m.z_gen[2, k]
136+
+ m.z_line[1, k]
137+
- sum(m.z_line[i, k] for i in [2, 4, 5])
138+
- thetas[k, 0]
139+
== 0
140+
)
141+
142+
@m.Constraint(m.K)
143+
def h3(m, k):
144+
return m.z_gen[3, k] + m.z_line[2, k] - m.z_line[3, k] - thetas[k, 1] == 0
145+
146+
@m.Constraint(m.K)
147+
def h4(m, k):
148+
return (
149+
sum(m.z_line[i, k] for i in [3, 4, 8])
150+
- sum(m.z_line[i, k] for i in [7, 11])
151+
- thetas[k, 2]
152+
== 0
153+
)
154+
155+
@m.Constraint(m.K)
156+
def h5(m, k):
157+
return sum(m.z_line[i, k] for i in [5, 6, 7, 12]) - thetas[k, 3] == 0
158+
159+
@m.Constraint(m.K)
160+
def h6(m, k):
161+
return (
162+
m.z_gen[4, k]
163+
+ sum(m.z_line[i, k] for i in [16, 18])
164+
- sum(m.z_line[i, k] for i in [12, 19])
165+
- thetas[k, 4]
166+
== 0
167+
)
168+
169+
@m.Constraint(m.K)
170+
def h7(m, k):
171+
return m.z_line[9, k] - sum(m.z_line[i, k] for i in [8, 10]) == 0
172+
173+
@m.Constraint(m.K)
174+
def h8(m, k):
175+
return m.z_gen[5, k] - m.z_line[9, k] == 0
176+
177+
@m.Constraint(m.K)
178+
def h9(m, k):
179+
return (
180+
sum(m.z_line[i, k] for i in [10, 11])
181+
- sum(m.z_line[i, k] for i in [13, 14])
182+
- thetas[k, 5]
183+
== 0
184+
)
185+
186+
@m.Constraint(m.K)
187+
def h10(m, k):
188+
return sum(m.z_line[i, k] for i in [13, 20]) - thetas[k, 6] == 0
189+
190+
@m.Constraint(m.K)
191+
def h11(m, k):
192+
return m.z_line[19, k] - m.z_line[20, k] - thetas[k, 7] == 0
193+
194+
@m.Constraint(m.K)
195+
def h12(m, k):
196+
return m.z_line[17, k] - m.z_line[18, k] - thetas[k, 8] == 0
197+
198+
@m.Constraint(m.K)
199+
def h13(m, k):
200+
return (
201+
m.z_line[15, k] - sum(m.z_line[i, k] for i in [16, 17]) - thetas[k, 9] == 0
202+
)
203+
204+
@m.Constraint(m.K)
205+
def h14(m, k):
206+
return m.z_line[14, k] - m.z_line[15, k] - thetas[k, 10] == 0
207+
208+
# Generator disjunctions: capacity constraint satisfied or violated per scenario
209+
def build_satisfy_gen_constraints(disjunct, g, k):
210+
m = disjunct.model()
211+
212+
@disjunct.Constraint()
213+
def gen_upper(disjunct):
214+
return m.z_gen[g, k] - gen_cap[g - 1] - m.d_gen[g] <= 0
215+
216+
def build_not_satisfy_gen_constraints(disjunct, g, k):
217+
m = disjunct.model()
218+
219+
@disjunct.Constraint()
220+
def gen_upper(disjunct):
221+
return m.z_gen[g, k] - gen_cap[g - 1] - m.d_gen[g] >= 0
222+
223+
m.gen_disjunct1 = Disjunct(m.G, m.K, rule=build_satisfy_gen_constraints)
224+
m.gen_disjunct2 = Disjunct(m.G, m.K, rule=build_not_satisfy_gen_constraints)
225+
226+
@m.Disjunction(m.G, m.K)
227+
def gen_constrs_satisfy_or_not(m, g, k):
228+
return [m.gen_disjunct1[g, k], m.gen_disjunct2[g, k]]
229+
230+
# Line disjunctions: both bounds satisfied, lower violated, or upper violated
231+
def build_satisfy_line_constraints(disjunct, ln, k):
232+
m = disjunct.model()
233+
234+
@disjunct.Constraint()
235+
def line_lower(disjunct):
236+
return -m.z_line[ln, k] - line_cap - m.d_line[ln] <= 0
237+
238+
@disjunct.Constraint()
239+
def line_upper(disjunct):
240+
return m.z_line[ln, k] - line_cap - m.d_line[ln] <= 0
241+
242+
def build_not_satisfy_lower_line_constraints(disjunct, ln, k):
243+
m = disjunct.model()
244+
245+
@disjunct.Constraint()
246+
def line_lower(disjunct):
247+
return -m.z_line[ln, k] - line_cap - m.d_line[ln] >= 0
248+
249+
def build_not_satisfy_upper_line_constraints(disjunct, ln, k):
250+
m = disjunct.model()
251+
252+
@disjunct.Constraint()
253+
def line_upper(disjunct):
254+
return m.z_line[ln, k] - line_cap - m.d_line[ln] >= 0
255+
256+
m.line_disjunct1 = Disjunct(m.L, m.K, rule=build_satisfy_line_constraints)
257+
m.line_disjunct2 = Disjunct(m.L, m.K, rule=build_not_satisfy_lower_line_constraints)
258+
m.line_disjunct3 = Disjunct(m.L, m.K, rule=build_not_satisfy_upper_line_constraints)
259+
260+
@m.Disjunction(m.L, m.K)
261+
def line_constrs_satisfy_or_not(m, ln, k):
262+
return [
263+
m.line_disjunct1[ln, k],
264+
m.line_disjunct2[ln, k],
265+
m.line_disjunct3[ln, k],
266+
]
267+
268+
# Event variable: True if scenario k jointly satisfies enough constraints
269+
m.w = BooleanVar(m.K)
270+
271+
@m.LogicalConstraint(m.K)
272+
def event_logic(m, k):
273+
return equivalent(
274+
land(
275+
atleast(active_gens, *m.gen_disjunct1[:, k].indicator_var),
276+
atleast(active_lines, *m.line_disjunct1[:, k].indicator_var),
277+
),
278+
m.w[k],
279+
)
280+
281+
# Chance constraint: event must hold in at least 90% of scenarios
282+
alpha = 0.9
283+
m.min_constrs = Param(mutable=True, initialize=ceil(alpha * num_samples))
284+
m.event_constr = LogicalConstraint(expr=atleast(m.min_constrs, m.w))
285+
286+
return m
287+
288+
289+
if __name__ == '__main__':
290+
m = build_model()
291+
TransformationFactory('core.logical_to_linear').apply_to(m)
292+
TransformationFactory('gdp.bigm').apply_to(m)
293+
opt = SolverFactory('gurobi_direct')
294+
results = opt.solve(m, tee=True)
295+
print(results)

gdplib/grid/model_size_report.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
| Component | Number |
2+
|:----------------------|---------:|
3+
| Variables | 47525 |
4+
| Binary variables | 35000 |
5+
| Integer variables | 0 |
6+
| Continuous variables | 12525 |
7+
| Disjunctions | 12500 |
8+
| Disjuncts | 35000 |
9+
| Constraints | 52000 |
10+
| Nonlinear constraints | 0 |

gdplib/kaibel/kaibel_solve_gdp.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,10 @@ def build_model():
103103
m.so = RangeSet(2, doc="Side product outlets")
104104
m.bounds = RangeSet(2, doc="Number of boundary condition values")
105105

106-
m.candidate_trays_main = Set(
107-
initialize=m.tray - [m.con_tray, m.reb_tray], doc="Candidate trays for top and \
108-
bottom sections 1 and 4"
109-
)
106+
m.candidate_trays_main = Set(
107+
initialize=m.tray - [m.con_tray, m.reb_tray],
108+
doc="Candidate trays for top and bottom sections 1 and 4",
109+
)
110110
m.candidate_trays_feed = Set(
111111
initialize=m.tray - [m.con_tray, m.feed_tray, m.reb_tray],
112112
doc="Candidate trays for feed section 2",

0 commit comments

Comments
 (0)