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.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