nashopt 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nashopt-1.0.0.dist-info/METADATA +539 -0
- nashopt-1.0.0.dist-info/RECORD +6 -0
- nashopt-1.0.0.dist-info/WHEEL +5 -0
- nashopt-1.0.0.dist-info/licenses/LICENSE +201 -0
- nashopt-1.0.0.dist-info/top_level.txt +1 -0
- nashopt.py +2504 -0
nashopt.py
ADDED
|
@@ -0,0 +1,2504 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NashOpt - A Python Library for Solving Generalized Nash Equilibrium (GNE) and Game-Design Problems.
|
|
3
|
+
|
|
4
|
+
For general nonlinear problems, the KKT conditions of all agents are enforced jointly by solving a nonlinear least-squares problem, using JAX for automatic differentiation. Game-design problems can also be solved on multi-parametric GNE problems to determine optimal game parameters.
|
|
5
|
+
|
|
6
|
+
For linear-quadratic problems, the KKT conditions are enforced instead via mixed-integer linear programming (MILP). MILP enables finding GNE solutions, if they exist, and to possibly enumerate all GNEs in case of multiple equilibria. Game-design problems based on convex piecewise affine objectives can also be solved via mip.
|
|
7
|
+
|
|
8
|
+
(C) 2025 Alberto Bemporad
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import jax
|
|
13
|
+
import jax.numpy as jnp
|
|
14
|
+
from jax.scipy.linalg import cho_factor, cho_solve
|
|
15
|
+
from jaxopt import ScipyBoundedMinimize
|
|
16
|
+
from scipy.optimize import least_squares
|
|
17
|
+
from scipy.linalg import block_diag
|
|
18
|
+
from types import SimpleNamespace
|
|
19
|
+
import highspy
|
|
20
|
+
from functools import partial
|
|
21
|
+
import osqp
|
|
22
|
+
from scipy.sparse import csc_matrix
|
|
23
|
+
from scipy.sparse import eye as speye
|
|
24
|
+
from scipy.sparse import vstack as spvstack
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import gurobipy as gp
|
|
29
|
+
GUROBI_INSTALLED = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
GUROBI_INSTALLED = False
|
|
32
|
+
|
|
33
|
+
jax.config.update("jax_enable_x64", True)
|
|
34
|
+
|
|
35
|
+
def eval_residual(res, verbose, f_evals, elapsed_time):
|
|
36
|
+
warn_tol = 1.e-4
|
|
37
|
+
norm_res = np.sqrt(np.sum(res**2))
|
|
38
|
+
if verbose > 0:
|
|
39
|
+
print(
|
|
40
|
+
f"GNEP solved: ||KKT residual||_2 = {norm_res:.3e} found in {f_evals} function evaluations, time = {elapsed_time:.3f} seconds.")
|
|
41
|
+
if norm_res > warn_tol:
|
|
42
|
+
print(
|
|
43
|
+
f"\033[1;33mWarning: the KKT residual norm > {warn_tol}, an equilibrium may not have been found.\033[0m")
|
|
44
|
+
return norm_res
|
|
45
|
+
|
|
46
|
+
class GNEP():
|
|
47
|
+
def __init__(self, sizes, f, g=None, ng=None, lb=None, ub=None, Aeq=None, beq=None, h=None, nh=None, variational=False, parametric=False):
|
|
48
|
+
"""
|
|
49
|
+
Generalized Nash Equilibrium Problem (GNEP) with N agents, where agent i solves:
|
|
50
|
+
|
|
51
|
+
min_{x_i} f_i(x)
|
|
52
|
+
s.t. g(x) <= 0 (shared inequality constraints)
|
|
53
|
+
Aeq x = beq (shared linear equality constraints)
|
|
54
|
+
h(x) = 0 (shared nonlinear equality constraints)
|
|
55
|
+
lb <= x <= ub (box constraints on x_i)
|
|
56
|
+
i= 1,...,N (N = number of agents)
|
|
57
|
+
|
|
58
|
+
Parameters:
|
|
59
|
+
-----------
|
|
60
|
+
sizes : list of int
|
|
61
|
+
List containing the number of variables for each agent.
|
|
62
|
+
f : list of callables
|
|
63
|
+
List of objective functions for each agent. Each function f[i](x) takes the full variable vector x as input.
|
|
64
|
+
g : callable, optional
|
|
65
|
+
Shared inequality constraint function g(x) <= 0, common to all agents.
|
|
66
|
+
ng : int, optional
|
|
67
|
+
Number of shared inequality constraints. Required if g is provided.
|
|
68
|
+
lb : array-like, optional
|
|
69
|
+
Lower bounds for the variables. If None, no lower bounds are applied.
|
|
70
|
+
ub : array-like, optional
|
|
71
|
+
Upper bounds for the variables. If None, no upper bounds are applied.
|
|
72
|
+
Aeq : array-like, optional
|
|
73
|
+
Equality constraint matrix. If None, no equality constraints are applied.
|
|
74
|
+
beq : array-like, optional
|
|
75
|
+
Equality constraint vector. If None, no equality constraints are applied.
|
|
76
|
+
h : callable, optional
|
|
77
|
+
Shared nonlinear equality constraint function h(x) = 0, common to all agents.
|
|
78
|
+
nh : int, optional
|
|
79
|
+
Number of shared nonlinear equality constraints. Required if h is provided.
|
|
80
|
+
variational : bool, optional
|
|
81
|
+
If True, solve for a variational GNE by imposing equal Lagrange multipliers.
|
|
82
|
+
|
|
83
|
+
(C) 2025 Alberto Bemporad
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
self.sizes = sizes
|
|
87
|
+
self.N = len(sizes) # number of agents
|
|
88
|
+
self.nvar = sum(sizes) # number of variables
|
|
89
|
+
self.i2 = np.cumsum(sizes) # x_i = x(i1[i]:i2[i])
|
|
90
|
+
self.i1 = np.hstack((0, self.i2[:-1]))
|
|
91
|
+
if len(f) != self.N:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"List of functions f must contain {self.N} elements, you provided {len(f)}.")
|
|
94
|
+
self.f = f
|
|
95
|
+
self.g = g # shared inequality constraints
|
|
96
|
+
# number of shared inequality constraints, taken into account by all agents
|
|
97
|
+
self.ng = int(ng) if ng is not None else 0
|
|
98
|
+
if self.ng > 0 and g is None:
|
|
99
|
+
raise ValueError("If ng>0, g must be provided.")
|
|
100
|
+
|
|
101
|
+
if lb is None:
|
|
102
|
+
lb = -np.inf * np.ones(self.nvar)
|
|
103
|
+
if ub is None:
|
|
104
|
+
ub = np.inf * np.ones(self.nvar)
|
|
105
|
+
|
|
106
|
+
# Make bounds JAX arrays
|
|
107
|
+
self.lb = jnp.asarray(lb)
|
|
108
|
+
self.ub = jnp.asarray(ub)
|
|
109
|
+
|
|
110
|
+
# Use *integer indices* of bounded variables per agent
|
|
111
|
+
self.lb_idx = []
|
|
112
|
+
self.ub_idx = []
|
|
113
|
+
self.nlb = []
|
|
114
|
+
self.nub = []
|
|
115
|
+
self.is_lower_bounded = []
|
|
116
|
+
self.is_upper_bounded = []
|
|
117
|
+
self.is_bounded = []
|
|
118
|
+
|
|
119
|
+
for i in range(self.N):
|
|
120
|
+
sl = slice(self.i1[i], self.i2[i])
|
|
121
|
+
lb_mask = np.isfinite(lb[sl])
|
|
122
|
+
ub_mask = np.isfinite(ub[sl])
|
|
123
|
+
lb_idx_i = np.nonzero(lb_mask)[0]
|
|
124
|
+
ub_idx_i = np.nonzero(ub_mask)[0]
|
|
125
|
+
self.lb_idx.append(lb_idx_i)
|
|
126
|
+
self.ub_idx.append(ub_idx_i)
|
|
127
|
+
self.nlb.append(len(lb_idx_i))
|
|
128
|
+
self.nub.append(len(ub_idx_i))
|
|
129
|
+
self.is_lower_bounded.append(self.nlb[i] > 0)
|
|
130
|
+
self.is_upper_bounded.append(self.nub[i] > 0)
|
|
131
|
+
self.is_bounded.append(
|
|
132
|
+
self.is_lower_bounded[i] or self.is_upper_bounded[i])
|
|
133
|
+
|
|
134
|
+
if Aeq is not None:
|
|
135
|
+
if beq is None:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
"If Aeq is provided, beq must also be provided.")
|
|
138
|
+
if Aeq.shape[1] != self.nvar:
|
|
139
|
+
raise ValueError(f"Aeq must have {self.nvar} columns.")
|
|
140
|
+
if Aeq.shape[0] != beq.shape[0]:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
"Aeq and beq must have compatible dimensions.")
|
|
143
|
+
self.Aeq = jnp.asarray(Aeq)
|
|
144
|
+
self.beq = jnp.asarray(beq)
|
|
145
|
+
self.neq = Aeq.shape[0]
|
|
146
|
+
else:
|
|
147
|
+
self.Aeq = None
|
|
148
|
+
self.beq = None
|
|
149
|
+
self.neq = 0
|
|
150
|
+
|
|
151
|
+
self.h = h # shared nonlinear equality constraints
|
|
152
|
+
# number of shared nonlinear equality constraints, taken into account by all agents
|
|
153
|
+
self.nh = int(nh) if nh is not None else 0
|
|
154
|
+
if self.nh > 0 and h is None:
|
|
155
|
+
raise ValueError("If nh>0, h must be provided.")
|
|
156
|
+
|
|
157
|
+
self.has_eq = self.neq > 0 or self.nh > 0
|
|
158
|
+
self.has_constraints = any(self.is_bounded) or (
|
|
159
|
+
self.ng > 0) or self.has_eq
|
|
160
|
+
|
|
161
|
+
if variational:
|
|
162
|
+
if self.ng == 0 and not self.has_eq:
|
|
163
|
+
print(
|
|
164
|
+
"\033[1;31mVariational GNE requested but no shared constraints are defined.\033[0m")
|
|
165
|
+
variational = False
|
|
166
|
+
self.variational = variational
|
|
167
|
+
|
|
168
|
+
n_shared = self.ng + self.neq + self.nh # number of shared multipliers
|
|
169
|
+
self.nlam = [int(self.nlb[i] + self.nub[i] + n_shared)
|
|
170
|
+
for i in range(self.N)] # Number of multipliers per agent
|
|
171
|
+
|
|
172
|
+
if not variational:
|
|
173
|
+
self.nlam_sum = sum(self.nlam) # total number of multipliers
|
|
174
|
+
i2_lam = np.cumsum(self.nlam)
|
|
175
|
+
i1_lam = np.hstack((0, i2_lam[:-1]))
|
|
176
|
+
self.ii_lam = [np.arange(i1_lam[i], i2_lam[i], dtype=int) for i in range(
|
|
177
|
+
self.N)] # indices of multipliers for each agent
|
|
178
|
+
else:
|
|
179
|
+
# all agents have the same multipliers for shared constraints
|
|
180
|
+
self.ii_lam = []
|
|
181
|
+
j = n_shared
|
|
182
|
+
for i in range(self.N):
|
|
183
|
+
self.ii_lam.append(np.hstack((np.arange(self.ng, dtype=int), # shared inequality-multipliers
|
|
184
|
+
# agent-specific box multipliers
|
|
185
|
+
np.arange(
|
|
186
|
+
j, j + self.nlb[i] + self.nub[i], dtype=int),
|
|
187
|
+
np.arange(self.ng, self.ng + self.neq + self.nh, dtype=int)))) # shared equality-multipliers
|
|
188
|
+
j += self.nlb[i] + self.nub[i]
|
|
189
|
+
self.nlam_sum = n_shared + \
|
|
190
|
+
sum([self.nlb[i] + self.nub[i] for i in range(self.N)])
|
|
191
|
+
|
|
192
|
+
# Gradients of the agents' objectives
|
|
193
|
+
if not parametric:
|
|
194
|
+
self.df = [
|
|
195
|
+
jax.jit(
|
|
196
|
+
jax.grad(
|
|
197
|
+
lambda xi, x, i=i: self.f[i](
|
|
198
|
+
x.at[self.i1[i]:self.i2[i]].set(xi)
|
|
199
|
+
),
|
|
200
|
+
argnums=0,
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
for i in range(self.N)
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
if self.ng > 0:
|
|
207
|
+
self.g = jax.jit(self.g)
|
|
208
|
+
self.dg = jax.jit(jax.jacobian(self.g))
|
|
209
|
+
|
|
210
|
+
if self.nh > 0:
|
|
211
|
+
self.h = jax.jit(self.h)
|
|
212
|
+
self.dh = jax.jit(jax.jacobian(self.h))
|
|
213
|
+
else:
|
|
214
|
+
self.df = [
|
|
215
|
+
jax.jit(
|
|
216
|
+
jax.grad(
|
|
217
|
+
lambda xi, x, p, i=i: self.f[i](
|
|
218
|
+
x.at[self.i1[i]:self.i2[i]].set(xi), p
|
|
219
|
+
),
|
|
220
|
+
argnums=0,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
for i in range(self.N)
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
if self.ng > 0:
|
|
227
|
+
self.g = jax.jit(self.g)
|
|
228
|
+
self.dg = jax.jit(jax.jacobian(self.g, argnums=0))
|
|
229
|
+
|
|
230
|
+
if self.nh > 0:
|
|
231
|
+
self.h = jax.jit(self.h)
|
|
232
|
+
self.dh = jax.jit(jax.jacobian(self.h, argnums=0))
|
|
233
|
+
|
|
234
|
+
self.parametric = parametric
|
|
235
|
+
self.npar = 0
|
|
236
|
+
|
|
237
|
+
def kkt_residual_shared(self, z):
|
|
238
|
+
# KKT residual function (shared constraints part)
|
|
239
|
+
x = z[:self.nvar]
|
|
240
|
+
isparam = self.parametric
|
|
241
|
+
if isparam:
|
|
242
|
+
p = z[-self.npar:]
|
|
243
|
+
|
|
244
|
+
res = []
|
|
245
|
+
|
|
246
|
+
ng = self.ng
|
|
247
|
+
if ng > 0:
|
|
248
|
+
if not isparam:
|
|
249
|
+
gx = self.g(x) # (ng,)
|
|
250
|
+
dgx = self.dg(x) # (ng, nvar)
|
|
251
|
+
else:
|
|
252
|
+
gx = self.g(x, p) # (ng,)
|
|
253
|
+
dgx = self.dg(x, p) # (ng, nvar)
|
|
254
|
+
else:
|
|
255
|
+
gx = None
|
|
256
|
+
dgx = None
|
|
257
|
+
|
|
258
|
+
nh = self.nh # number of nonlinear equalities
|
|
259
|
+
if nh > 0:
|
|
260
|
+
if not isparam:
|
|
261
|
+
hx = self.h(x) # (nh,)
|
|
262
|
+
dhx = self.dh(x) # (nh, nvar)
|
|
263
|
+
else:
|
|
264
|
+
hx = self.h(x, p) # (nh,)
|
|
265
|
+
dhx = self.dh(x, p) # (nh, nvar)
|
|
266
|
+
else:
|
|
267
|
+
dhx = None
|
|
268
|
+
|
|
269
|
+
# primal feasibility for shared constraints
|
|
270
|
+
neq = self.neq # number of linear equalities
|
|
271
|
+
if ng > 0:
|
|
272
|
+
# res.append(jnp.maximum(gx, 0.0)) # This is redundant, due to the Fischer–Burmeister function used below in kkt_residual_i
|
|
273
|
+
pass
|
|
274
|
+
if neq > 0:
|
|
275
|
+
if not isparam:
|
|
276
|
+
res.append(self.Aeq @ x - self.beq)
|
|
277
|
+
else:
|
|
278
|
+
res.append(self.Aeq @ x - (self.beq + self.Seq @ p))
|
|
279
|
+
if nh > 0:
|
|
280
|
+
res.append(hx)
|
|
281
|
+
return res, gx, dgx, dhx
|
|
282
|
+
|
|
283
|
+
def kkt_residual_i(self, z, i, gx, dgx, dhx):
|
|
284
|
+
# KKT residual function for agent i
|
|
285
|
+
x = z[:self.nvar]
|
|
286
|
+
isparam = self.parametric
|
|
287
|
+
if not isparam:
|
|
288
|
+
if self.has_constraints:
|
|
289
|
+
lam = z[self.nvar:]
|
|
290
|
+
else:
|
|
291
|
+
if self.has_constraints:
|
|
292
|
+
lam = z[self.nvar:-self.npar]
|
|
293
|
+
p = z[-self.npar:]
|
|
294
|
+
|
|
295
|
+
ng = self.ng
|
|
296
|
+
nh = self.nh
|
|
297
|
+
neq = self.neq # number of linear equalities
|
|
298
|
+
nh = self.nh # number of nonlinear equalities
|
|
299
|
+
|
|
300
|
+
is_bounded = self.is_bounded
|
|
301
|
+
is_lower_bounded = self.is_lower_bounded
|
|
302
|
+
is_upper_bounded = self.is_upper_bounded
|
|
303
|
+
|
|
304
|
+
res = []
|
|
305
|
+
i1 = int(self.i1[i])
|
|
306
|
+
i2 = int(self.i2[i])
|
|
307
|
+
|
|
308
|
+
if is_bounded[i]:
|
|
309
|
+
zero = jnp.zeros(self.sizes[i])
|
|
310
|
+
if is_bounded[i] or ng > 0 or neq > 0: # we have inequality constraints
|
|
311
|
+
nlam_i = self.nlam[i]
|
|
312
|
+
lam_i = lam[self.ii_lam[i]]
|
|
313
|
+
|
|
314
|
+
# 1st KKT condition
|
|
315
|
+
if not isparam:
|
|
316
|
+
res_1st = self.df[i](x[i1:i2], x)
|
|
317
|
+
else:
|
|
318
|
+
res_1st = self.df[i](x[i1:i2], x, p)
|
|
319
|
+
|
|
320
|
+
if ng > 0:
|
|
321
|
+
res_1st += dgx[:, i1:i2].T @ lam_i[:ng]
|
|
322
|
+
if is_lower_bounded[i]:
|
|
323
|
+
lb_idx_i = self.lb_idx[i]
|
|
324
|
+
# Add -sum(e_i * lam_lb_i), where e_i is a unit vector
|
|
325
|
+
res_1st -= zero.at[lb_idx_i].set(lam_i[ng:ng + self.nlb[i]])
|
|
326
|
+
if is_upper_bounded[i]:
|
|
327
|
+
ub_idx_i = self.ub_idx[i]
|
|
328
|
+
# Add sum(e_i * lam_ub_i)
|
|
329
|
+
res_1st += zero.at[ub_idx_i].set(lam_i[ng +
|
|
330
|
+
self.nlb[i]:ng + self.nlb[i] + self.nub[i]])
|
|
331
|
+
if neq > 0:
|
|
332
|
+
res_1st += self.Aeq[:, i1:i2].T @ lam_i[-neq-nh:][:neq]
|
|
333
|
+
if nh > 0:
|
|
334
|
+
res_1st += dhx[:, i1:i2].T @ lam_i[-nh:]
|
|
335
|
+
res.append(res_1st)
|
|
336
|
+
|
|
337
|
+
x_i = x[i1:i2]
|
|
338
|
+
|
|
339
|
+
if is_bounded[i] or ng > 0:
|
|
340
|
+
# inequality constraints
|
|
341
|
+
if ng > 0:
|
|
342
|
+
g_parts = [gx]
|
|
343
|
+
else:
|
|
344
|
+
g_parts = []
|
|
345
|
+
if is_lower_bounded[i]:
|
|
346
|
+
g_parts.append(-x_i[lb_idx_i] + self.lb[i1:i2][lb_idx_i])
|
|
347
|
+
if is_upper_bounded[i]:
|
|
348
|
+
g_parts.append(x_i[ub_idx_i] - self.ub[i1:i2][ub_idx_i])
|
|
349
|
+
gix = jnp.concatenate(g_parts)
|
|
350
|
+
|
|
351
|
+
# complementary slackness
|
|
352
|
+
# Use Fischer–Burmeister NCP function: min phi(a,b) = sqrt(a^2 + b^2) - (a + b)
|
|
353
|
+
# where here a = lam_i>=0 and b = -gix>=0
|
|
354
|
+
res.append(jnp.sqrt(lam_i[:nlam_i-neq-nh] **
|
|
355
|
+
2 + gix**2) - lam_i[:nlam_i-neq-nh] + gix)
|
|
356
|
+
# res.append(jnp.minimum(lam_i[:nlam_i-neq-nh], -gix))
|
|
357
|
+
# res.append(lam_i[:nlam_i-neq-nh]*gix)
|
|
358
|
+
|
|
359
|
+
# dual feasibility
|
|
360
|
+
# res.append(jnp.minimum(lam_i[:nlam_i-neq-nh], 0.0)) # This is redundant, due to the Fischer–Burmeister function above
|
|
361
|
+
return res
|
|
362
|
+
|
|
363
|
+
def kkt_residual(self, z):
|
|
364
|
+
# KKT residual function: append agent-specific parts to shared constraints part
|
|
365
|
+
|
|
366
|
+
res, gx, dgx, dhx = self.kkt_residual_shared(z)
|
|
367
|
+
|
|
368
|
+
for i in range(self.N):
|
|
369
|
+
res += self.kkt_residual_i(z, i, gx, dgx, dhx)
|
|
370
|
+
|
|
371
|
+
return jnp.concatenate(res)
|
|
372
|
+
|
|
373
|
+
def solve(self, x0=None, max_nfev=200, tol=1e-12, solver="trf", verbose=1):
|
|
374
|
+
""" Solve the GNEP starting from initial guess x0.
|
|
375
|
+
|
|
376
|
+
The residuals of the KKT optimality conditions of all agents are minimized jointly as a
|
|
377
|
+
nonlinear least-squares problem, solved via a Trust Region Reflective algorithm or Levenberg-Marquardt method. Strict complementarity is enforced via the Fischer–Burmeister NCP function. Variational GNEs are also supported by simply imposing equal Lagrange multipliers.
|
|
378
|
+
|
|
379
|
+
Parameters:
|
|
380
|
+
-----------
|
|
381
|
+
x0 : array-like or None
|
|
382
|
+
Initial guess for the Nash equilibrium x.
|
|
383
|
+
max_nfev : int, optional
|
|
384
|
+
Maximum number of function evaluations.
|
|
385
|
+
tol : float, optional
|
|
386
|
+
Tolerance used for solver convergence.
|
|
387
|
+
solver : str, optional
|
|
388
|
+
Solver method used by scipy.optimize.least_squares: "lm" (Levenberg-Marquardt) or "trf" (Trust Region Reflective algorithm). Method "dogbox" is another option.
|
|
389
|
+
verbose : int, optional
|
|
390
|
+
Verbosity level (0: silent, 1: termination report, 2: progress (not supported by "lm")).
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
--------
|
|
394
|
+
sol : SimpleNamespace
|
|
395
|
+
Solution object with fields:
|
|
396
|
+
x : ndarray
|
|
397
|
+
Computed GNE solution (if one is found).
|
|
398
|
+
res : ndarray
|
|
399
|
+
KKT residual at the solution x*
|
|
400
|
+
lam : list of ndarrays
|
|
401
|
+
List of Lagrange multipliers for each agent at the GNE solution (if constrains are present).
|
|
402
|
+
For each agent i, lam_star[i] contains the multipliers in the order:
|
|
403
|
+
- shared inequality constraints
|
|
404
|
+
- finite lower bounds for agent i
|
|
405
|
+
- finite upper bounds for agent i
|
|
406
|
+
- shared linear equality constraints
|
|
407
|
+
- shared nonlinear equality constraints
|
|
408
|
+
stats : Statistics about the optimization result.
|
|
409
|
+
"""
|
|
410
|
+
t0 = time.time()
|
|
411
|
+
|
|
412
|
+
solver = solver.lower()
|
|
413
|
+
|
|
414
|
+
if x0 is None:
|
|
415
|
+
x0 = jnp.zeros(self.nvar)
|
|
416
|
+
else:
|
|
417
|
+
x0 = jnp.asarray(x0)
|
|
418
|
+
|
|
419
|
+
if self.has_constraints:
|
|
420
|
+
lam0 = 0.1 * jnp.ones(self.nlam_sum)
|
|
421
|
+
z0 = jnp.hstack((x0, lam0))
|
|
422
|
+
else:
|
|
423
|
+
z0 = x0
|
|
424
|
+
|
|
425
|
+
# Solve the KKT residual minimization problem via SciPy least_squares
|
|
426
|
+
f = jax.jit(self.kkt_residual)
|
|
427
|
+
df = jax.jit(jax.jacobian(self.kkt_residual))
|
|
428
|
+
try:
|
|
429
|
+
solution = least_squares(f, z0, jac=df, method=solver, verbose=verbose,
|
|
430
|
+
ftol=tol, xtol=tol, gtol=tol, max_nfev=max_nfev)
|
|
431
|
+
except Exception as e:
|
|
432
|
+
raise RuntimeError(
|
|
433
|
+
f"Error in least_squares solver: {str(e)} If you are using 'lm', try using 'trf' instead.") from e
|
|
434
|
+
z_star = solution.x
|
|
435
|
+
res = solution.fun
|
|
436
|
+
kkt_evals = solution.nfev # number of function evaluations
|
|
437
|
+
if verbose>0 and kkt_evals == max_nfev:
|
|
438
|
+
print(
|
|
439
|
+
"\033[1;33mWarning: maximum number of function evaluations reached.\033[0m")
|
|
440
|
+
|
|
441
|
+
x = z_star[:self.nvar]
|
|
442
|
+
lam = []
|
|
443
|
+
if self.has_constraints:
|
|
444
|
+
lam_star = z_star[self.nvar:]
|
|
445
|
+
for i in range(self.N):
|
|
446
|
+
lam.append(np.asarray(lam_star[self.ii_lam[i]]))
|
|
447
|
+
|
|
448
|
+
t0 = time.time() - t0
|
|
449
|
+
|
|
450
|
+
norm_res = eval_residual(res, verbose, kkt_evals, t0)
|
|
451
|
+
|
|
452
|
+
stats = SimpleNamespace()
|
|
453
|
+
stats.solver = solver
|
|
454
|
+
stats.kkt_evals = kkt_evals
|
|
455
|
+
stats.elapsed_time = t0
|
|
456
|
+
|
|
457
|
+
sol = SimpleNamespace()
|
|
458
|
+
sol.x = np.asarray(x)
|
|
459
|
+
sol.res = np.asarray(res)
|
|
460
|
+
sol.lam = lam
|
|
461
|
+
sol.stats = stats
|
|
462
|
+
sol.norm_residual = norm_res
|
|
463
|
+
return sol
|
|
464
|
+
|
|
465
|
+
def best_response(self, i, x, rho=1e5, maxiter=200, tol=1e-8):
|
|
466
|
+
"""
|
|
467
|
+
Compute best response for agent i via SciPy's L-BFGS-B:
|
|
468
|
+
|
|
469
|
+
min_{x_i} f_i(x_i, x_{-i}) + rho * (sum_j max(g_i(x), 0)^2 + ||Aeq x - beq||^2 + ||h(x)||^2)
|
|
470
|
+
s.t. lb_i <= x_i <= ub_i
|
|
471
|
+
|
|
472
|
+
Parameters:
|
|
473
|
+
-----------
|
|
474
|
+
i : int
|
|
475
|
+
Index of the agent for which to compute the best response.
|
|
476
|
+
x : array-like
|
|
477
|
+
Current joint strategy of all agents.
|
|
478
|
+
rho : float, optional
|
|
479
|
+
Penalty parameter for constraint violations.
|
|
480
|
+
maxiter : int, optional
|
|
481
|
+
Maximum number of L-BFGS-B iterations.
|
|
482
|
+
tol : float, optional
|
|
483
|
+
Tolerance used in L-BFGS-B optimization.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
-----------
|
|
487
|
+
sol : SimpleNamespace
|
|
488
|
+
Solution object with fields:
|
|
489
|
+
x : ndarray
|
|
490
|
+
best response of agent i, within the full vector x.
|
|
491
|
+
f : ndarray
|
|
492
|
+
optimal objective value for agent i at best response, fi(x).
|
|
493
|
+
stats : Statistics about the optimization result.
|
|
494
|
+
"""
|
|
495
|
+
|
|
496
|
+
i1 = self.i1[i]
|
|
497
|
+
i2 = self.i2[i]
|
|
498
|
+
x = jnp.asarray(x)
|
|
499
|
+
|
|
500
|
+
t0 = time.time()
|
|
501
|
+
|
|
502
|
+
@jax.jit
|
|
503
|
+
def fun(xi):
|
|
504
|
+
# reconstruct full x with x_i replaced
|
|
505
|
+
x_i = x.at[i1:i2].set(xi)
|
|
506
|
+
f = jnp.array(self.f[i](x_i)).reshape(-1)
|
|
507
|
+
if self.ng > 0:
|
|
508
|
+
f += rho*jnp.sum(jnp.maximum(self.g(x_i), 0.0)**2)
|
|
509
|
+
if self.neq > 0:
|
|
510
|
+
f += rho*jnp.sum((self.Aeq @ x_i - self.beq)**2)
|
|
511
|
+
if self.nh > 0:
|
|
512
|
+
f += rho*jnp.sum(self.h(x_i)**2)
|
|
513
|
+
return f[0]
|
|
514
|
+
|
|
515
|
+
li = self.lb[i1:i2]
|
|
516
|
+
ui = self.ub[i1:i2]
|
|
517
|
+
|
|
518
|
+
options = {'iprint': -1, 'maxls': 20, 'gtol': tol, 'eps': tol,
|
|
519
|
+
'ftol': tol, 'maxfun': maxiter, 'maxcor': 10}
|
|
520
|
+
|
|
521
|
+
solver = ScipyBoundedMinimize(
|
|
522
|
+
fun=fun, tol=tol, method="L-BFGS-B", maxiter=maxiter, options=options)
|
|
523
|
+
xi, state = solver.run(x[i1:i2], bounds=(li, ui))
|
|
524
|
+
x_new = np.asarray(x.at[i1:i2].set(xi))
|
|
525
|
+
|
|
526
|
+
t0 = time.time() - t0
|
|
527
|
+
|
|
528
|
+
stats = SimpleNamespace()
|
|
529
|
+
stats.elapsed_time = t0
|
|
530
|
+
stats.solver = state
|
|
531
|
+
stats.iters = state.iter_num
|
|
532
|
+
|
|
533
|
+
sol = SimpleNamespace()
|
|
534
|
+
sol.x = x_new
|
|
535
|
+
sol.f = self.f[i](x_new)
|
|
536
|
+
sol.stats = stats
|
|
537
|
+
return sol
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class ParametricGNEP(GNEP):
|
|
541
|
+
def __init__(self, *args, **kwargs):
|
|
542
|
+
"""
|
|
543
|
+
Multiparametric Generalized Nash Equilibrium Problem (mpGNEP).
|
|
544
|
+
|
|
545
|
+
We consider a multiparametric GNEP with N agents, where agent i solves:
|
|
546
|
+
|
|
547
|
+
min_{x_i} f_i(x,p)
|
|
548
|
+
s.t. g(x,p) <= 0 (shared inequality constraints)
|
|
549
|
+
Aeq x = beq + Seq p (shared linear equality constraints)
|
|
550
|
+
h(x,p) = 0 (shared nonlinear equality constraints)
|
|
551
|
+
lb <= x <= ub (box constraints on x_i)
|
|
552
|
+
i= 1,...,N (N = number of agents)
|
|
553
|
+
|
|
554
|
+
where p are the game parameters.
|
|
555
|
+
|
|
556
|
+
Parameters:
|
|
557
|
+
-----------
|
|
558
|
+
sizes : list of int
|
|
559
|
+
List containing the number of variables for each agent.
|
|
560
|
+
f : list of callables
|
|
561
|
+
List of objective functions for each agent. Each function f[i](x) takes the full variable vector x as input.
|
|
562
|
+
g : callable, optional
|
|
563
|
+
Shared inequality constraint function g(x,p) <= 0, common to all agents.
|
|
564
|
+
ng : int, optional
|
|
565
|
+
Number of shared inequality constraints. Required if g is provided.
|
|
566
|
+
lb : array-like, optional
|
|
567
|
+
Lower bounds for the variables. If None, no lower bounds are applied.
|
|
568
|
+
ub : array-like, optional
|
|
569
|
+
Upper bounds for the variables. If None, no upper bounds are applied.
|
|
570
|
+
Aeq : array-like, optional
|
|
571
|
+
Equality constraint matrix. If None, no equality constraints are applied.
|
|
572
|
+
beq : array-like, optional
|
|
573
|
+
Equality constraint vector. If None, no equality constraints are applied.
|
|
574
|
+
h : callable, optional
|
|
575
|
+
Shared inequality constraint function h(x,p) <= 0, common to all agents.
|
|
576
|
+
nh : int, optional
|
|
577
|
+
Number of shared inequality constraints. Required if h is provided.
|
|
578
|
+
variational : bool, optional
|
|
579
|
+
If True, solve for a variational GNE by imposing equal Lagrange multipliers.
|
|
580
|
+
npar: int, optional
|
|
581
|
+
Number of game parameters p.
|
|
582
|
+
Seq : array-like, optional
|
|
583
|
+
Parameter dependence matrix for equality constraints. If None, no parameter dependence is applied on equality constraints.
|
|
584
|
+
|
|
585
|
+
(C) 2025 Alberto Bemporad
|
|
586
|
+
"""
|
|
587
|
+
|
|
588
|
+
Seq = kwargs.pop("Seq", None)
|
|
589
|
+
npar = kwargs.pop("npar", None)
|
|
590
|
+
if npar is None:
|
|
591
|
+
raise ValueError(
|
|
592
|
+
"npar (number of parameters) must be provided for ParametricGNEP.")
|
|
593
|
+
|
|
594
|
+
super().__init__(*args, **kwargs, parametric=True)
|
|
595
|
+
|
|
596
|
+
self.npar = int(npar)
|
|
597
|
+
|
|
598
|
+
if Seq is not None:
|
|
599
|
+
if self.Aeq is None:
|
|
600
|
+
raise ValueError(
|
|
601
|
+
"If Seq is provided, Aeq must also be provided.")
|
|
602
|
+
if Seq.shape[0] != self.Aeq.shape[0]:
|
|
603
|
+
raise ValueError(
|
|
604
|
+
"Seq and Aeq must have the same number of rows.")
|
|
605
|
+
if Seq.shape[1] != self.npar:
|
|
606
|
+
raise ValueError(f"Seq must have {self.npar} columns.")
|
|
607
|
+
self.Seq = jnp.asarray(Seq)
|
|
608
|
+
else:
|
|
609
|
+
self.Seq = jnp.zeros(
|
|
610
|
+
(self.Aeq.shape[0], self.npar)) if self.Aeq is not None else None
|
|
611
|
+
|
|
612
|
+
def solve(self, J=None, pmin=None, pmax=None, p0=None, x0=None, rho=1e5, alpha1=0., alpha2=0., maxiter=200, tol=1e-10, gne_warm_start=False, refine_gne=False, verbose=True):
|
|
613
|
+
"""
|
|
614
|
+
Design game-parameter vector p for the GNEP by solving:
|
|
615
|
+
|
|
616
|
+
min_{p} J(x*(p), p)
|
|
617
|
+
s.t. pmin <= p <= pmax
|
|
618
|
+
|
|
619
|
+
where x*(p) is the GNE solution for parameters p.
|
|
620
|
+
|
|
621
|
+
Parameters:
|
|
622
|
+
-----------
|
|
623
|
+
J : callable or None
|
|
624
|
+
Design objective function J(x, p) to be minimized. If None, the default objective J(x,p) = 0 is used.
|
|
625
|
+
pmin : array-like or None
|
|
626
|
+
Lower bounds for the parameters p.
|
|
627
|
+
pmax : array-like or None
|
|
628
|
+
Upper bounds for the parameters p.
|
|
629
|
+
p0 : array-like or None
|
|
630
|
+
Initial guess for the parameters p.
|
|
631
|
+
x0 : array-like or None
|
|
632
|
+
Initial guess for the GNE solution x.
|
|
633
|
+
rho : float, optional
|
|
634
|
+
Penalty parameter for KKT violation in best-response.
|
|
635
|
+
alpha1 : float or None, optional
|
|
636
|
+
If provided, add the regularization term alpha1*||x||_1
|
|
637
|
+
alpha2 : float or None, optional
|
|
638
|
+
If provided, add the regularization term alpha2*||x||_2^2
|
|
639
|
+
When alpha2>0 and J is None, a GNE solution is computed nonlinear least squares.
|
|
640
|
+
maxiter : int, optional
|
|
641
|
+
Maximum number of solver iterations.
|
|
642
|
+
tol : float, optional
|
|
643
|
+
Optimization tolerance.
|
|
644
|
+
gne_warm_start : bool, optional
|
|
645
|
+
If True, warm-start the optimization by computing a GNE.
|
|
646
|
+
refine_gne : bool, optional
|
|
647
|
+
If True, try refining the solution to get a GNE after solving the problem for the optimal parameter p found. Mainly useful when J is provided or regularization is used.
|
|
648
|
+
verbose : bool, optional
|
|
649
|
+
If True, print optimization statistics.
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
--------
|
|
653
|
+
sol : SimpleNamespace
|
|
654
|
+
Solution object with fields:
|
|
655
|
+
x : ndarray
|
|
656
|
+
Computed GNE solution at optimal parameters p*.
|
|
657
|
+
p : ndarray
|
|
658
|
+
Computed optimal parameters p*.
|
|
659
|
+
res : ndarray
|
|
660
|
+
KKT residual at the solution (x*(p*), p*).
|
|
661
|
+
lam : list of ndarrays
|
|
662
|
+
List of Lagrange multipliers for each agent at the GNE solution (if constraints are present).
|
|
663
|
+
J : float
|
|
664
|
+
Optimal value of the design objective J at (x*(p*), p*).
|
|
665
|
+
stats : Statistics about the optimization result.
|
|
666
|
+
"""
|
|
667
|
+
t0 = time.time()
|
|
668
|
+
|
|
669
|
+
is_J = J is not None
|
|
670
|
+
if not is_J:
|
|
671
|
+
def J(x, p): return 0.0
|
|
672
|
+
|
|
673
|
+
L1_regularized = alpha1 > 0.
|
|
674
|
+
L2_regularized = alpha2 > 0.
|
|
675
|
+
|
|
676
|
+
if p0 is None:
|
|
677
|
+
p0 = jnp.zeros(self.npar)
|
|
678
|
+
if x0 is None:
|
|
679
|
+
x0 = jnp.zeros(self.nvar)
|
|
680
|
+
if self.has_constraints:
|
|
681
|
+
lam0 = 0.1 * jnp.ones(self.nlam_sum)
|
|
682
|
+
else:
|
|
683
|
+
lam0 = jnp.array([])
|
|
684
|
+
|
|
685
|
+
z0 = jnp.hstack((x0, lam0, p0)) if not L1_regularized else jnp.hstack(
|
|
686
|
+
(jnp.maximum(x0, 0.), jnp.maximum(-x0, 0.), lam0, p0))
|
|
687
|
+
|
|
688
|
+
nvars = self.nvar*(1+L1_regularized) + self.npar + self.nlam_sum
|
|
689
|
+
lb = -np.inf*np.ones(nvars)
|
|
690
|
+
ub = np.inf*np.ones(nvars)
|
|
691
|
+
if pmin is not None:
|
|
692
|
+
lb[-self.npar:] = pmin
|
|
693
|
+
if pmax is not None:
|
|
694
|
+
ub[-self.npar:] = pmax
|
|
695
|
+
if not L1_regularized:
|
|
696
|
+
lb[:self.nvar] = self.lb
|
|
697
|
+
ub[:self.nvar] = self.ub
|
|
698
|
+
else:
|
|
699
|
+
lb[:self.nvar] = jnp.maximum(self.lb, 0.0)
|
|
700
|
+
ub[:self.nvar] = jnp.maximum(self.ub, 0.0)
|
|
701
|
+
lb[self.nvar:2*self.nvar] = jnp.maximum(-self.ub, 0.0)
|
|
702
|
+
ub[self.nvar:2*self.nvar] = jnp.maximum(-self.lb, 0.0)
|
|
703
|
+
|
|
704
|
+
stats = SimpleNamespace()
|
|
705
|
+
stats.kkt_evals = 0
|
|
706
|
+
|
|
707
|
+
if gne_warm_start:
|
|
708
|
+
# Compute a GNE for initial guess
|
|
709
|
+
dR_fun = jax.jit(jax.jacobian(self.kkt_residual))
|
|
710
|
+
solution = least_squares(self.kkt_residual, z0, jac=dR_fun, method="trf",
|
|
711
|
+
verbose=0, ftol=tol, xtol=tol, gtol=tol, max_nfev=maxiter, bounds=(lb, ub))
|
|
712
|
+
z0 = solution.x
|
|
713
|
+
stats.kkt_evals += solution.nfev
|
|
714
|
+
|
|
715
|
+
# also include the case of no J and pure L1-regularization, since alpha2 = 0 cannot be handled by least_squares
|
|
716
|
+
if is_J or (L1_regularized and alpha2 == 0.0):
|
|
717
|
+
stats.solver = "L-BFGS"
|
|
718
|
+
options = {'iprint': -1, 'maxls': 20, 'gtol': tol, 'eps': tol,
|
|
719
|
+
'ftol': tol, 'maxfun': maxiter, 'maxcor': 10}
|
|
720
|
+
|
|
721
|
+
if not L1_regularized:
|
|
722
|
+
@jax.jit
|
|
723
|
+
def obj(z):
|
|
724
|
+
x = z[:self.nvar]
|
|
725
|
+
p = z[-self.npar:]
|
|
726
|
+
return J(x, p) + 0.5*rho * jnp.sum(self.kkt_residual(z)**2) + alpha2*jnp.sum(x**2)
|
|
727
|
+
|
|
728
|
+
solver = ScipyBoundedMinimize(
|
|
729
|
+
fun=obj, tol=tol, method="L-BFGS-B", maxiter=maxiter, options=options)
|
|
730
|
+
z, state = solver.run(z0, bounds=(lb, ub))
|
|
731
|
+
x = z[:self.nvar]
|
|
732
|
+
R = self.kkt_residual(z)
|
|
733
|
+
|
|
734
|
+
else: # L1-regularized
|
|
735
|
+
@jax.jit
|
|
736
|
+
def obj(z):
|
|
737
|
+
xp = z[:self.nvar]
|
|
738
|
+
xm = z[self.nvar:2*self.nvar]
|
|
739
|
+
p = z[-self.npar:]
|
|
740
|
+
return J(xp-xm, p) + alpha1 * jnp.sum(xp+xm) + alpha2 * (jnp.sum(xp**2+xm**2))
|
|
741
|
+
|
|
742
|
+
solver = ScipyBoundedMinimize(
|
|
743
|
+
fun=obj, tol=tol, method="L-BFGS-B", maxiter=maxiter, options=options)
|
|
744
|
+
z, state = solver.run(z0, bounds=(lb, ub))
|
|
745
|
+
x = z[:self.nvar]-z[self.nvar:2*self.nvar]
|
|
746
|
+
R = self.kkt_residual(jnp.concatenate((x, z[2*self.nvar:])))
|
|
747
|
+
|
|
748
|
+
stats.kkt_evals += state.num_fun_eval
|
|
749
|
+
|
|
750
|
+
else:
|
|
751
|
+
# No design objective, just solve for a GNE with possible regularization
|
|
752
|
+
stats.solver = "TRF"
|
|
753
|
+
srho = jnp.sqrt(rho)
|
|
754
|
+
alpha3 = jnp.sqrt(2.*alpha2)
|
|
755
|
+
|
|
756
|
+
if not L1_regularized:
|
|
757
|
+
if not L2_regularized:
|
|
758
|
+
R_obj = jax.jit(self.kkt_residual)
|
|
759
|
+
else:
|
|
760
|
+
@jax.jit
|
|
761
|
+
def R_obj(z):
|
|
762
|
+
return jnp.concatenate((srho*self.kkt_residual(z), alpha3*x))
|
|
763
|
+
else:
|
|
764
|
+
# The case (L1_regularized and alpha2==0.0) was already handled above, so here alpha2>0 --> alpha3>0
|
|
765
|
+
alpha4 = alpha1/alpha3
|
|
766
|
+
|
|
767
|
+
@jax.jit
|
|
768
|
+
def R_obj(z):
|
|
769
|
+
zx = jnp.concatenate(
|
|
770
|
+
(z[:self.nvar]-z[self.nvar:2*self.nvar], z[2*self.nvar:]))
|
|
771
|
+
res, gx, dgx, dhx = self.kkt_residual_shared(zx)
|
|
772
|
+
for i in range(self.N):
|
|
773
|
+
res += self.kkt_residual_i(zx, i, gx, dgx, dhx)
|
|
774
|
+
for i in range(len(res)):
|
|
775
|
+
res[i] = srho*res[i]
|
|
776
|
+
res += [alpha3*z[:self.nvar] + alpha4]
|
|
777
|
+
res += [alpha3*z[self.nvar:2*self.nvar] + alpha4]
|
|
778
|
+
return jnp.concatenate(res)
|
|
779
|
+
|
|
780
|
+
# Solve the KKT residual minimization via SciPy least_squares
|
|
781
|
+
dR_obj = jax.jit(jax.jacobian(R_obj))
|
|
782
|
+
solution = least_squares(R_obj, z0, jac=dR_obj, method="trf", verbose=0,
|
|
783
|
+
ftol=tol, xtol=tol, gtol=tol, max_nfev=maxiter, bounds=(lb, ub))
|
|
784
|
+
z = solution.x
|
|
785
|
+
if not L1_regularized:
|
|
786
|
+
x = z[:self.nvar]
|
|
787
|
+
zx = z
|
|
788
|
+
else:
|
|
789
|
+
x = z[:self.nvar]-z[self.nvar:2*self.nvar]
|
|
790
|
+
zx = jnp.concatenate((x, z[2*self.nvar:]))
|
|
791
|
+
|
|
792
|
+
R = self.kkt_residual(zx)
|
|
793
|
+
|
|
794
|
+
# This actually returns the number of function evaluations, not solver iterations
|
|
795
|
+
stats.kkt_evals += solution.nfev
|
|
796
|
+
|
|
797
|
+
x = np.asarray(x)
|
|
798
|
+
R = np.asarray(R)
|
|
799
|
+
p = np.asarray(z[-self.npar:])
|
|
800
|
+
|
|
801
|
+
if refine_gne:
|
|
802
|
+
if verbose and (not L1_regularized and not is_J):
|
|
803
|
+
# No need to refine
|
|
804
|
+
print(
|
|
805
|
+
"\033[1;33mWarning: refine_gne=True has no effect when no design objective and regularization are used. Skipping refinement.\033[0m")
|
|
806
|
+
else:
|
|
807
|
+
def kkt_residual_refine(z, p):
|
|
808
|
+
zx = jnp.concatenate((z, p))
|
|
809
|
+
return self.kkt_residual(zx)
|
|
810
|
+
Rx = partial(jax.jit(kkt_residual_refine), p=p)
|
|
811
|
+
dRx = jax.jit(jax.jacobian(Rx))
|
|
812
|
+
lam0 = z[self.nvar+self.nvar*L1_regularized: -self.npar]
|
|
813
|
+
z0 = jnp.hstack((x, lam0))
|
|
814
|
+
lbz = jnp.concatenate((self.lb, -np.inf*np.ones(len(lam0))))
|
|
815
|
+
ubz = jnp.concatenate((self.ub, np.inf*np.ones(len(lam0))))
|
|
816
|
+
solution = least_squares(Rx, z0, jac=dRx, method="trf", verbose=0,
|
|
817
|
+
ftol=tol, xtol=tol, gtol=tol, max_nfev=maxiter, bounds=(lbz, ubz))
|
|
818
|
+
z = solution.x
|
|
819
|
+
x = z[:self.nvar]
|
|
820
|
+
z = jnp.hstack((z, p))
|
|
821
|
+
R = np.asarray(self.kkt_residual(z))
|
|
822
|
+
stats.kkt_evals += solution.nfev
|
|
823
|
+
|
|
824
|
+
t0 = time.time() - t0
|
|
825
|
+
J_opt = J(x, p) if is_J else 0.0
|
|
826
|
+
lam = []
|
|
827
|
+
if self.has_constraints:
|
|
828
|
+
lam_star = z[self.nvar+self.nvar*L1_regularized:]
|
|
829
|
+
for i in range(self.N):
|
|
830
|
+
lam.append(np.asarray(lam_star[self.ii_lam[i]]))
|
|
831
|
+
|
|
832
|
+
stats.elapsed_time = t0
|
|
833
|
+
|
|
834
|
+
norm_res = eval_residual(R, verbose, stats.kkt_evals, t0)
|
|
835
|
+
|
|
836
|
+
sol = SimpleNamespace()
|
|
837
|
+
sol.x = x
|
|
838
|
+
sol.p = p
|
|
839
|
+
sol.lam = lam
|
|
840
|
+
sol.J = J_opt
|
|
841
|
+
sol.res = R
|
|
842
|
+
sol.stats = stats
|
|
843
|
+
sol.norm_residual = norm_res
|
|
844
|
+
return sol
|
|
845
|
+
|
|
846
|
+
def best_response(self, i, x, p, rho=1e5, maxiter=200, tol=1e-8):
|
|
847
|
+
"""
|
|
848
|
+
Compute best response for agent i via SciPy's L-BFGS-B:
|
|
849
|
+
|
|
850
|
+
min_{x_i} f_i(x_i, x_{-i}, p) + rho * (sum_j max(g_i(x,p), 0)^2 + ||Aeq x - beq -Seq p||^2 + ||h(x,p)||^2)
|
|
851
|
+
s.t. lb_i <= x_i <= ub_i
|
|
852
|
+
|
|
853
|
+
Parameters:
|
|
854
|
+
-----------
|
|
855
|
+
i : int
|
|
856
|
+
Index of the agent for which to compute the best response.
|
|
857
|
+
x : array-like
|
|
858
|
+
Current joint strategy of all agents.
|
|
859
|
+
p : array-like
|
|
860
|
+
Current game parameters.
|
|
861
|
+
rho : float, optional
|
|
862
|
+
Penalty parameter for constraint violations.
|
|
863
|
+
maxiter : int, optional
|
|
864
|
+
Maximum number of L-BFGS-B iterations.
|
|
865
|
+
tol : float, optional
|
|
866
|
+
Tolerance used in L-BFGS-B optimization.
|
|
867
|
+
|
|
868
|
+
Returns:
|
|
869
|
+
x_i : best response of agent i
|
|
870
|
+
res : SciPy optimize result
|
|
871
|
+
"""
|
|
872
|
+
t0 = time.time()
|
|
873
|
+
|
|
874
|
+
i1 = self.i1[i]
|
|
875
|
+
i2 = self.i2[i]
|
|
876
|
+
x = jnp.asarray(x)
|
|
877
|
+
|
|
878
|
+
@jax.jit
|
|
879
|
+
def fun(xi):
|
|
880
|
+
# reconstruct full x with x_i replaced
|
|
881
|
+
x_i = x.at[i1:i2].set(xi)
|
|
882
|
+
f = jnp.array(self.f[i](x_i, p)).reshape(-1)
|
|
883
|
+
if self.ng > 0:
|
|
884
|
+
f += rho*jnp.sum(jnp.maximum(self.g(x_i, p), 0.0)**2)
|
|
885
|
+
if self.neq > 0:
|
|
886
|
+
f += rho*jnp.sum((self.Aeq @ x_i - self.beq - self.Seq @ p)**2)
|
|
887
|
+
if self.nh > 0:
|
|
888
|
+
f += rho*jnp.sum(self.h(x_i, p)**2)
|
|
889
|
+
return f[0]
|
|
890
|
+
|
|
891
|
+
li = self.lb[i1:i2]
|
|
892
|
+
ui = self.ub[i1:i2]
|
|
893
|
+
|
|
894
|
+
options = {'iprint': -1, 'maxls': 20, 'gtol': tol, 'eps': tol,
|
|
895
|
+
'ftol': tol, 'maxfun': maxiter, 'maxcor': 10}
|
|
896
|
+
|
|
897
|
+
solver = ScipyBoundedMinimize(
|
|
898
|
+
fun=fun, tol=tol, method="L-BFGS-B", maxiter=maxiter, options=options)
|
|
899
|
+
xi, state = solver.run(x[i1:i2], bounds=(li, ui))
|
|
900
|
+
x_new = np.asarray(x.at[i1:i2].set(xi))
|
|
901
|
+
|
|
902
|
+
t0 = time.time() - t0
|
|
903
|
+
|
|
904
|
+
stats = SimpleNamespace()
|
|
905
|
+
stats.elapsed_time = t0
|
|
906
|
+
stats.solver = state
|
|
907
|
+
stats.iters = state.iter_num
|
|
908
|
+
|
|
909
|
+
sol = SimpleNamespace()
|
|
910
|
+
sol.x = x_new
|
|
911
|
+
sol.f = self.f[i](x_new, p)
|
|
912
|
+
sol.stats = stats
|
|
913
|
+
return sol
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
class GNEP_LQ():
|
|
917
|
+
def __init__(self, dim, Q, c, F=None, lb=None, ub=None, pmin=None, pmax=None, A=None, b=None, S=None, Aeq=None, beq=None, Seq=None, D_pwa=None, E_pwa=None, h_pwa=None, Q_J=None, c_J=None, M=1e4, variational=False, solver="highs"):
|
|
918
|
+
"""Given a (multiparametric) generalized Nash equilibrium problem with N agents,
|
|
919
|
+
convex quadratic objectives, and linear constraints, solve the following game-design problem
|
|
920
|
+
|
|
921
|
+
min_{x*,p} f(x*,p)
|
|
922
|
+
|
|
923
|
+
s.t. x* is a generalized Nash equilibrium of the parametric GNEP:
|
|
924
|
+
|
|
925
|
+
min_{x_i} 0.5 x^T Qi x + (c_i+ F_i p)^T x
|
|
926
|
+
s.t. A x <= b + S p
|
|
927
|
+
Aeq x = beq + Seq p
|
|
928
|
+
lb <= x <= ub
|
|
929
|
+
|
|
930
|
+
where f is either the sum of convex piecewise affine (PWA) functions
|
|
931
|
+
|
|
932
|
+
f(x,p) = sum_{k=1..nk} max_{i=1..nk} { D_pwa[k](i,:) x + E_pwa[k](i,:) p + h_pwa[k](i) }
|
|
933
|
+
|
|
934
|
+
or the convex quadratic function
|
|
935
|
+
|
|
936
|
+
f(x,p) = 0.5 [x p]^T Q_J [x;p] + c_J^T [x;p]
|
|
937
|
+
|
|
938
|
+
or the sum of both. Here, x = [x_1; x_2; ...; x_N] is the stacked vector of all agents' variables,
|
|
939
|
+
p is a vector of parameters (possibly empty), and Qi, c_i, F_i are the cost function data for agent i.
|
|
940
|
+
|
|
941
|
+
Special cases of the general problem are:
|
|
942
|
+
1) If p is empty and f(x,p)=0, we simply look for a generalized Nash equilibrium of the linear
|
|
943
|
+
quadratic game;
|
|
944
|
+
2) If p is not empty and f(x,p)=||x-xdes||_inf or f(x,p)=||x-xdes||_1, we solve the game design problem of finding the parameter vector p such that the resulting general Nash equilibrium x* is as close as possible to the desired equilibrium point xdes.
|
|
945
|
+
|
|
946
|
+
If max_solutions > 1, multiple solutions are searched for (if they exist, up to max_solutions), each corresponding to a different combination of active constraints at the equilibrium.
|
|
947
|
+
|
|
948
|
+
To search for a variational GNE, set the flag variational=True. In this case, the KKT conditions require equal Lagrange multipliers for all agents for each shared constraint.
|
|
949
|
+
|
|
950
|
+
Parameters
|
|
951
|
+
----------
|
|
952
|
+
dim : list of int
|
|
953
|
+
List with number of variables for each agent.
|
|
954
|
+
Q : list of (nx, nx) np.ndarray
|
|
955
|
+
Q matrices for each agent.
|
|
956
|
+
c : list of (nx,) np.ndarray
|
|
957
|
+
c vectors for each agent.
|
|
958
|
+
F : list of (nx, np) np.ndarray or None
|
|
959
|
+
F matrices for each agent.
|
|
960
|
+
lb : (nx,) np.ndarray or None
|
|
961
|
+
Range lower bounds on x (unbounded if None or -inf).
|
|
962
|
+
ub : (nx,) np.ndarray or None
|
|
963
|
+
Range upper bounds on x (unbounded if None or +inf).
|
|
964
|
+
pmin : (npar,) np.ndarray or None
|
|
965
|
+
Range lower bounds on p.
|
|
966
|
+
pmax : (npar,) np.ndarray or None
|
|
967
|
+
Range upper bounds on p.
|
|
968
|
+
A : (nA, nx) np.ndarray or None
|
|
969
|
+
Shared inequality constraint matrix.
|
|
970
|
+
b : (nA,) np.ndarray or None
|
|
971
|
+
Shared inequality constraint RHS vector.
|
|
972
|
+
S : (nA, npar) np.ndarray or None
|
|
973
|
+
Shared inequality constraint parameter matrix.
|
|
974
|
+
Aeq : (nAeq, nx) np.ndarray or None
|
|
975
|
+
Shared equality constraint matrix.
|
|
976
|
+
beq : (nAeq,) np.ndarray or None
|
|
977
|
+
Shared equality constraint RHS vector.
|
|
978
|
+
Seq : (nAeq, npar) np.ndarray or None
|
|
979
|
+
Shared equality constraint parameter matrix.
|
|
980
|
+
D_pwa : (list of) (nf,nx) np.ndarray(s) or None
|
|
981
|
+
Matrix defining the convex PWA objective function for designing the game. If None, no objective function is used, and only an equilibrium point is searched for.
|
|
982
|
+
E_pwa : (list of) (nf, npar) np.ndarray(s) or None
|
|
983
|
+
Parameter matrix defining the convex PWA objective function for designing the game.
|
|
984
|
+
h_pwa : (list of) (nf,) np.ndarray(s) or None
|
|
985
|
+
Vector defining the convex PWA objective function for designing the game.
|
|
986
|
+
Q_J : (nx+npar, nx+npar) np.ndarray or None
|
|
987
|
+
Hessian matrix defining the convex quadratic objective function for designing the game. If None, no quadratic objective function is used.
|
|
988
|
+
c_J : (nx+npar,) np.ndarray or None
|
|
989
|
+
Linear term of the convex quadratic objective function for designing the game.
|
|
990
|
+
M : float
|
|
991
|
+
Big-M constant for complementary slackness condition. This must be an upper bound
|
|
992
|
+
on the Lagrange multipliers lam(i,j) and on the slack variables y(j).
|
|
993
|
+
variational : bool
|
|
994
|
+
If True, search for a variational GNE.
|
|
995
|
+
solver : str
|
|
996
|
+
Solver used to solve the resulting mixed-integer program. "highs" (default) or "gurobi".
|
|
997
|
+
|
|
998
|
+
(C) 2025 Alberto Bemporad, December 20, 2025
|
|
999
|
+
"""
|
|
1000
|
+
|
|
1001
|
+
nx = sum(dim) # total number of variables
|
|
1002
|
+
N = len(dim) # number of agents
|
|
1003
|
+
if not len(Q) == N:
|
|
1004
|
+
raise ValueError("Length of Q must be equal to number of agents")
|
|
1005
|
+
if not len(c) == N:
|
|
1006
|
+
raise ValueError("Length of c must be equal to number of agents")
|
|
1007
|
+
if F is not None and not len(F) == N:
|
|
1008
|
+
raise ValueError("Length of F must be equal to number of agents")
|
|
1009
|
+
|
|
1010
|
+
for i in range(N):
|
|
1011
|
+
if not Q[i].shape == (nx, nx):
|
|
1012
|
+
raise ValueError(f"Q[{i}] must be of shape ({nx},{nx})")
|
|
1013
|
+
if not c[i].shape == (nx,):
|
|
1014
|
+
raise ValueError(f"c[{i}] must be of shape ({nx},)")
|
|
1015
|
+
|
|
1016
|
+
has_pwa_objective = (D_pwa is not None)
|
|
1017
|
+
if has_pwa_objective:
|
|
1018
|
+
if E_pwa is None or h_pwa is None:
|
|
1019
|
+
raise ValueError("E_pwa and h_pwa must be provided if D_pwa is provided")
|
|
1020
|
+
if not isinstance(D_pwa, list):
|
|
1021
|
+
D_pwa = [D_pwa]
|
|
1022
|
+
if not isinstance(E_pwa, list):
|
|
1023
|
+
E_pwa = [E_pwa]
|
|
1024
|
+
if not isinstance(h_pwa, list):
|
|
1025
|
+
h_pwa = [h_pwa]
|
|
1026
|
+
if not (len(D_pwa) == len(E_pwa) == len(h_pwa)):
|
|
1027
|
+
raise ValueError("D, E, and h must be lists of the same length")
|
|
1028
|
+
|
|
1029
|
+
nJ = len(D_pwa)
|
|
1030
|
+
for k in range(nJ):
|
|
1031
|
+
nk = D_pwa[k].shape[0]
|
|
1032
|
+
if not D_pwa[k].shape == (nk, nx):
|
|
1033
|
+
raise ValueError(f"D[{k}] must be of shape ({nk},{nx})")
|
|
1034
|
+
if not h_pwa[k].shape == (nk,):
|
|
1035
|
+
raise ValueError(f"h[{k}] must be of shape ({nk},)")
|
|
1036
|
+
|
|
1037
|
+
if pmin is not None:
|
|
1038
|
+
pmin = np.asarray(pmin).reshape(-1)
|
|
1039
|
+
if pmax is not None:
|
|
1040
|
+
pmax = np.asarray(pmax).reshape(-1)
|
|
1041
|
+
has_params = (pmin is not None) and (pmax is not None) and (
|
|
1042
|
+
pmin.size > 0) and (pmax.size > 0)
|
|
1043
|
+
if has_params:
|
|
1044
|
+
npar = F[0].shape[1]
|
|
1045
|
+
for i in range(N):
|
|
1046
|
+
if not F[i].shape == (nx, npar):
|
|
1047
|
+
raise ValueError(f"F[{i}] must be of shape ({nx},{npar})")
|
|
1048
|
+
if has_pwa_objective:
|
|
1049
|
+
for k in range(nJ):
|
|
1050
|
+
nk = D_pwa[k].shape[0]
|
|
1051
|
+
if not E_pwa[k].shape == (nk, npar):
|
|
1052
|
+
raise ValueError(f"E_pwa[{k}] must be of shape ({nk},{npar})")
|
|
1053
|
+
if not pmin.size == npar:
|
|
1054
|
+
raise ValueError(f"pmin must have {npar} elements")
|
|
1055
|
+
if not pmax.size == npar:
|
|
1056
|
+
raise ValueError(f"pmax must have {npar} elements")
|
|
1057
|
+
if np.all(pmin == pmax):
|
|
1058
|
+
for i in range(N):
|
|
1059
|
+
c[i] = c[i] + F[i] @ pmin # absorb fixed p into c
|
|
1060
|
+
if has_pwa_objective:
|
|
1061
|
+
for k in range(nJ):
|
|
1062
|
+
h_pwa[k] = h_pwa[k] + E_pwa[k] @ pmin # absorb fixed p into h
|
|
1063
|
+
else:
|
|
1064
|
+
npar = 0
|
|
1065
|
+
|
|
1066
|
+
has_quad_objective = (Q_J is not None) or (c_J is not None)
|
|
1067
|
+
if has_quad_objective:
|
|
1068
|
+
if Q_J is None:
|
|
1069
|
+
raise ValueError("No quadratic term specified for game objective, use J(x,p) = c_J @ [x;p] = D_pwa@x + E_pwa@p for linear objectives")
|
|
1070
|
+
if solver == "highs":
|
|
1071
|
+
raise ValueError("HiGHS solver does not support quadratic objective functions, use solver='gurobi'")
|
|
1072
|
+
if c_J is None:
|
|
1073
|
+
c_J = np.zeros((nx + npar))
|
|
1074
|
+
if not Q_J.shape == (nx + npar, nx + npar):
|
|
1075
|
+
raise ValueError(f"Q_J must be of shape ({nx+npar},{nx+npar})")
|
|
1076
|
+
if not c_J.shape == (nx + npar,):
|
|
1077
|
+
raise ValueError(f"c_J must be of shape ({nx+npar},)")
|
|
1078
|
+
|
|
1079
|
+
has_ineq_constraints = (A is not None) and (A.size > 0)
|
|
1080
|
+
if has_ineq_constraints:
|
|
1081
|
+
if b is None:
|
|
1082
|
+
raise ValueError("b must be provided if A is provided")
|
|
1083
|
+
if has_params:
|
|
1084
|
+
if S is None:
|
|
1085
|
+
S = np.zeros((A.shape[0], npar))
|
|
1086
|
+
ncon = A.shape[0]
|
|
1087
|
+
if not A.shape[1] == nx:
|
|
1088
|
+
raise ValueError(f"A must have {nx} columns")
|
|
1089
|
+
if not b.size == ncon:
|
|
1090
|
+
raise ValueError(f"b must have {ncon} elements")
|
|
1091
|
+
if has_params:
|
|
1092
|
+
if not S.shape == (ncon, npar):
|
|
1093
|
+
raise ValueError(f"S must be of shape ({ncon},{npar})")
|
|
1094
|
+
if np.all(pmin == pmax):
|
|
1095
|
+
b = b + S @ pmin # absorb fixed p into b
|
|
1096
|
+
else:
|
|
1097
|
+
ncon = 0
|
|
1098
|
+
nlam = 0 # no lam, delta, y variables
|
|
1099
|
+
|
|
1100
|
+
has_eq_constraints = (Aeq is not None)
|
|
1101
|
+
|
|
1102
|
+
if has_eq_constraints:
|
|
1103
|
+
if beq is None:
|
|
1104
|
+
raise ValueError("beq must be provided if Aeq is provided")
|
|
1105
|
+
|
|
1106
|
+
if has_params:
|
|
1107
|
+
if Seq is None:
|
|
1108
|
+
raise ValueError(
|
|
1109
|
+
"Seq must be provided if Aeq is provided and pmin/pmax are provided")
|
|
1110
|
+
|
|
1111
|
+
nconeq = Aeq.shape[0]
|
|
1112
|
+
if not Aeq.shape[1] == nx:
|
|
1113
|
+
raise ValueError(f"Aeq must have {nx} columns")
|
|
1114
|
+
if not beq.size == nconeq:
|
|
1115
|
+
raise ValueError(f"beq must have {nconeq} elements")
|
|
1116
|
+
|
|
1117
|
+
if has_params:
|
|
1118
|
+
if not Seq.shape == (nconeq, npar):
|
|
1119
|
+
raise ValueError(f"Seq must be of shape ({nconeq},{npar})")
|
|
1120
|
+
if np.all(pmin == pmax):
|
|
1121
|
+
beq = beq + Seq @ pmin # absorb fixed p into beq
|
|
1122
|
+
else:
|
|
1123
|
+
nconeq = 0
|
|
1124
|
+
nmu = 0 # no Lagrange multipliers mu
|
|
1125
|
+
|
|
1126
|
+
if variational:
|
|
1127
|
+
if not (has_ineq_constraints or has_eq_constraints):
|
|
1128
|
+
print(
|
|
1129
|
+
"\033[1;31mVariational GNE requested but no shared constraints are defined.\033[0m")
|
|
1130
|
+
variational = False
|
|
1131
|
+
|
|
1132
|
+
if has_params and np.all(pmin == pmax):
|
|
1133
|
+
has_params = False # no parameters anymore
|
|
1134
|
+
npar = 0
|
|
1135
|
+
|
|
1136
|
+
solver = solver.lower()
|
|
1137
|
+
|
|
1138
|
+
if solver == 'gurobi' and not GUROBI_INSTALLED:
|
|
1139
|
+
print(
|
|
1140
|
+
"\033[1;33mWarning: Gurobi not installed, switching to HiGHS solver.\033[0m")
|
|
1141
|
+
solver = "highs"
|
|
1142
|
+
if solver == "highs":
|
|
1143
|
+
inf = highspy.kHighsInf
|
|
1144
|
+
elif solver == "gurobi":
|
|
1145
|
+
inf = gp.GRB.INFINITY
|
|
1146
|
+
else:
|
|
1147
|
+
raise ValueError("solver must be 'highs' or 'gurobi'")
|
|
1148
|
+
|
|
1149
|
+
self.solver = solver
|
|
1150
|
+
|
|
1151
|
+
# Deal with variable bounds
|
|
1152
|
+
if lb is None:
|
|
1153
|
+
lb = -inf * np.ones(nx)
|
|
1154
|
+
if ub is None:
|
|
1155
|
+
ub = inf * np.ones(nx)
|
|
1156
|
+
if not lb.size == nx:
|
|
1157
|
+
raise ValueError(f"lb must have {nx} elements")
|
|
1158
|
+
if not ub.size == nx:
|
|
1159
|
+
raise ValueError(f"ub must have {nx} elements")
|
|
1160
|
+
if not np.all(ub >= lb):
|
|
1161
|
+
raise ValueError("Inconsistent variable bounds: some ub < lb")
|
|
1162
|
+
if any(ub < inf) or any(lb > -inf):
|
|
1163
|
+
# Embed variable bounds into inequality constraints
|
|
1164
|
+
AA = []
|
|
1165
|
+
bb = []
|
|
1166
|
+
SS = []
|
|
1167
|
+
for i in range(nx):
|
|
1168
|
+
ei = np.zeros(nx)
|
|
1169
|
+
ei[i] = 1.0
|
|
1170
|
+
if ub[i] < inf:
|
|
1171
|
+
AA.append(ei.reshape(1, -1))
|
|
1172
|
+
bb.append(ub[i])
|
|
1173
|
+
if has_params:
|
|
1174
|
+
SS.append(np.zeros((1, npar)))
|
|
1175
|
+
if lb[i] > -inf:
|
|
1176
|
+
AA.append(-ei.reshape(1, -1))
|
|
1177
|
+
bb.append(-lb[i])
|
|
1178
|
+
if has_params:
|
|
1179
|
+
SS.append(np.zeros((1, npar)))
|
|
1180
|
+
if has_ineq_constraints:
|
|
1181
|
+
A = np.vstack((A, np.vstack(AA)))
|
|
1182
|
+
b = np.hstack((b, np.hstack(bb)))
|
|
1183
|
+
if has_params:
|
|
1184
|
+
S = np.vstack((S, np.vstack(SS)))
|
|
1185
|
+
else:
|
|
1186
|
+
A = np.vstack(AA)
|
|
1187
|
+
b = np.hstack(bb)
|
|
1188
|
+
has_ineq_constraints = True
|
|
1189
|
+
if has_params:
|
|
1190
|
+
S = np.vstack(SS)
|
|
1191
|
+
ncon = A.shape[0]
|
|
1192
|
+
nbox = len(AA) # the last nbox constraints are box constraints
|
|
1193
|
+
else:
|
|
1194
|
+
nbox = 0 # no box constraints added
|
|
1195
|
+
|
|
1196
|
+
cum_dim_x = np.cumsum([0]+dim[:-1]) # cumulative sum of dim
|
|
1197
|
+
|
|
1198
|
+
if has_ineq_constraints:
|
|
1199
|
+
# Determine where each agent's vars appear in the inequality constraints
|
|
1200
|
+
# G[i,j] = 1 if constraint i depends on agent j's variables
|
|
1201
|
+
G = np.zeros((ncon, N), dtype=bool)
|
|
1202
|
+
for i in range(N):
|
|
1203
|
+
G[:, i] = np.any(
|
|
1204
|
+
A[:, cum_dim_x[i]:cum_dim_x[i]+dim[i]] != 0, axis=1)
|
|
1205
|
+
# number of constraints involving each agent, for each agent
|
|
1206
|
+
dim_lam = np.sum(G, axis=0)
|
|
1207
|
+
# cumulative sum of dim_lam
|
|
1208
|
+
cum_dim_lam = np.cumsum([0]+list(dim_lam[:-1]))
|
|
1209
|
+
nlam = np.sum(dim_lam) # total number of lam and delta variables
|
|
1210
|
+
else:
|
|
1211
|
+
nlam = 0
|
|
1212
|
+
G = None
|
|
1213
|
+
dim_lam = None
|
|
1214
|
+
|
|
1215
|
+
if has_eq_constraints:
|
|
1216
|
+
# Determine where each agent's vars appear in the equality constraints
|
|
1217
|
+
# G[i,j] = 1 if constraint i depends on agent j's variables
|
|
1218
|
+
Geq = np.zeros((nconeq, N), dtype=bool)
|
|
1219
|
+
for i in range(N):
|
|
1220
|
+
Geq[:, i] = np.any(
|
|
1221
|
+
Aeq[:, cum_dim_x[i]:cum_dim_x[i]+dim[i]] != 0, axis=1)
|
|
1222
|
+
# number of constraints involving each agent, for each agent
|
|
1223
|
+
dim_mu = np.sum(Geq, axis=0)
|
|
1224
|
+
# cumulative sum of dim_mu
|
|
1225
|
+
cum_dim_mu = np.cumsum([0]+list(dim_mu[:-1]))
|
|
1226
|
+
# total number of y and lam and delta variables
|
|
1227
|
+
nmu = np.sum(dim_mu)
|
|
1228
|
+
else:
|
|
1229
|
+
nmu = 0
|
|
1230
|
+
dim_mu = None
|
|
1231
|
+
Geq = None
|
|
1232
|
+
|
|
1233
|
+
if variational:
|
|
1234
|
+
if has_ineq_constraints:
|
|
1235
|
+
# Find mapping from multiplier index to original constraint index for shared inequalities
|
|
1236
|
+
c_map = []
|
|
1237
|
+
for i in range(N):
|
|
1238
|
+
c_map_j = np.zeros(ncon-nbox, dtype=int)
|
|
1239
|
+
k = 0 # index of multiplier for agent i
|
|
1240
|
+
for j in range(ncon-nbox):
|
|
1241
|
+
if G[j, i]:
|
|
1242
|
+
# constraint j involves agent i -> this corresponds to lam(i,k)
|
|
1243
|
+
c_map_j[j] = k
|
|
1244
|
+
c_map.append(c_map_j)
|
|
1245
|
+
|
|
1246
|
+
if has_eq_constraints:
|
|
1247
|
+
# Find mapping from multiplier index to original constraint index for shared equalities
|
|
1248
|
+
ceq_map = []
|
|
1249
|
+
for i in range(N):
|
|
1250
|
+
ceq_map_j = np.zeros(nconeq, dtype=int)
|
|
1251
|
+
k = 0 # index of multiplier for agent i
|
|
1252
|
+
for j in range(nconeq):
|
|
1253
|
+
if Geq[j, i]:
|
|
1254
|
+
# constraint j involves agent i -> this corresponds to mu(i,k)
|
|
1255
|
+
ceq_map_j[j] = k
|
|
1256
|
+
ceq_map.append(ceq_map_j)
|
|
1257
|
+
|
|
1258
|
+
# Variable index ranges in the *single* Highs column space (j=agent index)
|
|
1259
|
+
# [ x (0..nx-1) | p (nx..nx+npar-1) | y | lam | delta ]
|
|
1260
|
+
def idx_x(j, i): return cum_dim_x[j] + i
|
|
1261
|
+
|
|
1262
|
+
if solver == 'highs':
|
|
1263
|
+
if has_params:
|
|
1264
|
+
def idx_p(t): return nx + t
|
|
1265
|
+
else:
|
|
1266
|
+
idx_p = None
|
|
1267
|
+
|
|
1268
|
+
if has_ineq_constraints:
|
|
1269
|
+
def idx_lam(j, k): return nx + npar + cum_dim_lam[j] + k
|
|
1270
|
+
def idx_delta(k): return nx + npar + nlam + k
|
|
1271
|
+
else:
|
|
1272
|
+
idx_lam = None
|
|
1273
|
+
idx_delta = None
|
|
1274
|
+
|
|
1275
|
+
if has_eq_constraints:
|
|
1276
|
+
def idx_mu(j, k): return nx + npar + (nlam + ncon) * \
|
|
1277
|
+
has_ineq_constraints + cum_dim_mu[j] + k
|
|
1278
|
+
else:
|
|
1279
|
+
idx_mu = None
|
|
1280
|
+
|
|
1281
|
+
if has_pwa_objective:
|
|
1282
|
+
def idx_eps(j): return nx + npar + (nlam + ncon) * \
|
|
1283
|
+
has_ineq_constraints + nmu*has_eq_constraints + j
|
|
1284
|
+
else:
|
|
1285
|
+
idx_eps = None
|
|
1286
|
+
|
|
1287
|
+
self.idx_lam = idx_lam
|
|
1288
|
+
self.idx_mu = idx_mu
|
|
1289
|
+
self.idx_delta = idx_delta
|
|
1290
|
+
self.idx_eps = idx_eps
|
|
1291
|
+
|
|
1292
|
+
mip = highspy.Highs()
|
|
1293
|
+
|
|
1294
|
+
# ------------------------------------------------------------------
|
|
1295
|
+
# 1. Add variables with bounds
|
|
1296
|
+
# ------------------------------------------------------------------
|
|
1297
|
+
# All costs default to 0 => min 0 (feasibility problem).
|
|
1298
|
+
|
|
1299
|
+
# x: free (or set bounds as needed)
|
|
1300
|
+
for i in range(nx):
|
|
1301
|
+
mip.addVar(lb[i], ub[i])
|
|
1302
|
+
|
|
1303
|
+
if has_params:
|
|
1304
|
+
# p: free (or set bounds as needed)
|
|
1305
|
+
for t in range(npar):
|
|
1306
|
+
mip.addVar(pmin[t], pmax[t])
|
|
1307
|
+
|
|
1308
|
+
if has_ineq_constraints:
|
|
1309
|
+
# lam: lam >=0
|
|
1310
|
+
for j in range(N):
|
|
1311
|
+
for k in range(dim_lam[j]):
|
|
1312
|
+
mip.addVar(0.0, inf)
|
|
1313
|
+
|
|
1314
|
+
# delta: binary => bounds [0,1] + integrality = integer
|
|
1315
|
+
for k in range(ncon):
|
|
1316
|
+
mip.addVar(0.0, 1.0)
|
|
1317
|
+
|
|
1318
|
+
# Mark delta columns as integer
|
|
1319
|
+
# (Binary is simply integer with bounds [0,1])
|
|
1320
|
+
for k in range(ncon):
|
|
1321
|
+
col = idx_delta(k)
|
|
1322
|
+
mip.changeColIntegrality(col, highspy.HighsVarType.kInteger)
|
|
1323
|
+
|
|
1324
|
+
if has_eq_constraints:
|
|
1325
|
+
# mu: free
|
|
1326
|
+
for j in range(N):
|
|
1327
|
+
for k in range(dim_mu[j]):
|
|
1328
|
+
mip.addVar(-inf, inf)
|
|
1329
|
+
|
|
1330
|
+
if has_pwa_objective:
|
|
1331
|
+
for k in range(nJ):
|
|
1332
|
+
# eps variable for PWA objective, unconstrained
|
|
1333
|
+
mip.addVar(-inf, inf)
|
|
1334
|
+
|
|
1335
|
+
# ------------------------------------------------------------------
|
|
1336
|
+
# 2. Add constraints
|
|
1337
|
+
# ------------------------------------------------------------------
|
|
1338
|
+
# (a) Qi x + Fi p + Ai^T lam_i + Q(i,-i) x(-i) = - ci
|
|
1339
|
+
for j in range(N):
|
|
1340
|
+
|
|
1341
|
+
if has_ineq_constraints:
|
|
1342
|
+
Gj = G[:, j] # constraints involving agent j
|
|
1343
|
+
nGj = np.sum(Gj)
|
|
1344
|
+
if has_eq_constraints:
|
|
1345
|
+
Geqj = Geq[:, j] # equality constraints involving agent j
|
|
1346
|
+
nGeqj = np.sum(Geqj)
|
|
1347
|
+
|
|
1348
|
+
for i in range(dim[j]):
|
|
1349
|
+
indices = []
|
|
1350
|
+
values = []
|
|
1351
|
+
|
|
1352
|
+
# Qx part: Q[j,:]@x = Q[j,i]@x(i) + Q[j,(-i)]@x(-i)
|
|
1353
|
+
row_Q = Q[j][idx_x(j, i), :]
|
|
1354
|
+
for k in range(nx):
|
|
1355
|
+
if row_Q[k] != 0.0:
|
|
1356
|
+
indices.append(k)
|
|
1357
|
+
values.append(row_Q[k])
|
|
1358
|
+
|
|
1359
|
+
if has_params:
|
|
1360
|
+
# Fp part: sum_t F[j,t] * p_t
|
|
1361
|
+
row_F = F[j][idx_x(j, i), :]
|
|
1362
|
+
for t in range(npar):
|
|
1363
|
+
if row_F[t] != 0.0:
|
|
1364
|
+
indices.append(idx_p(t))
|
|
1365
|
+
values.append(row_F[t])
|
|
1366
|
+
|
|
1367
|
+
if has_ineq_constraints:
|
|
1368
|
+
# A^T lam part: sum_k A[k,j] * lam_k
|
|
1369
|
+
# A is (nA, nx), so column j is A[:, j]
|
|
1370
|
+
col_Aj = A[Gj, idx_x(j, i)]
|
|
1371
|
+
for k in range(nGj):
|
|
1372
|
+
if col_Aj[k] != 0.0:
|
|
1373
|
+
indices.append(idx_lam(j, k))
|
|
1374
|
+
values.append(col_Aj[k])
|
|
1375
|
+
|
|
1376
|
+
if has_eq_constraints:
|
|
1377
|
+
# Aeq^T mu part: sum_k Aeq[k,j] * mu_k
|
|
1378
|
+
# Aeq is (nAeq, nx), so column j is Aeq[:, j]
|
|
1379
|
+
col_Aeqj = Aeq[Geqj, idx_x(j, i)]
|
|
1380
|
+
for k in range(nGeqj):
|
|
1381
|
+
if col_Aeqj[k] != 0.0:
|
|
1382
|
+
indices.append(idx_mu(j, k))
|
|
1383
|
+
values.append(col_Aeqj[k])
|
|
1384
|
+
|
|
1385
|
+
# Equality: lower = upper = -c_j
|
|
1386
|
+
rhs = -float(c[j][idx_x(j, i)])
|
|
1387
|
+
num_nz = len(indices)
|
|
1388
|
+
if num_nz == 0:
|
|
1389
|
+
# still add the row with empty pattern
|
|
1390
|
+
mip.addRow(rhs, rhs, 0, [], [])
|
|
1391
|
+
else:
|
|
1392
|
+
mip.addRow(rhs, rhs, num_nz,
|
|
1393
|
+
np.array(indices, dtype=np.int64),
|
|
1394
|
+
np.array(values, dtype=np.double))
|
|
1395
|
+
|
|
1396
|
+
if has_ineq_constraints:
|
|
1397
|
+
# (b) 0 <= lam(i,j) <= M * delta(j)
|
|
1398
|
+
for j in range(N):
|
|
1399
|
+
ind_lam = 0
|
|
1400
|
+
for k in range(ncon):
|
|
1401
|
+
if G[k, j]: # agent j involved in constraint k or vGNE
|
|
1402
|
+
indices = np.array(
|
|
1403
|
+
[idx_lam(j, ind_lam), idx_delta(k)], dtype=np.int64)
|
|
1404
|
+
values = np.array([1.0, -M], dtype=np.double)
|
|
1405
|
+
lower = -inf
|
|
1406
|
+
upper = 0.
|
|
1407
|
+
mip.addRow(lower, upper, len(
|
|
1408
|
+
indices), indices, values)
|
|
1409
|
+
ind_lam += 1
|
|
1410
|
+
|
|
1411
|
+
# (c) b + S p - A x <= M (1-delta)
|
|
1412
|
+
for i in range(ncon):
|
|
1413
|
+
indices = [idx_delta(i)]
|
|
1414
|
+
values = [M]
|
|
1415
|
+
upper = float(M - b[i])
|
|
1416
|
+
for k in range(nx):
|
|
1417
|
+
if A[i, k] != 0.0:
|
|
1418
|
+
indices.append(k)
|
|
1419
|
+
values.append(-A[i, k])
|
|
1420
|
+
if has_params:
|
|
1421
|
+
for t in range(npar):
|
|
1422
|
+
if S[i, t] != 0.0:
|
|
1423
|
+
indices.append(idx_p(t))
|
|
1424
|
+
values.append(S[i, t])
|
|
1425
|
+
indices = np.array(indices, dtype=np.int64)
|
|
1426
|
+
values = np.array(values, dtype=np.double)
|
|
1427
|
+
mip.addRow(-inf, upper, len(indices), indices, values)
|
|
1428
|
+
|
|
1429
|
+
# (d) A x <= b + S p
|
|
1430
|
+
for i in range(ncon):
|
|
1431
|
+
indices = []
|
|
1432
|
+
values = []
|
|
1433
|
+
|
|
1434
|
+
# Ai x part
|
|
1435
|
+
row_Ai = A[i, :]
|
|
1436
|
+
for k in range(nx):
|
|
1437
|
+
if row_Ai[k] != 0.0:
|
|
1438
|
+
indices.append(k)
|
|
1439
|
+
values.append(row_Ai[k])
|
|
1440
|
+
|
|
1441
|
+
if has_params:
|
|
1442
|
+
# Si p part
|
|
1443
|
+
row_Si = S[i, :]
|
|
1444
|
+
for t in range(npar):
|
|
1445
|
+
if row_Si[t] != 0.0:
|
|
1446
|
+
indices.append(idx_p(t))
|
|
1447
|
+
values.append(-row_Si[t])
|
|
1448
|
+
|
|
1449
|
+
rhs = float(b[i])
|
|
1450
|
+
num_nz = len(indices)
|
|
1451
|
+
mip.addRow(-inf, rhs, num_nz,
|
|
1452
|
+
np.array(indices, dtype=np.int64),
|
|
1453
|
+
np.array(values, dtype=np.double))
|
|
1454
|
+
|
|
1455
|
+
if has_eq_constraints:
|
|
1456
|
+
# (d2) Aeq x - Seq p = beq
|
|
1457
|
+
for i in range(nconeq):
|
|
1458
|
+
indices = []
|
|
1459
|
+
values = []
|
|
1460
|
+
|
|
1461
|
+
# Aeqi x part
|
|
1462
|
+
row_Aeqi = Aeq[i, :]
|
|
1463
|
+
for k in range(nx):
|
|
1464
|
+
if row_Aeqi[k] != 0.0:
|
|
1465
|
+
indices.append(k)
|
|
1466
|
+
values.append(row_Aeqi[k])
|
|
1467
|
+
|
|
1468
|
+
if has_params:
|
|
1469
|
+
# Seqi p part
|
|
1470
|
+
row_Seqi = Seq[i, :]
|
|
1471
|
+
for t in range(npar):
|
|
1472
|
+
if row_Seqi[t] != 0.0:
|
|
1473
|
+
indices.append(idx_p(t))
|
|
1474
|
+
values.append(-row_Seqi[t])
|
|
1475
|
+
|
|
1476
|
+
rhs = float(beq[i])
|
|
1477
|
+
num_nz = len(indices)
|
|
1478
|
+
mip.addRow(rhs, rhs, num_nz,
|
|
1479
|
+
np.array(indices, dtype=np.int64),
|
|
1480
|
+
np.array(values, dtype=np.double))
|
|
1481
|
+
if variational:
|
|
1482
|
+
if has_ineq_constraints:
|
|
1483
|
+
# exclude box constraints, they have their own multipliers
|
|
1484
|
+
for j in range(ncon-nbox):
|
|
1485
|
+
# indices of agents involved in constraint j
|
|
1486
|
+
ii = np.argwhere(G[j, :])
|
|
1487
|
+
i1 = int(ii[0]) # first agent involved
|
|
1488
|
+
for k in range(1, len(ii)): # loop not executed if only one agent involved
|
|
1489
|
+
i2 = int(ii[k]) # other agent involved
|
|
1490
|
+
indices = [idx_lam(i1, c_map[i1][j]),
|
|
1491
|
+
idx_lam(i2, c_map[i2][j])]
|
|
1492
|
+
num_nz = 2
|
|
1493
|
+
values = [1.0, -1.0]
|
|
1494
|
+
mip.addRow(0.0, 0.0, num_nz, np.array(indices, dtype=np.int64),
|
|
1495
|
+
np.array(values, dtype=np.double))
|
|
1496
|
+
|
|
1497
|
+
if has_eq_constraints:
|
|
1498
|
+
for j in range(nconeq):
|
|
1499
|
+
# indices of agents involved in constraint j
|
|
1500
|
+
ii = np.argwhere(Geq[j, :])
|
|
1501
|
+
i1 = int(ii[0]) # first agent involved
|
|
1502
|
+
for k in range(1, len(ii)): # loop not executed if only one agent involved
|
|
1503
|
+
i2 = int(ii[k]) # other agent involved
|
|
1504
|
+
indices = [idx_mu(i1, ceq_map[i1][j]),
|
|
1505
|
+
idx_mu(i2, ceq_map[i2][j])]
|
|
1506
|
+
num_nz = 2
|
|
1507
|
+
values = [1.0, -1.0]
|
|
1508
|
+
mip.addRow(0.0, 0.0, num_nz, np.array(indices, dtype=np.int64),
|
|
1509
|
+
np.array(values, dtype=np.double))
|
|
1510
|
+
|
|
1511
|
+
if has_pwa_objective:
|
|
1512
|
+
# (e) eps[k] >= D_pwa[k](i,:) x + E_pwa[k](i,:) p + h_pwa[k](i), i=1..nk
|
|
1513
|
+
for k in range(nJ):
|
|
1514
|
+
for i in range(D_pwa[k].shape[0]):
|
|
1515
|
+
indices = []
|
|
1516
|
+
values = []
|
|
1517
|
+
|
|
1518
|
+
# D x part
|
|
1519
|
+
row_Di = D_pwa[k][i, :]
|
|
1520
|
+
for t in range(nx):
|
|
1521
|
+
if row_Di[t] != 0.0:
|
|
1522
|
+
indices.append(t)
|
|
1523
|
+
values.append(row_Di[t])
|
|
1524
|
+
|
|
1525
|
+
# E p part
|
|
1526
|
+
if has_params:
|
|
1527
|
+
row_Ei = E_pwa[k][i, :]
|
|
1528
|
+
for t in range(npar):
|
|
1529
|
+
if row_Ei[t] != 0.0:
|
|
1530
|
+
indices.append(idx_p(t))
|
|
1531
|
+
values.append(row_Ei[t])
|
|
1532
|
+
|
|
1533
|
+
# eps part
|
|
1534
|
+
indices.append(idx_eps(k))
|
|
1535
|
+
values.append(-1.0)
|
|
1536
|
+
|
|
1537
|
+
rhs = float(-h_pwa[k][i])
|
|
1538
|
+
num_nz = len(indices)
|
|
1539
|
+
mip.addRow(-inf, rhs, num_nz,
|
|
1540
|
+
np.array(indices, dtype=np.int64),
|
|
1541
|
+
np.array(values, dtype=np.double))
|
|
1542
|
+
|
|
1543
|
+
# Define objective function: min eps
|
|
1544
|
+
mip.changeColCost(idx_eps(k), 1.0)
|
|
1545
|
+
|
|
1546
|
+
else: # gurobi
|
|
1547
|
+
|
|
1548
|
+
m = gp.Model("GNEP_LQ_MIP")
|
|
1549
|
+
mip = SimpleNamespace()
|
|
1550
|
+
mip.model = m
|
|
1551
|
+
|
|
1552
|
+
# x variables
|
|
1553
|
+
x = m.addVars(range(nx), lb=lb.tolist(), ub=ub.tolist(), vtype=gp.GRB.CONTINUOUS, name="x")
|
|
1554
|
+
mip.x = x
|
|
1555
|
+
p = m.addVars(range(npar), lb=pmin.tolist(), ub=pmax.tolist(), vtype=gp.GRB.CONTINUOUS, name="p") if has_params else None
|
|
1556
|
+
mip.p = p
|
|
1557
|
+
|
|
1558
|
+
if has_ineq_constraints:
|
|
1559
|
+
# lam: lam >=0
|
|
1560
|
+
lam = []
|
|
1561
|
+
for j in range(N):
|
|
1562
|
+
lam_j = m.addVars(range(dim_lam[j]), lb=0.0, ub=inf, vtype=gp.GRB.CONTINUOUS, name=f"lam_{j}")
|
|
1563
|
+
lam.append(lam_j)
|
|
1564
|
+
# delta: binary
|
|
1565
|
+
delta = m.addVars(range(ncon), vtype=gp.GRB.BINARY, name="delta")
|
|
1566
|
+
mip.lam = lam
|
|
1567
|
+
mip.delta = delta
|
|
1568
|
+
else:
|
|
1569
|
+
lam = None
|
|
1570
|
+
delta = None
|
|
1571
|
+
|
|
1572
|
+
if has_eq_constraints:
|
|
1573
|
+
# mu: free
|
|
1574
|
+
mu = []
|
|
1575
|
+
for j in range(N):
|
|
1576
|
+
mu_j = m.addVars(range(dim_mu[j]), lb=-inf, ub=inf, vtype=gp.GRB.CONTINUOUS, name=f"mu_{j}")
|
|
1577
|
+
mu.append(mu_j)
|
|
1578
|
+
mip.mu = mu
|
|
1579
|
+
else:
|
|
1580
|
+
mu = None
|
|
1581
|
+
|
|
1582
|
+
if has_pwa_objective:
|
|
1583
|
+
eps = m.addVars(range(nJ), lb=-inf, ub=inf, vtype=gp.GRB.CONTINUOUS, name="eps")
|
|
1584
|
+
mip.eps = eps
|
|
1585
|
+
|
|
1586
|
+
# ------------------------------------------------------------------
|
|
1587
|
+
# 2. Add constraints
|
|
1588
|
+
# ------------------------------------------------------------------
|
|
1589
|
+
# (a) Qi x + Fi p + Ai^T lam_i + Q(i,-i) x(-i) = - ci
|
|
1590
|
+
for j in range(N):
|
|
1591
|
+
if has_ineq_constraints:
|
|
1592
|
+
Gj = G[:, j] # constraints involving agent j
|
|
1593
|
+
nGj = np.sum(Gj)
|
|
1594
|
+
if has_eq_constraints:
|
|
1595
|
+
Geqj = Geq[:, j] # equality constraints involving agent j
|
|
1596
|
+
nGeqj = np.sum(Geqj)
|
|
1597
|
+
|
|
1598
|
+
KKT1 = []
|
|
1599
|
+
for i in range(dim[j]):
|
|
1600
|
+
# Qx part: Q[j,:]@x = Q[j,i]@x(i) + Q[j,(-i)]@x(-i)
|
|
1601
|
+
row_Q = Q[j][cum_dim_x[j] + i, :]
|
|
1602
|
+
KKT1_i = gp.quicksum(row_Q[t]*x[t] for t in range(nx)) + c[j][cum_dim_x[j] + i]
|
|
1603
|
+
|
|
1604
|
+
if has_params:
|
|
1605
|
+
# Fp part: sum_t F[j,t] * p_t
|
|
1606
|
+
row_F = F[j][idx_x(j, i), :]
|
|
1607
|
+
KKT1_i += gp.quicksum(row_F[t]*p[t] for t in range(npar))
|
|
1608
|
+
|
|
1609
|
+
if has_ineq_constraints:
|
|
1610
|
+
# A^T lam part: sum_k A[k,j] * lam_k
|
|
1611
|
+
# A is (nA, nx), so column j is A[:, j]
|
|
1612
|
+
col_Aj = A[Gj, idx_x(j, i)]
|
|
1613
|
+
KKT1_i += gp.quicksum(col_Aj[k]*lam[j][k] for k in range(nGj))
|
|
1614
|
+
|
|
1615
|
+
if has_eq_constraints:
|
|
1616
|
+
# Aeq^T mu part: sum_k Aeq[k,j] * mu_k
|
|
1617
|
+
# Aeq is (nAeq, nx), so column j is Aeq[:, j]
|
|
1618
|
+
col_Aeqj = Aeq[Geqj, idx_x(j, i)]
|
|
1619
|
+
KKT1_i += gp.quicksum(col_Aeqj[k]*mu[j][k] for k in range(nGeqj))
|
|
1620
|
+
|
|
1621
|
+
KKT1.append(KKT1_i)
|
|
1622
|
+
|
|
1623
|
+
m.addConstrs((KKT1[i] == 0. for i in range(dim[j])), name=f"KKT1_agent_{j}")
|
|
1624
|
+
|
|
1625
|
+
if has_ineq_constraints:
|
|
1626
|
+
# (b) 0 <= lam(i,j) <= M * delta(j)
|
|
1627
|
+
for j in range(N):
|
|
1628
|
+
ind_lam = 0
|
|
1629
|
+
for k in range(ncon):
|
|
1630
|
+
if G[k, j]: # agent j involved in constraint k or vGNE
|
|
1631
|
+
m.addConstr(lam[j][ind_lam] <= M * delta[k], name=f"big-M-lam_{j}_constr_{k}")
|
|
1632
|
+
ind_lam += 1
|
|
1633
|
+
|
|
1634
|
+
# (c) b + S p - A x <= M (1-delta)
|
|
1635
|
+
for i in range(ncon):
|
|
1636
|
+
m.addConstr(b[i] + gp.quicksum(S[i,t]*p[t] for t in range(npar) if has_params) - gp.quicksum(A[i,k]*x[k] for k in range(nx)) <= M * (1. - delta[i]), name=f"big-M-slack_constr_{i}")
|
|
1637
|
+
|
|
1638
|
+
# (d) A x <= b + S p
|
|
1639
|
+
for i in range(ncon):
|
|
1640
|
+
m.addConstr(gp.quicksum(A[i,k]*x[k] for k in range(nx)) <= b[i] + gp.quicksum(S[i,t]*p[t] for t in range(npar) if has_params), name=f"shared_ineq_constr_{i}")
|
|
1641
|
+
|
|
1642
|
+
if has_eq_constraints:
|
|
1643
|
+
# (d2) Aeq x - Seq p = beq
|
|
1644
|
+
for i in range(nconeq):
|
|
1645
|
+
m.addConstr(gp.quicksum(Aeq[i,k]*x[k] for k in range(nx)) - gp.quicksum(Seq[i,t]*p[t] for t in range(npar) if has_params) == beq[i], name=f"shared_eq_constr_{i}")
|
|
1646
|
+
|
|
1647
|
+
if variational:
|
|
1648
|
+
if has_ineq_constraints:
|
|
1649
|
+
# exclude box constraints, they have their own multipliers
|
|
1650
|
+
for j in range(ncon-nbox):
|
|
1651
|
+
# indices of agents involved in constraint j
|
|
1652
|
+
ii = np.argwhere(G[j, :])
|
|
1653
|
+
i1 = int(ii[0]) # first agent involved
|
|
1654
|
+
for k in range(1, len(ii)): # loop not executed if only one agent involved
|
|
1655
|
+
i2 = int(ii[k]) # other agent involved
|
|
1656
|
+
m.addConstr(lam[i1][c_map[i1][j]] == lam[i2][c_map[i2][j]], name=f"variational_ineq_constr_{j}")
|
|
1657
|
+
if has_eq_constraints:
|
|
1658
|
+
for j in range(nconeq):
|
|
1659
|
+
# indices of agents involved in constraint j
|
|
1660
|
+
ii = np.argwhere(Geq[j, :])
|
|
1661
|
+
i1 = int(ii[0]) # first agent involved
|
|
1662
|
+
for k in range(1, len(ii)): # loop not executed if only one agent involved
|
|
1663
|
+
i2 = int(ii[k]) # other agent involved
|
|
1664
|
+
m.addConstr(mu[i1][ceq_map[i1][j]] == mu[i2][ceq_map[i2][j]], name=f"variational_eq_constr_{j}")
|
|
1665
|
+
|
|
1666
|
+
if has_pwa_objective:
|
|
1667
|
+
# (e) eps[k] >= D[k](i,:) x + E[k](i,:) p + h[k](i), i=1..nk
|
|
1668
|
+
for k in range(nJ):
|
|
1669
|
+
for i in range(D_pwa[k].shape[0]):
|
|
1670
|
+
m.addConstr(eps[k] >= gp.quicksum(D_pwa[k][i,t]*x[t] for t in range(nx)) + gp.quicksum(E_pwa[k][i,t]*p[t] for t in range(npar) if has_params) + h_pwa[k][i], name=f"pwa_obj_constr_{k}_{i}")
|
|
1671
|
+
row_Di = D_pwa[k][i, :]
|
|
1672
|
+
if has_params:
|
|
1673
|
+
row_Ei = E_pwa[k][i, :]
|
|
1674
|
+
|
|
1675
|
+
J_PWA = gp.quicksum(eps[k] for k in range(nJ)) # Define objective function term: min sum(eps)
|
|
1676
|
+
else:
|
|
1677
|
+
J_PWA = 0.0
|
|
1678
|
+
|
|
1679
|
+
if has_quad_objective:
|
|
1680
|
+
J_Q = 0.5 * gp.quicksum(Q_J[i, j] * (x[i] if i < nx else p[i - nx]) * (x[j] if j < nx else p[j - nx]) for i in range(nx + npar) for j in range(nx + npar)) + gp.quicksum(c_J[i] * (x[i] if i < nx else p[i - nx]) for i in range(nx + npar))
|
|
1681
|
+
else:
|
|
1682
|
+
J_Q = 0.0
|
|
1683
|
+
|
|
1684
|
+
m.setObjective(J_PWA + J_Q, gp.GRB.MINIMIZE)
|
|
1685
|
+
|
|
1686
|
+
self.mip = mip
|
|
1687
|
+
self.has_params = has_params
|
|
1688
|
+
self.has_ineq_constraints = has_ineq_constraints
|
|
1689
|
+
self.has_eq_constraints = has_eq_constraints
|
|
1690
|
+
self.has_pwa_objective = has_pwa_objective
|
|
1691
|
+
self.nx = nx
|
|
1692
|
+
self.npar = npar
|
|
1693
|
+
self.ncon = ncon
|
|
1694
|
+
self.nconeq = nconeq
|
|
1695
|
+
self.N = N
|
|
1696
|
+
self.G = G
|
|
1697
|
+
self.A = A
|
|
1698
|
+
self.Geq = Geq
|
|
1699
|
+
self.dim_lam = dim_lam
|
|
1700
|
+
self.dim_mu = dim_mu
|
|
1701
|
+
self.M = M
|
|
1702
|
+
self.lb = lb
|
|
1703
|
+
self.ub = ub
|
|
1704
|
+
self.nbox = nbox
|
|
1705
|
+
self.pmin = pmin
|
|
1706
|
+
self.pmax = pmax
|
|
1707
|
+
if has_pwa_objective:
|
|
1708
|
+
self.nJ = nJ
|
|
1709
|
+
else:
|
|
1710
|
+
self.nJ = 0
|
|
1711
|
+
|
|
1712
|
+
def solve(self, max_solutions=1, verbose=0):
|
|
1713
|
+
"""Solve a linear quadratic generalized GNE problem and associated game-design problem via mixed-integer linear programming (MILP) or mixed-integer quadratic programming (MIQP):
|
|
1714
|
+
|
|
1715
|
+
min_{x,p,y,lam,delta,eps} sum(eps[k]) (if D,E,h provided)
|
|
1716
|
+
+ 0.5 *[x;p]^T Q_J [x;p] + c_J^T [x;p] (if Q_J,c_J provided)
|
|
1717
|
+
s.t.
|
|
1718
|
+
eps[k] >= D_pwa[k](i,:) x + E_pwa[k](i,:) p + h_pwa[k](i), i=1,...,nk
|
|
1719
|
+
Q_ii x_i + c_i + F_i p + Q_{i(-i)} x(-i) + A_i^T lam_i + Aeq_i^T mu_i = 0
|
|
1720
|
+
(individual 1st KKT condition)
|
|
1721
|
+
A x <= b + S p (shared inequality constraints)
|
|
1722
|
+
Aeq x = beq + Seq p (shared equality constraints)
|
|
1723
|
+
lam(i,j) >= 0 (individual Lagrange multipliers)
|
|
1724
|
+
b + S p - A x <= M (1-delta) (delta(j) = 1 -> constraint j is active)
|
|
1725
|
+
0 <= lam(i,j) <= M * delta(j) (delta(j) = 0 -> lam(i,j) = 0 for all agents i)
|
|
1726
|
+
delta(j) binary
|
|
1727
|
+
lb <= x <= ub (variable bounds, possibly infinite)
|
|
1728
|
+
pmin <= p <= pmax
|
|
1729
|
+
|
|
1730
|
+
If D_pwa, E_pwa, h_pwa, Q_J, and c_J are None, the objective function is omitted, and only an equilibrium point is searched for. If pmin = pmax (or pmin,pmax are None), the problem reduces to finding a solution to a standard (non-parametric) GNEP-QP (or, in case infinitely many exist, the one
|
|
1731
|
+
minimizing f(x,p). The MILP solver specified during object construction is used to solve the problem.
|
|
1732
|
+
|
|
1733
|
+
When multiple solutions are searched for (max_solutions > 1), the MIP is solved multiple times, adding a "no-good" cut after each solution found to exclude it from the feasible set.
|
|
1734
|
+
|
|
1735
|
+
MILP is used when no quadratic objective function is specified, otherwise MIQP is used (only Gurobi supported).
|
|
1736
|
+
|
|
1737
|
+
Parameters
|
|
1738
|
+
----------
|
|
1739
|
+
max_solutions : int
|
|
1740
|
+
Maximum number of solutions to look for (1 by default).
|
|
1741
|
+
verbose : int
|
|
1742
|
+
Verbosity level: 0 = None, 1 = minimal, 2 = detailed.
|
|
1743
|
+
|
|
1744
|
+
Returns
|
|
1745
|
+
-------
|
|
1746
|
+
sol : SimpleNamespace (or list of SimpleNamespace, if multiple solutions are searched for)
|
|
1747
|
+
Each entry has the following fields:
|
|
1748
|
+
x = generalized Nash equilibrium
|
|
1749
|
+
p = parameter vector (if any)
|
|
1750
|
+
lam = list of Lagrange multipliers for each agent (if any) in the order:
|
|
1751
|
+
- shared inequality constraints
|
|
1752
|
+
- finite lower bounds for agent i
|
|
1753
|
+
- finite upper bounds for agent i
|
|
1754
|
+
delta = binary variables for shared inequalities (if any)
|
|
1755
|
+
mu = list of Lagrange multipliers for equalities for each agent (if any)
|
|
1756
|
+
eps = optimal value of the objective function (if xdes is provided)
|
|
1757
|
+
G = boolean matrix indicating which constraints involve which agents (if any inequalities)
|
|
1758
|
+
Geq = boolean matrix indicating which equalities involve which agents (if any equalities)
|
|
1759
|
+
status_str = HiGHS MIP model status as string
|
|
1760
|
+
elapsed_time = time taken to solve the MILP (in seconds)
|
|
1761
|
+
|
|
1762
|
+
(C) 2025 Alberto Bemporad, December 18, 2025
|
|
1763
|
+
"""
|
|
1764
|
+
|
|
1765
|
+
nx = self.nx
|
|
1766
|
+
npar = self.npar
|
|
1767
|
+
ncon = self.ncon
|
|
1768
|
+
nconeq = self.nconeq
|
|
1769
|
+
nbox = self.nbox
|
|
1770
|
+
N = self.N
|
|
1771
|
+
G = self.G
|
|
1772
|
+
A = self.A
|
|
1773
|
+
Geq = self.Geq
|
|
1774
|
+
dim_lam = self.dim_lam
|
|
1775
|
+
dim_mu = self.dim_mu
|
|
1776
|
+
|
|
1777
|
+
pmin = self.pmin
|
|
1778
|
+
|
|
1779
|
+
if not self.has_ineq_constraints and max_solutions > 1:
|
|
1780
|
+
print(
|
|
1781
|
+
"\033[1;31mCannot search for multiple solutions if no inequality constraints are present.\033[0m")
|
|
1782
|
+
max_solutions = 1
|
|
1783
|
+
|
|
1784
|
+
if verbose >= 1:
|
|
1785
|
+
print("Solving MIP problem ...")
|
|
1786
|
+
|
|
1787
|
+
if self.solver == 'highs':
|
|
1788
|
+
idx_lam = self.idx_lam
|
|
1789
|
+
idx_mu = self.idx_mu
|
|
1790
|
+
idx_delta = self.idx_delta
|
|
1791
|
+
idx_eps = self.idx_eps
|
|
1792
|
+
inf = highspy.kHighsInf
|
|
1793
|
+
if verbose < 2:
|
|
1794
|
+
self.mip.setOptionValue("log_to_console", False)
|
|
1795
|
+
else:
|
|
1796
|
+
self.mip.model.setParam('OutputFlag', verbose >=2)
|
|
1797
|
+
|
|
1798
|
+
x = None
|
|
1799
|
+
p = None
|
|
1800
|
+
lam = None
|
|
1801
|
+
delta = None
|
|
1802
|
+
mu = None
|
|
1803
|
+
eps = None
|
|
1804
|
+
|
|
1805
|
+
go = True
|
|
1806
|
+
solutions = [] # store found solutions
|
|
1807
|
+
found = 0
|
|
1808
|
+
while go and (found < max_solutions):
|
|
1809
|
+
|
|
1810
|
+
t0 = time.time()
|
|
1811
|
+
|
|
1812
|
+
if self.solver == 'highs':
|
|
1813
|
+
status = self.mip.run()
|
|
1814
|
+
model_status = self.mip.getModelStatus()
|
|
1815
|
+
status_str = self.mip.modelStatusToString(model_status)
|
|
1816
|
+
|
|
1817
|
+
if (status != highspy.HighsStatus.kOk) or (model_status != highspy.HighsModelStatus.kOptimal):
|
|
1818
|
+
go = False
|
|
1819
|
+
else:
|
|
1820
|
+
self.mip.model.optimize()
|
|
1821
|
+
go = (self.mip.model.status == gp.GRB.OPTIMAL)
|
|
1822
|
+
status_str = 'optimal solution found' if go else 'not solved'
|
|
1823
|
+
|
|
1824
|
+
t0 = time.time() - t0
|
|
1825
|
+
|
|
1826
|
+
if go:
|
|
1827
|
+
found += 1
|
|
1828
|
+
if self.solver == 'highs':
|
|
1829
|
+
sol = self.mip.getSolution()
|
|
1830
|
+
x_full = np.array(sol.col_value, dtype=float)
|
|
1831
|
+
else:
|
|
1832
|
+
x_full = np.array(list(self.mip.model.getAttr('X', self.mip.x).values()))
|
|
1833
|
+
|
|
1834
|
+
if verbose == 1 and max_solutions > 1:
|
|
1835
|
+
print(".", end="")
|
|
1836
|
+
if found % 50 == 0 and found > 0:
|
|
1837
|
+
print("")
|
|
1838
|
+
|
|
1839
|
+
# Extract slices
|
|
1840
|
+
x = x_full[0:nx].reshape(-1)
|
|
1841
|
+
if self.has_params:
|
|
1842
|
+
if self.solver == 'highs':
|
|
1843
|
+
p = x_full[nx:nx+npar].reshape(-1)
|
|
1844
|
+
else:
|
|
1845
|
+
p = np.array(list(self.mip.model.getAttr('X', self.mip.p).values()))
|
|
1846
|
+
else:
|
|
1847
|
+
p = pmin # fixed p (or None)
|
|
1848
|
+
|
|
1849
|
+
if self.has_ineq_constraints:
|
|
1850
|
+
if self.solver == 'highs':
|
|
1851
|
+
delta = x_full[idx_delta(0):idx_delta(ncon)].reshape(-1)
|
|
1852
|
+
else:
|
|
1853
|
+
delta = np.array(list(self.mip.model.getAttr('X', self.mip.delta).values()))
|
|
1854
|
+
# Round delta to {0,1} just in case
|
|
1855
|
+
delta = 0 + (delta > 0.5)
|
|
1856
|
+
|
|
1857
|
+
lam = []
|
|
1858
|
+
for j in range(N):
|
|
1859
|
+
lam_j = np.zeros(ncon)
|
|
1860
|
+
if self.solver == 'highs':
|
|
1861
|
+
lam_j[G[:, j]] = x_full[idx_lam(
|
|
1862
|
+
j, 0):idx_lam(j, dim_lam[j])]
|
|
1863
|
+
else:
|
|
1864
|
+
lam_j[G[:, j]] = np.array(list(self.mip.model.getAttr('X', self.mip.lam[j]).values()))
|
|
1865
|
+
lam_g = lam_j[:ncon - nbox] # exclude box constraints
|
|
1866
|
+
# add only multipliers for box constraints involving agent j
|
|
1867
|
+
# Start with finite lower bounds
|
|
1868
|
+
for k in range(ncon - nbox, ncon):
|
|
1869
|
+
if G[k, j] and sum(A[k, :]) < -0.5:
|
|
1870
|
+
lam_g = np.hstack((lam_g, lam_j[k]))
|
|
1871
|
+
for k in range(ncon - nbox, ncon):
|
|
1872
|
+
if G[k, j] and sum(A[k, :]) > 0.5:
|
|
1873
|
+
lam_g = np.hstack((lam_g, lam_j[k]))
|
|
1874
|
+
lam.append(lam_g.reshape(-1))
|
|
1875
|
+
|
|
1876
|
+
if self.has_eq_constraints:
|
|
1877
|
+
mu = []
|
|
1878
|
+
for j in range(N):
|
|
1879
|
+
mu_j = np.zeros(nconeq)
|
|
1880
|
+
if self.solver == 'highs':
|
|
1881
|
+
mu_j[Geq[:, j]] = x_full[idx_mu(
|
|
1882
|
+
j, 0):idx_mu(j, dim_mu[j])]
|
|
1883
|
+
else:
|
|
1884
|
+
mu_j[Geq[:, j]] = np.array(list(self.mip.model.getAttr('X', self.mip.mu[j]).values()))
|
|
1885
|
+
mu.append(mu_j.reshape(-1))
|
|
1886
|
+
|
|
1887
|
+
if self.has_pwa_objective:
|
|
1888
|
+
if self.solver == 'highs':
|
|
1889
|
+
eps = np.array(x_full[idx_eps(0):idx_eps(self.nJ)]).reshape(-1)
|
|
1890
|
+
else:
|
|
1891
|
+
eps = np.array(list(self.mip.model.getAttr('X', self.mip.eps).values()))
|
|
1892
|
+
|
|
1893
|
+
solutions.append(SimpleNamespace(x=x, p=p, lam=lam, delta=delta, mu=mu,
|
|
1894
|
+
eps=eps, status_str=status_str, G=G, Geq=Geq, elapsed_time=t0))
|
|
1895
|
+
|
|
1896
|
+
if found < max_solutions:
|
|
1897
|
+
# Append no-good constraint to exclude this delta in future iterations
|
|
1898
|
+
# sum_{i: delta_k(i)=1} delta(i) - sum_{i: delta_k(i)=0} delta(i) <= -1 + sum(delta_k(i))
|
|
1899
|
+
if self.solver == 'highs':
|
|
1900
|
+
indices = np.array([idx_delta(k)
|
|
1901
|
+
for k in range(ncon)], dtype=np.int64)
|
|
1902
|
+
values = np.ones(ncon, dtype=np.double)
|
|
1903
|
+
values[delta < 0.5] = -1.0
|
|
1904
|
+
lower = -inf
|
|
1905
|
+
upper = np.sum(delta) - 1.
|
|
1906
|
+
self.mip.addRow(lower, upper, len(
|
|
1907
|
+
indices), indices, values)
|
|
1908
|
+
else:
|
|
1909
|
+
self.mip.model.addConstr(
|
|
1910
|
+
gp.quicksum(self.mip.delta[k] if delta[k] > 0.5 else -self.mip.delta[k] for k in range(ncon)) <= - 1. + np.sum(delta),
|
|
1911
|
+
name=f"no_good_cut_{found}")
|
|
1912
|
+
|
|
1913
|
+
if verbose == 1:
|
|
1914
|
+
print(f" done. {found} combinations found")
|
|
1915
|
+
|
|
1916
|
+
if len(solutions) == 1:
|
|
1917
|
+
return solutions[0]
|
|
1918
|
+
else:
|
|
1919
|
+
return solutions
|
|
1920
|
+
|
|
1921
|
+
|
|
1922
|
+
class NashLQR():
|
|
1923
|
+
def __init__(self, sizes, A, B, Q, R, dare_iters=50):
|
|
1924
|
+
"""Set up a discrete-time linear quadratic dynamic game (Nash-LQR game) with N agents.
|
|
1925
|
+
|
|
1926
|
+
The dynamics are given by
|
|
1927
|
+
|
|
1928
|
+
x(k+1) = A x(k) + sum_{i=1..N} B_i u_i(k)
|
|
1929
|
+
|
|
1930
|
+
where x(k) is the state vector at time k, and u_i(k) is the control input of agent i at time k and has dimension sizes[i].
|
|
1931
|
+
|
|
1932
|
+
Each agent i minimizes its LQR cost K_i
|
|
1933
|
+
|
|
1934
|
+
J_i = sum_{k=0}^\infty x(k)^T Q_i x(k) + u_i(k)^T R_i u_i(k)
|
|
1935
|
+
|
|
1936
|
+
subject to dynamics x(k+1) = (A -B_{-i}K_{-i})x(k) + B_i u_i(k).
|
|
1937
|
+
|
|
1938
|
+
The LQR cost is solved by approximating the infinite-horizon cost by a finite-horizon cost using "dare_iters" fixed-point iterations.
|
|
1939
|
+
|
|
1940
|
+
(C) 2025 Alberto Bemporad, December 20, 2025
|
|
1941
|
+
"""
|
|
1942
|
+
self.sizes = sizes
|
|
1943
|
+
self.A = A
|
|
1944
|
+
N = len(sizes)
|
|
1945
|
+
self.N = N
|
|
1946
|
+
nx = A.shape[0]
|
|
1947
|
+
self.nx = nx
|
|
1948
|
+
nu = sum(sizes)
|
|
1949
|
+
if not B.shape == (nx, nu):
|
|
1950
|
+
raise ValueError(
|
|
1951
|
+
f"B must be of shape ({nx},{nu}), you provided {B.shape}")
|
|
1952
|
+
self.B = B
|
|
1953
|
+
if len(Q) != len(sizes):
|
|
1954
|
+
raise ValueError(
|
|
1955
|
+
f"Q must be a list of matrices with length equal to number {N} of agents")
|
|
1956
|
+
for i in range(N):
|
|
1957
|
+
if not Q[i].shape == (nx, nx):
|
|
1958
|
+
raise ValueError(f"Q[{i}] must be of shape ({nx},{nx})")
|
|
1959
|
+
# We should also check that Q[i] is symmetric and positive semidefinite ...
|
|
1960
|
+
|
|
1961
|
+
self.Q = Q
|
|
1962
|
+
if len(R) != N:
|
|
1963
|
+
raise ValueError(
|
|
1964
|
+
f"R must be a list of matrices with length equal to number {N} of agents")
|
|
1965
|
+
for i in range(N):
|
|
1966
|
+
if not R[i].shape == (sizes[i], sizes[i]):
|
|
1967
|
+
raise ValueError(
|
|
1968
|
+
f"R[{i}] must be of shape ({sizes[i]},{sizes[i]})")
|
|
1969
|
+
# We should also check that R[i] is symmetric and positive definite ...
|
|
1970
|
+
|
|
1971
|
+
self.R = R
|
|
1972
|
+
self.dare_iters = dare_iters
|
|
1973
|
+
nu = sum(sizes)
|
|
1974
|
+
self.nu = nu
|
|
1975
|
+
sum_i = np.cumsum(sizes)
|
|
1976
|
+
not_i = [list(range(sizes[0], nu))]
|
|
1977
|
+
for i in range(1, N):
|
|
1978
|
+
not_i.append(list(range(sum_i[i-1])) + list(range(sum_i[i], nu)))
|
|
1979
|
+
self.not_i = not_i
|
|
1980
|
+
self.ii = [list(range(sum_i[i]-sizes[i], sum_i[i])) for i in range(N)]
|
|
1981
|
+
|
|
1982
|
+
def solve(self, **kwargs):
|
|
1983
|
+
"""Solve the Nash-LQR game.
|
|
1984
|
+
|
|
1985
|
+
K_Nash = self.solve() provides the Nash equilibrium feedback gain matrix K_Nash, where u = -K_Nash x.
|
|
1986
|
+
|
|
1987
|
+
K_Nash = self.solve(**kwargs) allows passing additional keyword arguments to the underlying GNEP solver, see GNEP.solve() for details.
|
|
1988
|
+
"""
|
|
1989
|
+
|
|
1990
|
+
dare_iters = self.dare_iters
|
|
1991
|
+
|
|
1992
|
+
@jax.jit
|
|
1993
|
+
def jax_dare(A, B, Q, R):
|
|
1994
|
+
""" Solve the discrete-time ARE
|
|
1995
|
+
|
|
1996
|
+
X = A^T X A - A^T X B (R + B^T X B)^(-1) B^T X A + Q
|
|
1997
|
+
|
|
1998
|
+
using the following simple fixed-point iterations
|
|
1999
|
+
|
|
2000
|
+
K = (R + B^T X_k B)^(-1) B^T X_k A
|
|
2001
|
+
A_cl = A - B K
|
|
2002
|
+
X_{k+1} = Q + A_cl^T X_k A_cl + K^T R K
|
|
2003
|
+
|
|
2004
|
+
"""
|
|
2005
|
+
|
|
2006
|
+
A = jnp.asarray(A)
|
|
2007
|
+
B = jnp.asarray(B)
|
|
2008
|
+
Q = jnp.asarray(Q)
|
|
2009
|
+
R = jnp.asarray(R)
|
|
2010
|
+
|
|
2011
|
+
def get_K(X, A, B, R):
|
|
2012
|
+
S = R + B.T @ X @ B
|
|
2013
|
+
L, lower = cho_factor(S, lower=True)
|
|
2014
|
+
# Equivalent to K = (R + B^T X B)^-1 B^T X A
|
|
2015
|
+
K = cho_solve((L, lower), B.T @ X @ A)
|
|
2016
|
+
return K
|
|
2017
|
+
|
|
2018
|
+
def update(X, _):
|
|
2019
|
+
K = get_K(X, A, B, R)
|
|
2020
|
+
A_cl = A - B @ K
|
|
2021
|
+
X_next = Q + A_cl.T @ X @ A_cl + K.T @ R @ K
|
|
2022
|
+
return X_next, _
|
|
2023
|
+
|
|
2024
|
+
# initial state: X = Q (or zeros)
|
|
2025
|
+
X_final, _ = jax.lax.scan(update, Q, xs=None, length=dare_iters)
|
|
2026
|
+
|
|
2027
|
+
K_final = get_K(X_final, A, B, R)
|
|
2028
|
+
return X_final, K_final
|
|
2029
|
+
|
|
2030
|
+
@partial(jax.jit, static_argnums=(1,)) # i is static
|
|
2031
|
+
def lqr_fun(K_flat, i, A, B, Q, R):
|
|
2032
|
+
K = K_flat.reshape(self.nu, self.nx)
|
|
2033
|
+
Ai = A - B[:, self.not_i[i]]@K[self.not_i[i], :]
|
|
2034
|
+
Bi = B[:, self.ii[i]]
|
|
2035
|
+
_, Ki = jax_dare(Ai, Bi, Q[i], R[i]) # best response gain
|
|
2036
|
+
return jnp.sum((K[self.ii[i], :]-Ki)**2) # Frobenius norm squared
|
|
2037
|
+
self.lqr_fun = lqr_fun # store for possible later use outside solve()
|
|
2038
|
+
|
|
2039
|
+
f = []
|
|
2040
|
+
for i in range(self.N):
|
|
2041
|
+
f.append(partial(lqr_fun, i=i, A=self.A,
|
|
2042
|
+
B=self.B, Q=self.Q, R=self.R))
|
|
2043
|
+
|
|
2044
|
+
# each agent's variable is K_i (size[i] x nx) flattened
|
|
2045
|
+
sizes = [self.sizes[i]*self.nx for i in range(self.N)]
|
|
2046
|
+
gnep = GNEP(sizes, f=f)
|
|
2047
|
+
|
|
2048
|
+
# Initial guess = centralized LQR
|
|
2049
|
+
nu = self.nu
|
|
2050
|
+
bigR = block_diag(*self.R)
|
|
2051
|
+
bigQ = sum(self.Q[i] for i in range(self.N))
|
|
2052
|
+
_, K_cen = jax_dare(self.A, self.B, bigQ, bigR)
|
|
2053
|
+
|
|
2054
|
+
# # Check for comparison using python control library
|
|
2055
|
+
# from control import dare
|
|
2056
|
+
# P1, _, K1 = dare(A, B, bigQ, bigR)
|
|
2057
|
+
# print("Max difference between LQR gains: ", np.max(np.abs(K_cen - K1)))
|
|
2058
|
+
# print("Max difference between Riccati matrices: ", np.max(np.abs(P - P1)))
|
|
2059
|
+
|
|
2060
|
+
print("Solving Nash-LQR problem ... ", end='')
|
|
2061
|
+
|
|
2062
|
+
K0 = K_cen.flatten()
|
|
2063
|
+
sol = gnep.solve(x0=K0, **kwargs)
|
|
2064
|
+
K_Nash, residual, stats = sol.x, sol.res, sol.stats
|
|
2065
|
+
print("done.")
|
|
2066
|
+
K_Nash = K_Nash.reshape(nu, self.nx)
|
|
2067
|
+
|
|
2068
|
+
sol = SimpleNamespace()
|
|
2069
|
+
sol.K_Nash = K_Nash
|
|
2070
|
+
sol.residual = residual
|
|
2071
|
+
sol.stats = stats
|
|
2072
|
+
sol.K_centralized = K_cen
|
|
2073
|
+
return sol
|
|
2074
|
+
|
|
2075
|
+
|
|
2076
|
+
class NashLinearMPC():
|
|
2077
|
+
def __init__(self, sizes, A, B, C, Qy, Qdu, T, ymin=None, ymax=None, umin=None, umax=None, dumin=None, dumax=None, Qeps=None, Tc=None):
|
|
2078
|
+
"""Set up a game-theoretic linear MPC problem with N agents for set-point tracking.
|
|
2079
|
+
|
|
2080
|
+
The dynamics are given by
|
|
2081
|
+
|
|
2082
|
+
x(t+1) = A x(t) + B u (t)
|
|
2083
|
+
y(t) = C x(t)
|
|
2084
|
+
u(t) = u(t-1) + du(t)
|
|
2085
|
+
|
|
2086
|
+
where x(t) is the state vector, du(t) the vector of input increments, and y(t) the output vector at time t. The input vector u(t) is partitioned among N agents as u = [u_1; ...; u_N], where u_i(t) is the control input of agent i, with u_i of dimension sizes[i].
|
|
2087
|
+
|
|
2088
|
+
Each agent i minimizes its finite-horizon cost
|
|
2089
|
+
|
|
2090
|
+
J_i(du,w) = sum_{k=0}^{T-1} (y(k+1)-w)^T Q[i] (y(k+1) - w) + du_i(k)^T Qdu[i] du_i(k)
|
|
2091
|
+
|
|
2092
|
+
subject to the above dynamics and the following (local) input constraints
|
|
2093
|
+
|
|
2094
|
+
u_i_min <= u_i(k) <= u_i_max
|
|
2095
|
+
du_i_min <= du_i(k) <= du_i_max
|
|
2096
|
+
|
|
2097
|
+
and (shared) output constraints
|
|
2098
|
+
|
|
2099
|
+
-sum_i(eps[i]) + y_min <= y(k+1) <= y_max + sum_i(eps[i])
|
|
2100
|
+
|
|
2101
|
+
where eps[i] >= 0 is a slack variable penalized in the cost function with the linear term Qeps[i]*eps[i] to soften the output constraints and prevent infeasibility issues. By default, the constraints are imposed at all time steps k=0 ... T-1, but if a constraint horizon Tc<T is provided, they are only imposed up to time Tc-1.
|
|
2102
|
+
|
|
2103
|
+
The problem is solved via MILP to compute the first input increment du_{0,i} of each agent to apply to the system to close the loop.
|
|
2104
|
+
|
|
2105
|
+
If variational=True at solution time, a variational equilibrium is computed by adding the necessary equality constraints on the multipliers of the shared output constraints.
|
|
2106
|
+
|
|
2107
|
+
If centralized=True at solution time, a centralized MPC problem is solved instead of the game-theoretic one.
|
|
2108
|
+
|
|
2109
|
+
(C) 2025 Alberto Bemporad, December 26, 2025.
|
|
2110
|
+
|
|
2111
|
+
Parameters
|
|
2112
|
+
----------
|
|
2113
|
+
sizes : list of int
|
|
2114
|
+
List of dimensions of each agent's input vector.
|
|
2115
|
+
A : ndarray
|
|
2116
|
+
State matrix of the discrete-time system.
|
|
2117
|
+
B : ndarray
|
|
2118
|
+
Input matrix of the discrete-time system.
|
|
2119
|
+
C : ndarray
|
|
2120
|
+
Output matrix of the discrete-time system.
|
|
2121
|
+
Qy : list of ndarray
|
|
2122
|
+
List of output weighting matrices for each agent.
|
|
2123
|
+
Qdu : list of ndarray
|
|
2124
|
+
List of input increment weighting matrices for each agent.
|
|
2125
|
+
T : int
|
|
2126
|
+
Prediction horizon.
|
|
2127
|
+
ymin : ndarray, optional
|
|
2128
|
+
Minimum output constraints (shared among all agents). If None, no lower bound is applied.
|
|
2129
|
+
ymax : ndarray, optional
|
|
2130
|
+
Maximum output constraints (shared among all agents). If None, no upper bound is applied.
|
|
2131
|
+
umin : ndarray, optional
|
|
2132
|
+
Lower bound on input vector. If None, no lower bound is applied.
|
|
2133
|
+
umax : ndarray, optional
|
|
2134
|
+
Upper bound on input vector. If None, no upper bound is applied.
|
|
2135
|
+
dumin : ndarray, optional
|
|
2136
|
+
Lower bound on input increments. If None, no lower bound is applied.
|
|
2137
|
+
dumax : ndarray, optional
|
|
2138
|
+
Upper bound on input increments. If None, no upper bound is applied.
|
|
2139
|
+
Qeps : float, list, or None, optional
|
|
2140
|
+
List of slack variable penalties for each agent. If None, a default value of 1.e3 is used for all agents.
|
|
2141
|
+
Tc : int, optional
|
|
2142
|
+
Constraint horizon. If None, constraints are applied over the entire prediction horizon T.
|
|
2143
|
+
"""
|
|
2144
|
+
|
|
2145
|
+
self.sizes = sizes
|
|
2146
|
+
self.A = A
|
|
2147
|
+
N = len(sizes)
|
|
2148
|
+
self.N = N
|
|
2149
|
+
nx = A.shape[0]
|
|
2150
|
+
self.nx = nx
|
|
2151
|
+
nu = sum(sizes)
|
|
2152
|
+
self.nu = nu
|
|
2153
|
+
if not B.shape == (nx, nu):
|
|
2154
|
+
raise ValueError(
|
|
2155
|
+
f"B must be of shape ({nx},{nu}), you provided {B.shape}")
|
|
2156
|
+
self.B = B
|
|
2157
|
+
ny = C.shape[0]
|
|
2158
|
+
if not C.shape == (ny, nx):
|
|
2159
|
+
raise ValueError(
|
|
2160
|
+
f"C must be of shape ({ny},{nx}), you provided {C.shape}")
|
|
2161
|
+
self.C = C
|
|
2162
|
+
self.ny = ny
|
|
2163
|
+
|
|
2164
|
+
if len(Qy) != len(sizes):
|
|
2165
|
+
raise ValueError(
|
|
2166
|
+
f"Qy must be a list of matrices with length equal to number {N} of agents")
|
|
2167
|
+
for i in range(N):
|
|
2168
|
+
if not isinstance(Qy[i], np.ndarray):
|
|
2169
|
+
# scalar output case
|
|
2170
|
+
Qy[i] = np.array(Qy[i]).reshape(1, 1)
|
|
2171
|
+
if not Qy[i].shape == (ny, ny):
|
|
2172
|
+
raise ValueError(f"Qy[{i}] must be of shape ({ny},{ny})")
|
|
2173
|
+
self.Qy = Qy
|
|
2174
|
+
if len(Qdu) != N:
|
|
2175
|
+
raise ValueError(
|
|
2176
|
+
f"Rdu must be a list of matrices with length equal to number {N} of agents")
|
|
2177
|
+
for i in range(N):
|
|
2178
|
+
if not isinstance(Qdu[i], np.ndarray):
|
|
2179
|
+
# scalar input case
|
|
2180
|
+
Qdu[i] = np.array(Qdu[i]).reshape(1, 1)
|
|
2181
|
+
if not Qdu[i].shape == (sizes[i], sizes[i]):
|
|
2182
|
+
raise ValueError(
|
|
2183
|
+
f"Rdu[{i}] must be of shape ({sizes[i]},{sizes[i]})")
|
|
2184
|
+
self.Qdu = Qdu
|
|
2185
|
+
|
|
2186
|
+
if Qeps is None:
|
|
2187
|
+
Qeps = [1.e3]*N
|
|
2188
|
+
elif not isinstance(Qeps, list):
|
|
2189
|
+
Qeps = [Qeps]*N
|
|
2190
|
+
else:
|
|
2191
|
+
if len(Qeps) != N:
|
|
2192
|
+
raise ValueError(
|
|
2193
|
+
f"Qeps must be a list of length equal to number {N} of agents")
|
|
2194
|
+
self.Qeps = Qeps
|
|
2195
|
+
self.T = T # prediction horizon
|
|
2196
|
+
self.Tc = min(Tc, T) if Tc is not None else T # constraint horizon
|
|
2197
|
+
|
|
2198
|
+
if ymin is not None:
|
|
2199
|
+
if not isinstance(ymin, np.ndarray):
|
|
2200
|
+
ymin = np.array(ymin).reshape(1,)
|
|
2201
|
+
if not ymin.shape == (ny,):
|
|
2202
|
+
raise ValueError(
|
|
2203
|
+
f"ymin must be of shape ({ny},), you provided {ymin.shape}")
|
|
2204
|
+
else:
|
|
2205
|
+
ymin = -np.inf * np.ones(ny)
|
|
2206
|
+
self.ymin = ymin
|
|
2207
|
+
if ymax is not None:
|
|
2208
|
+
if not isinstance(ymax, np.ndarray):
|
|
2209
|
+
ymax = np.array(ymax).reshape(1,)
|
|
2210
|
+
if not ymax.shape == (ny,):
|
|
2211
|
+
raise ValueError(
|
|
2212
|
+
f"ymax must be of shape ({ny},), you provided {ymax.shape}")
|
|
2213
|
+
else:
|
|
2214
|
+
ymax = np.inf * np.ones(ny)
|
|
2215
|
+
self.ymax = ymax
|
|
2216
|
+
if umin is not None:
|
|
2217
|
+
if not isinstance(umin, np.ndarray):
|
|
2218
|
+
umin = np.array(umin).reshape(1,)
|
|
2219
|
+
if not umin.shape == (nu,):
|
|
2220
|
+
raise ValueError(
|
|
2221
|
+
f"umin must be of shape ({nu},), you provided {umin.shape}")
|
|
2222
|
+
else:
|
|
2223
|
+
umin = -np.inf * np.ones(nu)
|
|
2224
|
+
self.umin = umin
|
|
2225
|
+
if umax is not None:
|
|
2226
|
+
if not isinstance(umax, np.ndarray):
|
|
2227
|
+
umax = np.array(umax).reshape(1,)
|
|
2228
|
+
if not umax.shape == (nu,):
|
|
2229
|
+
raise ValueError(
|
|
2230
|
+
f"umax must be of shape ({nu},), you provided {umax.shape}")
|
|
2231
|
+
else:
|
|
2232
|
+
umax = np.inf * np.ones(nu)
|
|
2233
|
+
self.umax = umax
|
|
2234
|
+
if dumin is not None:
|
|
2235
|
+
if not isinstance(dumin, np.ndarray):
|
|
2236
|
+
dumin = np.array(dumin).reshape(1,)
|
|
2237
|
+
if not dumin.shape == (nu,):
|
|
2238
|
+
raise ValueError(
|
|
2239
|
+
f"dumin must be of shape ({nu},), you provided {dumin.shape}")
|
|
2240
|
+
else:
|
|
2241
|
+
dumin = -np.inf * np.ones(nu)
|
|
2242
|
+
self.dumin = dumin
|
|
2243
|
+
if dumax is not None:
|
|
2244
|
+
if not isinstance(dumax, np.ndarray):
|
|
2245
|
+
dumax = np.array(dumax).reshape(1,)
|
|
2246
|
+
if not dumax.shape == (nu,):
|
|
2247
|
+
raise ValueError(
|
|
2248
|
+
f"dumax must be of shape ({nu},), you provided {dumax.shape}")
|
|
2249
|
+
else:
|
|
2250
|
+
dumax = np.inf * np.ones(nu)
|
|
2251
|
+
self.dumax = dumax
|
|
2252
|
+
|
|
2253
|
+
def build_qp(A, B, C, Qy, Qdu, Qeps, sizes, N, T, ymin, ymax, umin, umax, dumin, dumax, Tc):
|
|
2254
|
+
# Construct QP problem to solve linear MPC for a generic input sequence du
|
|
2255
|
+
nx, nu = B.shape
|
|
2256
|
+
ny = C.shape[0]
|
|
2257
|
+
|
|
2258
|
+
# Build extended system matrices (input = du, state = (x,u), output = y)
|
|
2259
|
+
Ae = np.block([[A, B],
|
|
2260
|
+
[np.zeros((nu, nx)), np.eye(nu)]])
|
|
2261
|
+
Be = np.vstack((B, np.eye(nu)))
|
|
2262
|
+
Ce = np.hstack((C, np.zeros((ny, nu))))
|
|
2263
|
+
|
|
2264
|
+
Ak = [np.eye(nx+nu)]
|
|
2265
|
+
for k in range(1, T+1):
|
|
2266
|
+
Ak.append(Ak[-1] @ Ae) # [A,B;0,I]^k
|
|
2267
|
+
|
|
2268
|
+
# Determine x(k) = Sx * x0 + Su * du_sequence, k=1,...,T
|
|
2269
|
+
Sx = np.zeros((T * (nx+nu), nx+nu))
|
|
2270
|
+
Su = np.zeros((T * (nx+nu), T*nu))
|
|
2271
|
+
|
|
2272
|
+
for k in range(1, T+1):
|
|
2273
|
+
# row block for x_k is from idx_start to idx_end
|
|
2274
|
+
i1 = (k-1) * (nx+nu)
|
|
2275
|
+
i2 = k * (nx+nu)
|
|
2276
|
+
|
|
2277
|
+
# x_k = A^k x0 + sum_{j=0..k-1} A^{k-1-j} Bu u_j
|
|
2278
|
+
Sx[i1:i2, :] = Ak[k]
|
|
2279
|
+
|
|
2280
|
+
for j in range(k): # j = 0..k-1
|
|
2281
|
+
Su[i1:i2, nu*j:nu*(j+1)] += Ak[k-1-j] @ Be
|
|
2282
|
+
|
|
2283
|
+
Qblk = [np.kron(np.eye(T), Qy[i])
|
|
2284
|
+
for i in range(N)] # [(T*ny x T*ny)]
|
|
2285
|
+
# [du_1(0); ...; du_N(0); ...; du_1(T-1); ...; du_N(T-1)]
|
|
2286
|
+
Rblk = np.zeros((T*nu, T*nu))
|
|
2287
|
+
cumsizes = np.cumsum([0]+sizes)
|
|
2288
|
+
for k in range(T):
|
|
2289
|
+
off = k*nu
|
|
2290
|
+
for i in range(N):
|
|
2291
|
+
Rblk[off+cumsizes[i]:off+cumsizes[i+1], off +
|
|
2292
|
+
cumsizes[i]:off+cumsizes[i+1]] = Qdu[i]
|
|
2293
|
+
|
|
2294
|
+
Cbar = np.kron(np.eye(T), Ce) # (T*ny x T*(nx+nu))
|
|
2295
|
+
# Determine y(k) = Sx_y * x0 + Su_y * du_sequence
|
|
2296
|
+
Sx_y = Cbar @ Sx # (T*ny x (nx+nu))
|
|
2297
|
+
Su_y = Cbar @ Su # (T*ny x N)
|
|
2298
|
+
# (T*ny x ny), for reference tracking
|
|
2299
|
+
E = np.kron(np.ones((T, 1)), np.eye(ny))
|
|
2300
|
+
|
|
2301
|
+
# Y -E@w = Cbar@X - E@w = Sx_y@x0 + Su_y@dU -E@w
|
|
2302
|
+
# .5*(Y -E@w)' Qblk (Y -E@w) = .5*dU' Su_y' Qblk Su_y dU + (Sx_y x0 - E w)' Qblk Su_y dU + const
|
|
2303
|
+
|
|
2304
|
+
# The overall optimization vector is z = [du_0; ...; du_{T-1}, eps, lambda, w]
|
|
2305
|
+
# Cost function: .5*[[dU;eps]' H [dU;eps] + (c + F @ [x0;u(-1);w])' [U;eps] + const
|
|
2306
|
+
H = [block_diag(Su_y.T @ Qblk[i] @ Su_y + Rblk, np.zeros((N, N)))
|
|
2307
|
+
for i in range(N)] # [(T*nu+N x T*nu+N)]
|
|
2308
|
+
F = [np.vstack((np.hstack((Su_y.T @ Qblk[i] @ Sx_y, -Su_y.T @ Qblk[i] @ E)),
|
|
2309
|
+
np.zeros((N, nx+nu+ny)))) for i in range(N)] # [(N*nu+1 x (nx + nu + ny))]
|
|
2310
|
+
c = [np.hstack((np.zeros(T*nu), np.array(Qeps)))
|
|
2311
|
+
for _ in range(N)] # [(T*nu+N,)]
|
|
2312
|
+
|
|
2313
|
+
# Output constraint for k=1,...,Tc:
|
|
2314
|
+
# -> Ce*(Ae*[x(t);u(t-1)]+Be*delta_u(t) <= ymax
|
|
2315
|
+
# -> -(Ce*(Ae*[x(t);u(t-1)]+Be*delta_u(t))) <= -ymin
|
|
2316
|
+
|
|
2317
|
+
# Constraint matrices for all agents
|
|
2318
|
+
A_con = np.hstack(
|
|
2319
|
+
(np.vstack((Su_y[:Tc*ny], -Su_y[:Tc*ny])), -np.ones((Tc*ny*2, N))))
|
|
2320
|
+
# Constraint bounds for all agents
|
|
2321
|
+
b_con = np.hstack(
|
|
2322
|
+
(np.kron(np.ones(Tc), ymax), -np.kron(np.ones(Tc), ymin)))
|
|
2323
|
+
# Constraint matrix for [x(t);u(t-1)]
|
|
2324
|
+
B_con = np.vstack((-Sx_y[:Tc*ny], Sx_y[:Tc*ny]))
|
|
2325
|
+
|
|
2326
|
+
# Input increment constraints
|
|
2327
|
+
# lower bound for all agents
|
|
2328
|
+
lb = np.hstack((np.kron(np.ones(T), dumin), np.zeros(N)))
|
|
2329
|
+
# upper bound for all agents
|
|
2330
|
+
ub = np.hstack((np.kron(np.ones(T), dumax), np.inf*np.ones(N)))
|
|
2331
|
+
|
|
2332
|
+
# Bounds for input-increment constraints due to input constraints
|
|
2333
|
+
# u_k = u(t-1) + sum{j=0}^{k-1} du(j) <= umax -> sum{j=0}^{k-1} du(j) <= umax - u(t-1)
|
|
2334
|
+
# >= umin -> -sum{j=0}^{k-1} du(j) <= -umin + u(t-1)
|
|
2335
|
+
AI = np.kron(np.tril(np.ones((Tc, T))),
|
|
2336
|
+
np.eye(nu)) # (Tc*nu x T*nu)
|
|
2337
|
+
A_con = np.vstack((A_con,
|
|
2338
|
+
np.hstack((AI, np.zeros((Tc*nu, N)))),
|
|
2339
|
+
np.hstack((-AI, np.zeros((Tc*nu, N))))
|
|
2340
|
+
))
|
|
2341
|
+
b_con = np.hstack((b_con,
|
|
2342
|
+
np.kron(np.ones(Tc), umax),
|
|
2343
|
+
np.kron(np.ones(Tc), -umin)
|
|
2344
|
+
))
|
|
2345
|
+
B_con = np.vstack((B_con,
|
|
2346
|
+
np.hstack((
|
|
2347
|
+
np.zeros((2*Tc*nu, nx)),
|
|
2348
|
+
np.vstack((np.kron(np.ones((Tc, 1)), -np.eye(nu)),
|
|
2349
|
+
np.kron(
|
|
2350
|
+
np.ones((Tc, 1)), np.eye(nu))
|
|
2351
|
+
))
|
|
2352
|
+
))
|
|
2353
|
+
))
|
|
2354
|
+
|
|
2355
|
+
# Final QP problem: each agent i solves
|
|
2356
|
+
#
|
|
2357
|
+
# min_{du_sequence, eps1...epsN} .5*[du_sequence;eps1..epsN]' H[i] [du_sequence;eps1...epsN]
|
|
2358
|
+
# + (c + F[i] @ [x0;u(-1);ref])' [du_sequence;eps1...epsN]
|
|
2359
|
+
#
|
|
2360
|
+
# # s.t. A_con [du_sequence;eps1...epsN] <= b_con + B_con [x0;u(-1)]
|
|
2361
|
+
# lb <= [du_sequence;eps1...epsN] <= ub
|
|
2362
|
+
|
|
2363
|
+
return H, c, F, A_con, b_con, B_con, lb, ub
|
|
2364
|
+
|
|
2365
|
+
H, c, F, A_con, b_con, B_con, lb, ub = build_qp(
|
|
2366
|
+
A, B, C, Qy, Qdu, Qeps, sizes, N, T, ymin, ymax, umin, umax, dumin, dumax, self.Tc)
|
|
2367
|
+
|
|
2368
|
+
# Rearrange optimization variables to have all agents' variables together at each time step
|
|
2369
|
+
# Original z ordering:
|
|
2370
|
+
# [du_1(0); ...; du_N(0); du_1(1); ...; du_N(1); ...; du_1(T-1); ...; du_N(T-1); eps1; ...; epsN]
|
|
2371
|
+
# Desired z_new ordering:
|
|
2372
|
+
# [du_1(0); du_1(1); ...; du_1(T-1); eps1; du_2(0); ...; du_2(T-1); eps2; ...; du_N(0); ...; du_N(T-1); epsN]
|
|
2373
|
+
perm = []
|
|
2374
|
+
cum_sizes = np.cumsum([0] + list(sizes))
|
|
2375
|
+
for i in range(N):
|
|
2376
|
+
i_start = int(cum_sizes[i])
|
|
2377
|
+
i_end = int(cum_sizes[i + 1])
|
|
2378
|
+
# Collect all du_i(k) blocks across the horizon
|
|
2379
|
+
for k in range(T):
|
|
2380
|
+
koff = k * nu
|
|
2381
|
+
perm.extend(range(koff + i_start, koff + i_end))
|
|
2382
|
+
# Append eps_i (which is stored after all du's)
|
|
2383
|
+
perm.append(T * nu + i)
|
|
2384
|
+
|
|
2385
|
+
# P = np.eye(T*nu+N)[perm,:] # permutation matrix: z_new = P z -> z = P' z_new
|
|
2386
|
+
# .5 z' H z = .5 z_new' (P H P') z_new
|
|
2387
|
+
self.H = [Hi[perm, :][:, perm] for Hi in H] # same as P@Hi@P.T
|
|
2388
|
+
# (c + F @ p)' z = (c + F @ p)' P' z_new = (P (c + F @ p))' z_new
|
|
2389
|
+
self.c = [ci[perm] for ci in c] # same as P@ci
|
|
2390
|
+
self.F = [Fi[perm, :] for Fi in F] # same as P@Fi
|
|
2391
|
+
|
|
2392
|
+
# A_con @z = ... -> A_con P' @ z_new = ...
|
|
2393
|
+
iscon = np.isfinite(b_con)
|
|
2394
|
+
self.A_con = A_con[:, perm][iscon, :] # same as A_con@P.T
|
|
2395
|
+
self.b_con = b_con[iscon]
|
|
2396
|
+
self.B_con = B_con[iscon, :]
|
|
2397
|
+
|
|
2398
|
+
# z >= lb -> P' z_new >= lb -> z_new >= P lb
|
|
2399
|
+
self.lb = lb[perm]
|
|
2400
|
+
self.ub = ub[perm]
|
|
2401
|
+
# remove constraints beyond constraint horizon Tc
|
|
2402
|
+
off = 0
|
|
2403
|
+
Tc = self.Tc
|
|
2404
|
+
for i in range(N):
|
|
2405
|
+
si = sizes[i]
|
|
2406
|
+
# constraint eps_i>=0 is not removed
|
|
2407
|
+
self.lb[off+Tc*si:off+T*si] = -np.inf
|
|
2408
|
+
self.ub[off+Tc*si:off+T*si] = np.inf
|
|
2409
|
+
off += T*si + 1 # Each agent optimizes du_i(0)..du_i(T-1), eps_i
|
|
2410
|
+
self.iperm = np.argsort(perm) # inverse permutation
|
|
2411
|
+
|
|
2412
|
+
def solve(self, x0, u1, ref, M=1.e4, variational=False, centralized=False, solver='highs'):
|
|
2413
|
+
"""Solve game-theoretic linear MPC problem for a given reference via MILP.
|
|
2414
|
+
|
|
2415
|
+
Parameters
|
|
2416
|
+
----------
|
|
2417
|
+
x0 : ndarray
|
|
2418
|
+
Current state vector x(t).
|
|
2419
|
+
u1 : ndarray
|
|
2420
|
+
Previous input vector u(t-1).
|
|
2421
|
+
ref : ndarray
|
|
2422
|
+
Reference output vector r(t) to track.
|
|
2423
|
+
M : float, optional
|
|
2424
|
+
Big-M parameter for MILP formulation.
|
|
2425
|
+
variational : bool, optional
|
|
2426
|
+
If True, compute a variational equilibrium by adding the necessary equality constraints on the multipliers of the shared output constraints.
|
|
2427
|
+
centralized : bool, optional
|
|
2428
|
+
If True, solve a centralized MPC problem via QP instead of the game-theoretic one via MILP.
|
|
2429
|
+
solver : str, optional
|
|
2430
|
+
MILP solver to use ('highs' or 'gurobi').
|
|
2431
|
+
|
|
2432
|
+
Returns
|
|
2433
|
+
-------
|
|
2434
|
+
sol : SimpleNamespace
|
|
2435
|
+
Solution object with the following fields:
|
|
2436
|
+
- u : ndarray
|
|
2437
|
+
First input of the optimal sequence to apply to the system as input u(t).
|
|
2438
|
+
- U : ndarray
|
|
2439
|
+
Full input sequence over the prediction horizon.
|
|
2440
|
+
- eps : ndarray
|
|
2441
|
+
Optimal slack variables for soft output constraints.
|
|
2442
|
+
- elapsed_time : float
|
|
2443
|
+
Total elapsed time (build + solve) in seconds.
|
|
2444
|
+
- elapsed_time_solver : float
|
|
2445
|
+
Elapsed time for solver only in seconds.
|
|
2446
|
+
"""
|
|
2447
|
+
T = self.T
|
|
2448
|
+
# each agent's variable is [du_i(0); ...; du_i(T-1); eps_i]
|
|
2449
|
+
sizes = [si*T+1 for si in self.sizes]
|
|
2450
|
+
nu = self.nu
|
|
2451
|
+
if variational and centralized:
|
|
2452
|
+
print(
|
|
2453
|
+
"\033[1;31mWarning: variational equilibrium ignored in centralized MPC.\033[0m")
|
|
2454
|
+
|
|
2455
|
+
b = self.b_con + self.B_con @ np.hstack((x0, u1))
|
|
2456
|
+
c = [self.c[i] + self.F[i] @
|
|
2457
|
+
np.hstack((x0, u1, ref)) for i in range(self.N)]
|
|
2458
|
+
|
|
2459
|
+
t0 = time.time()
|
|
2460
|
+
if not centralized:
|
|
2461
|
+
# Set up and solve GNEP via MILP
|
|
2462
|
+
gnep = GNEP_LQ(sizes, self.H, c, F=None, lb=self.lb, ub=self.ub, pmin=None, pmax=None,
|
|
2463
|
+
A=self.A_con, b=b, S=None, D=None, E=None, h=None, M=M, variational=variational, solver=solver)
|
|
2464
|
+
else:
|
|
2465
|
+
# Centralized MPC: total cost = sum of all agents' costs, solve via QP
|
|
2466
|
+
H_cen = csc_matrix(sum(self.H[i] for i in range(self.N)))
|
|
2467
|
+
c_cen = sum(c[i] for i in range(self.N))
|
|
2468
|
+
nvar = c_cen.size
|
|
2469
|
+
A_cen = spvstack([csc_matrix(self.A_con), speye(
|
|
2470
|
+
nvar, format="csc")], format="csc")
|
|
2471
|
+
lb_cen = np.hstack((-np.inf*np.ones(self.A_con.shape[0]), self.lb))
|
|
2472
|
+
ub_cen = np.hstack((b, self.ub))
|
|
2473
|
+
|
|
2474
|
+
prob = osqp.OSQP()
|
|
2475
|
+
prob.setup(P=H_cen, q=c_cen, A=A_cen, l=lb_cen,
|
|
2476
|
+
u=ub_cen, verbose=False, polish=True, max_iter=10000, eps_abs=1.e-6, eps_rel=1.e-6, polish_refine_iter=3)
|
|
2477
|
+
elapsed_time_build = time.time() - t0
|
|
2478
|
+
|
|
2479
|
+
if not centralized:
|
|
2480
|
+
gnep_sol = gnep.solve()
|
|
2481
|
+
z = gnep_sol.x
|
|
2482
|
+
elapsed_time_solver = gnep_sol.elapsed_time
|
|
2483
|
+
else:
|
|
2484
|
+
# prob.update(q=c_cen, u=b) # We could speedup by storing prob and reusing previous factorizations
|
|
2485
|
+
res = prob.solve() # Solve QP problem
|
|
2486
|
+
z = res.x
|
|
2487
|
+
elapsed_time_solver = res.info.run_time
|
|
2488
|
+
|
|
2489
|
+
# permutation matrix: z_new = P z -> z = P' z_new
|
|
2490
|
+
zeps_seq = z[self.iperm] # rearranged optimization vector
|
|
2491
|
+
U = []
|
|
2492
|
+
uk = u1.copy()
|
|
2493
|
+
for k in range(T):
|
|
2494
|
+
uk = uk + zeps_seq[k*nu: (k+1)*nu]
|
|
2495
|
+
U.append(uk)
|
|
2496
|
+
|
|
2497
|
+
sol = SimpleNamespace()
|
|
2498
|
+
sol.u = U[0] # first input to apply
|
|
2499
|
+
sol.U = np.array(U) # full input sequence
|
|
2500
|
+
# optimal slack variables for soft output constraints
|
|
2501
|
+
sol.eps = zeps_seq[-self.N:]
|
|
2502
|
+
sol.elapsed_time = elapsed_time_build + elapsed_time_solver
|
|
2503
|
+
sol.elapsed_time_solver = elapsed_time_solver
|
|
2504
|
+
return sol
|