PyDiffGame 0.1.2__py3-none-any.whl → 2.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.
PyDiffGame/PyDiffGame.py DELETED
@@ -1,1273 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- from pathlib import Path
5
- from tqdm import tqdm
6
- import numpy as np
7
- import math
8
- import matplotlib.pyplot as plt
9
- import scipy as sp
10
- import sympy
11
- import warnings
12
- from typing import Callable, Final, ClassVar, Optional, Sequence, Union
13
- from abc import ABC, abstractmethod
14
-
15
- from PyDiffGame.Objective import Objective, GameObjective, LQRObjective
16
-
17
-
18
- class PyDiffGame(ABC, Callable, Sequence):
19
- """
20
- Differential game abstract base class
21
-
22
-
23
- Parameters
24
- ----------
25
-
26
- A: np.array of shape(n, n)
27
- System dynamics matrix
28
- B: np.array of shape(n, sum_{i=}1^N m_i), optional
29
- Input matrix for all virtual control objectives
30
- Bs: Sequence of np.arrays of len(N), each array B_i of shape(n, m_i), optional
31
- Input matrices for each virtual control objective
32
- Qs: Sequence of np.arrays of len(N), each array Q_i of shape(n, n), optional
33
- State weight matrices for each virtual control objective
34
- Rs: Sequence of np.arrays of len(N), each array R_i of shape(m_i, m_i), optional
35
- Input weight matrices for each virtual control objective
36
- Ms: Sequence of np.arrays of len(N), each array M_i of shape(m_i, m), optional
37
- Decomposition matrices for each virtual control objective
38
- objectives: Sequence of Objective objects of len(N), optional
39
- Desired objectives for the game, each O_i specifying Q_i R_i and M_i
40
- x_0: np.array of shape(n), optional
41
- Initial state vector
42
- x_T: np.array of shape(n), optional
43
- Final state vector, in case of signal tracking
44
- T_f: positive float, optional
45
- System dynamics horizon. Should be given in the case of finite horizon
46
- P_f: Sequence of np.arrays of len(N), each array P_f_i of shape(n, n), optional
47
- default = uncoupled solution of scipy's solve_are
48
- Final condition for the Riccati equation array. Should be given in the case of finite horizon
49
- show_legend: bool, optional
50
- Indicates whether to display a legend in the plots
51
- state_variables_names: Sequence of str objects of len(n), optional
52
- The state variables' names to display when plotting
53
- epsilon_x: float in the interval (0,1), optional
54
- Numerical convergence threshold for the state vector of the system
55
- epsilon_P: float in the interval (0,1), optional
56
- Numerical convergence threshold for the matrices P_i
57
- L: positive int, optional
58
- Number of data points
59
- eta: positive int, optional
60
- The number of last matrix norms to consider for convergence
61
- debug: bool, optional
62
- Indicates whether to display debug information
63
- """
64
-
65
- # class fields
66
- __T_f_default: Final[ClassVar[int]] = 20
67
- _epsilon_x_default: Final[ClassVar[float]] = 10e-7
68
- _epsilon_P_default: Final[ClassVar[float]] = 10e-7
69
- _eigvals_tol: Final[ClassVar[float]] = 10e-7
70
- _L_default: Final[ClassVar[int]] = 1000
71
- _eta_default: Final[ClassVar[int]] = 3
72
- _g: Final[ClassVar[float]] = 9.81
73
- __x_max_convergence_iterations: Final[ClassVar[int]] = 25
74
- __p_max_convergence_iterations: Final[ClassVar[int]] = 25
75
-
76
- _default_figures_path = Path(fr'{os.getcwd()}/figures')
77
- _default_figures_filename = 'image'
78
-
79
- @classmethod
80
- @property
81
- def epsilon_x_default(cls) -> float:
82
- return cls._epsilon_x_default
83
-
84
- @classmethod
85
- @property
86
- def epsilon_P_default(cls) -> float:
87
- return cls._epsilon_P_default
88
-
89
- @classmethod
90
- @property
91
- def L_default(cls) -> int:
92
- return cls._L_default
93
-
94
- @classmethod
95
- @property
96
- def eta_default(cls) -> float:
97
- return cls._eta_default
98
-
99
- @classmethod
100
- @property
101
- def g(cls) -> float:
102
- return cls._g
103
-
104
- @classmethod
105
- @property
106
- def default_figures_path(cls) -> Path:
107
- return cls._default_figures_path
108
-
109
- @classmethod
110
- @property
111
- def default_figures_filename(cls) -> str:
112
- return cls._default_figures_filename
113
-
114
- def __init__(self,
115
- A: np.array,
116
- B: Optional[np.array] = None,
117
- Bs: Optional[Sequence[np.array]] = None,
118
- Qs: Optional[Sequence[np.array]] = None,
119
- Rs: Optional[Sequence[np.array]] = None,
120
- Ms: Optional[Sequence[np.array]] = None,
121
- objectives: Optional[Sequence[Objective]] = None,
122
- x_0: Optional[np.array] = None,
123
- x_T: Optional[np.array] = None,
124
- T_f: Optional[float] = None,
125
- P_f: Optional[Union[Sequence[np.array], np.array]] = None,
126
- show_legend: Optional[bool] = True,
127
- state_variables_names: Optional[Sequence[str]] = None,
128
- epsilon_x: Optional[float] = _epsilon_x_default,
129
- epsilon_P: Optional[float] = _epsilon_P_default,
130
- L: Optional[int] = _L_default,
131
- eta: Optional[int] = _eta_default,
132
- debug: Optional[bool] = False):
133
-
134
- self.__continuous = 'ContinuousPyDiffGame' in [c.__name__ for c in
135
- set(type(self).__bases__).union({self.__class__})]
136
- self._A = A
137
- self._B = B
138
- self._Bs = Bs
139
- self._n = self._A.shape[0]
140
- self._m = self._B.shape[1] if self._B is not None else self._Bs[0].shape[1]
141
- self.__objectives = objectives
142
- self._N = len(objectives) if objectives else len(Qs)
143
- self._P_size = self._n ** 2
144
- self._Qs = Qs if Qs else [o.Q for o in objectives]
145
- self._Rs = Rs if Rs else [o.R for o in objectives]
146
- self._x_0 = x_0
147
- self._x_T = x_T
148
- self.__infinite_horizon = T_f is None
149
- self._T_f = self.__T_f_default if T_f is None else T_f
150
-
151
- self._Ms = Ms
152
-
153
- if self._Ms is None and self.__objectives is not None:
154
- self._Ms = [o.M_i for o in self.__objectives if isinstance(o, GameObjective)]
155
-
156
- self._M = None
157
-
158
- if self._Bs is None:
159
- if self._Ms is not None and len(self._Ms):
160
- self._M = np.concatenate(self._Ms, axis=0)
161
- try:
162
- self._M_inv = np.linalg.inv(self._M)
163
- except np.linalg.LinAlgError:
164
- self._M_inv = np.linalg.pinv(self._M)
165
- l = 0
166
- self._Bs = []
167
-
168
- for M_i in self._Ms:
169
- m_i = M_i.shape[0]
170
- M_i_tilde = self._M_inv[:, l:l + m_i]
171
- BM_i_tilde = self._B @ M_i_tilde
172
- B_i = BM_i_tilde.reshape((self._n, m_i))
173
- self._Bs += [B_i]
174
- l += m_i
175
- else:
176
- self._Bs = [B]
177
-
178
- self._P_f = self.__get_are_P_f() if P_f is None else P_f
179
- # self._P_f = [np.eye(self._n)] * self._N if P_f is None else P_f
180
- self._L = L
181
- self._delta = self._T_f / self._L
182
- self.__show_legend = show_legend
183
- self.__state_variables_names = state_variables_names
184
- self._forward_time = np.linspace(start=0,
185
- stop=self._T_f,
186
- num=self._L)
187
- self._backward_time = self._forward_time[::-1]
188
- self._A_cl = np.empty_like(self._A)
189
- self._P = []
190
- self._K = []
191
- self.__epsilon_x = epsilon_x
192
- self.__epsilon_P = epsilon_P
193
- self._x = self._x_0
194
- self._x_non_linear = None
195
- self.__eta = eta
196
- self._converged = False
197
- self._debug = debug
198
- self._fig = None
199
-
200
- self._verify_input()
201
-
202
- def _verify_input(self):
203
- """
204
- Input checking method
205
-
206
- Raises
207
- ------
208
-
209
- ValueError
210
- If any of the class parameters don't meet the specified requirements
211
- """
212
-
213
- if self._N == 0:
214
- raise ValueError('At least one objective must be specified')
215
- if self._N > 1 and self._Ms and not all([M_i.shape[0] == R_ii.shape[0] == R_ii.shape[1]
216
- if R_ii.ndim > 1 else M_i.shape[0] == R_ii.shape[0]
217
- for M_i, R_ii in zip(self._Ms, self._Rs)]):
218
- raise ValueError('R must be a Sequence of square numpy arrays with shape '
219
- 'corresponding to the second dimensions of the arrays in M')
220
-
221
- if not 1 > self.__epsilon_x > 0:
222
- raise ValueError('The state convergence tolerance must be in the open interval (0,1)')
223
- if not 1 > self.__epsilon_P > 0:
224
- raise ValueError('The convergence tolerance of the matrix P must be in the open interval (0,1)')
225
-
226
- if self._A.shape != (self._n, self._n):
227
- raise ValueError(f'A must be a square numpy array with shape nxn = {self._n}x{self._n}')
228
- if self._B is not None:
229
- if self._B.shape[0] != self._n:
230
- raise ValueError(f'B must be a numpy array with n = {self._n} rows')
231
- if not self.is_fully_controllable():
232
- warnings.warn("Warning: The given system is not fully controllable")
233
- else:
234
- if not all([B_i.shape[0] == self._n and B_i.shape[1] == R_ii.shape[0] == R_ii.shape[1]
235
- if R_ii.ndim > 1 else B_i.shape[1] == R_ii.shape[0] for B_i, R_ii in zip(self._Bs, self._Rs)]):
236
- raise ValueError('R must be a Sequence of square numpy arrays with shape '
237
- 'corresponding to the second dimensions of the arrays in Bs')
238
- if not all([np.all(np.linalg.eigvals(R_ii) > 0) if R_ii.ndim > 1 else R_ii > 0
239
- for R_ii in self._Rs]):
240
- raise ValueError('R must be a Sequence of square positive definite numpy arrays')
241
- if not all([Q_i.shape == (self._n, self._n) for Q_i in self._Qs]):
242
- raise ValueError(f'Q must be a Sequence of square positive semi-definite numpy arrays with shape nxn '
243
- f'= {self._n}x{self._n}')
244
- if not all([all([e_i >= 0 or PyDiffGame._eigvals_tol > abs(e_i) >= 0 for e_i in np.linalg.eigvals(Q_i)])
245
- for Q_i in self._Qs]):
246
- raise ValueError('Q must contain positive semi-definite numpy arrays')
247
-
248
- if self._x_0 is not None and self._x_0.shape != (self._n,):
249
- raise ValueError(f'x_0 must be a numpy array with length n = {self._n}')
250
- if self._x_T is not None and self._x_T.shape != (self._n,):
251
- raise ValueError(f'x_T must be a numpy array with length n= {self._n}')
252
- if self._T_f is not None and self._T_f <= 0:
253
- raise ValueError('T_f must be a positive real number')
254
- if not all([P_f_i.shape[0] == P_f_i.shape[1] == self._n for P_f_i in self._P_f]):
255
- raise ValueError(f'P_f must be a Sequence of positive semi-definite numpy arrays with shape nxn = '
256
- f'{self._n}x{self._n}')
257
- # if not all([eig >= 0 for eig_set in [np.linalg.eigvals(P_f_i) for P_f_i in self._P_f] for eig in eig_set]):
258
- # warnings.warn("Warning: there is a matrix in P_f that has negative eigenvalues.
259
- # Convergence may not occur")
260
- if self.__state_variables_names and len(self.__state_variables_names) != self._n:
261
- raise ValueError(f'The parameter state_variables_names must be of length n = {self._n}')
262
-
263
- if self._L <= 0:
264
- raise ValueError('The number of data points must be a positive integer')
265
- if self.__eta <= 0:
266
- raise ValueError('The number of last matrix norms to consider for convergence must be a positive integer')
267
-
268
- def is_fully_controllable(self) -> bool:
269
- """
270
- Tests full controllability of an LTI system by the equivalent condition of full rank
271
- of the controllability Gramian
272
-
273
- Returns
274
- -------
275
-
276
- fully_controllable : bool
277
- Whether the system is fully controllable
278
- """
279
-
280
- controllability_matrix = np.concatenate([self._B] +
281
- [np.linalg.matrix_power(self._A, i) @ self._B
282
- for i in range(1, self._n)],
283
- axis=1)
284
- controllability_matrix_rank = np.linalg.matrix_rank(A=controllability_matrix,
285
- tol=10e-5)
286
- fully_controllable = controllability_matrix_rank == self._n
287
-
288
- return fully_controllable
289
-
290
- def is_LQR(self) -> bool:
291
- """
292
- Indicates whether the game has only one objective and is just a regular LQR
293
-
294
- Returns
295
- -------
296
-
297
- is_lqr : bool
298
- Whether the system is LQR
299
- """
300
-
301
- return len(self) == 1
302
-
303
- def __print_eigenvalues(self):
304
- """
305
- Prints the eigenvalues of the matrices Q_i and R_ii for all 1 <= i <= N
306
- with the greatest common divisor of their elements d as a parameter
307
- """
308
-
309
- d = sympy.symbols('d')
310
-
311
- for matrix in self._Rs + self._Qs:
312
- curr_gcd = math.gcd(*matrix.ravel())
313
- sympy_matrix = sympy.Matrix((matrix / curr_gcd).astype(int)) * d
314
- eigenvalues_multiset = sympy_matrix.eigenvals(multiple=True)
315
- print(f"{repr(sympy_matrix)}: {eigenvalues_multiset}\n")
316
-
317
- def __print_characteristic_polynomials(self):
318
- """
319
- Prints the characteristic polynomials of the matrices Q_i and R_ii for all 1 <= i <= N
320
- as a function of L and with the greatest common divisor of all their elements d as a parameter
321
- """
322
-
323
- d = sympy.symbols('d')
324
- L = sympy.symbols('L')
325
-
326
- for matrix in self._Rs + self._Qs:
327
- curr_gcd = math.gcd(*matrix.ravel())
328
- sympy_matrix = sympy.Matrix((matrix / curr_gcd).astype(int)) * d
329
- characteristic_polynomial = sympy_matrix.charpoly(x=L).as_expr()
330
- factored_characteristic_polynomial_expression = sympy.factor(f=characteristic_polynomial)
331
- print(f"{repr(sympy_matrix)}: {factored_characteristic_polynomial_expression}\n")
332
-
333
- def _converge_DREs_to_AREs(self):
334
- """
335
- Solves the game as backwards convergence of the differential
336
- finite-horizon game for repeated consecutive steps until the matrix norm converges
337
- """
338
-
339
- x_converged = False
340
- P_converged = False
341
- last_eta_norms = []
342
- curr_iteration_T_f = self._T_f
343
- x_T = self._x_T if self._x_T is not None else np.zeros_like(self._x_0)
344
- x_convergence_iterations = 0
345
-
346
- with tqdm(total=self.__x_max_convergence_iterations) as x_progress_bar:
347
- while (not x_converged) and x_convergence_iterations < self.__x_max_convergence_iterations:
348
- x_convergence_iterations += 1
349
- P_convergence_iterations = 0
350
-
351
- while (not P_converged) and P_convergence_iterations < self.__p_max_convergence_iterations:
352
- P_convergence_iterations += 1
353
- self._backward_time = np.linspace(start=self._T_f,
354
- stop=self._T_f - self._delta,
355
- num=self._L)
356
- self._update_Ps_from_last_state()
357
- self._P_f = self._P[-1]
358
- last_eta_norms += [np.linalg.norm(self._P_f)]
359
-
360
- if len(last_eta_norms) > self.__eta:
361
- last_eta_norms.pop(0)
362
-
363
- if len(last_eta_norms) == self.__eta:
364
- P_converged = all([abs(norm_i - norm_i1) < self.__epsilon_P for norm_i, norm_i1
365
- in zip(last_eta_norms, last_eta_norms[1:])])
366
- self._T_f -= self._delta
367
-
368
- self._T_f = curr_iteration_T_f
369
-
370
- if self._x_0 is not None:
371
- curr_x_T = self._simulate_curr_x_T()
372
- x_T_difference = x_T - curr_x_T
373
- x_T_difference_norm = x_T_difference.T @ x_T_difference
374
- curr_x_T_norm = curr_x_T.T @ curr_x_T
375
-
376
- if curr_x_T_norm > 0:
377
- convergence_ratio = x_T_difference_norm / curr_x_T_norm
378
- x_converged = convergence_ratio < self.__epsilon_x
379
-
380
- curr_iteration_T_f += 1
381
- self._T_f = curr_iteration_T_f
382
- self._forward_time = np.linspace(start=0,
383
- stop=self._T_f,
384
- num=self._L)
385
- else:
386
- x_converged = True
387
-
388
- x_progress_bar.update(1)
389
-
390
- def _post_convergence(f: Callable) -> Callable:
391
- """
392
- A decorator static-method to apply on methods that need only be called after convergence
393
-
394
- Parameters
395
- ----------
396
-
397
- f: Callable
398
- The method requiring convergence
399
-
400
- Returns
401
- ----------
402
-
403
- decorated_f: Callable
404
- A decorated version of the method requiring convergence
405
- """
406
-
407
- def decorated_f(self: PyDiffGame,
408
- *args,
409
- **kwargs):
410
- if not self._converged:
411
- raise RuntimeError('Must first simulate the differential game')
412
- return f(self,
413
- *args,
414
- **kwargs)
415
-
416
- return decorated_f
417
-
418
- @_post_convergence
419
- def __plot_temporal_variables(self,
420
- t: np.array,
421
- temporal_variables: np.array,
422
- is_P: bool,
423
- title: Optional[str] = None,
424
- output_variables_names: Optional[Sequence[str]] = None,
425
- two_state_spaces: Optional[bool] = False):
426
- """
427
- Displays plots for the state variables with respect to time and the convergence of the values of P
428
-
429
- Parameters
430
- ----------
431
-
432
- t: np.array array of len(L)
433
- The time axis information to plot
434
- temporal_variables: np.arrays of shape(L, n)
435
- The y-axis information to plot
436
- is_P: bool
437
- Indicates whether to accommodate plotting for all the values in P_i or just for x
438
- title: str, optional
439
- The plot title to display
440
- two_state_spaces: bool, optional
441
- Indicates whether to plot two state spaces
442
- """
443
-
444
- self._fig = plt.figure(dpi=150)
445
- self._fig.set_size_inches(8, 6)
446
- plt.plot(t[:temporal_variables.shape[0]], temporal_variables)
447
- plt.xlabel('Time $[s]$', fontsize=18)
448
- plt.xticks(fontsize=18)
449
- plt.yticks(fontsize=18)
450
- plt.subplots_adjust(wspace=0)
451
-
452
- if not is_P and max(np.max(temporal_variables, axis=0)) > 1e3:
453
- plt.ticklabel_format(style='sci', axis='y', scilimits=(0, 0))
454
- plt.gca().yaxis.get_offset_text().set_size(18)
455
-
456
-
457
- if title:
458
- plt.title(title)
459
-
460
- if self.__show_legend:
461
- if is_P:
462
- labels = ['${P' + str(i) + '}_{' + str(j) + str(k) + '}$' for i in range(1, self._N + 1)
463
- for j in range(1, self._n + 1) for k in range(1, self._n + 1)]
464
- elif output_variables_names is not None:
465
- labels = output_variables_names
466
- elif self.__state_variables_names is not None:
467
- if two_state_spaces:
468
- labels = [f'${name}_{1}$' for name in self.__state_variables_names] + \
469
- [f'${name}_{2}$' for name in self.__state_variables_names]
470
- else:
471
- labels = [f'${name}$' for name in self.__state_variables_names]
472
- else:
473
- labels = ["${\mathbf{x}}_{" + str(j) + "}$" for j in range(1, self._n + 1)]
474
-
475
- plt.legend(labels=labels,
476
- loc='upper left' if is_P else 'right',
477
- ncol=4 if self._n > 8 else 2,
478
- prop={'size': 26},
479
- bbox_to_anchor=(1, 0.55),
480
- framealpha=0.3
481
- )
482
-
483
- plt.grid()
484
- plt.show()
485
-
486
- def __get_temporal_state_variables_title(self,
487
- linear_system: Optional[bool] = True,
488
- two_state_spaces: Optional[bool] = False) -> str:
489
- """
490
- Returns the title of a temporal figure to plot
491
-
492
- Parameters
493
- ----------
494
-
495
- linear_system: bool, optional
496
- Whether the system is linear
497
- two_state_spaces: bool, optional
498
- Whether the plot is of two state spaces
499
-
500
- Returns
501
- -------
502
-
503
- title : str
504
- The title of the figure
505
- """
506
-
507
- if two_state_spaces:
508
- title = f"{self._N}-Player Game"
509
- else:
510
- title = f"{('' if linear_system else 'Nonlinear, ')}" \
511
- f"{('Continuous' if self.__continuous else 'Discrete')}, " \
512
- f"{('Infinite' if self.__infinite_horizon else 'Finite')} Horizon" \
513
- f"{(f', {self._N}-Player Game' if self._N > 1 else ' LQR')}" \
514
- f"{f', {self._L} Sampling Points'}"
515
-
516
- return title
517
-
518
- def plot_state_variables(self,
519
- state_variables: np.array,
520
- linear_system: Optional[bool] = True,
521
- output_variables_names: Optional[Sequence[str]] = None, ):
522
- """
523
- Displays plots for the state variables with respect to time
524
-
525
- Parameters
526
- ----------
527
-
528
- state_variables: np.array of shape(L, n)
529
- The temporal state variables y-axis information to plot
530
- linear_system: bool, optional
531
- Indicates whether the system is linear or not
532
- output_variables_names: optional
533
- Names of the output variables
534
- """
535
-
536
- self.__plot_temporal_variables(t=self._forward_time,
537
- temporal_variables=state_variables,
538
- is_P=False,
539
- # title=self.__get_temporal_state_variables_title(linear_system=linear_system),
540
- output_variables_names=output_variables_names)
541
-
542
- def __plot_x(self):
543
- """
544
- Plots the state vector variables wth respect to time
545
- """
546
-
547
- self.plot_state_variables(state_variables=self._x)
548
-
549
- def plot_y(self,
550
- C: np.array,
551
- output_variables_names: Optional[Sequence[str]] = None,
552
- save_figure: Optional[bool] = False,
553
- figure_path: Optional[Union[str, Path]] = _default_figures_filename,
554
- figure_filename: Optional[str] = _default_figures_filename):
555
- """
556
- Plots an output vector y = C x^T wth respect to time
557
-
558
- Parameters
559
- ----------
560
-
561
- C: np.array array of shape(p, n)
562
- The output coefficients with respect to state
563
- output_variables_names: Sequence of len(p)
564
- Names of output variables
565
- save_figure: bool
566
- Whether to save the figure or not
567
- figure_path: str or Path, optional, default = figures sub-folder of the current directory
568
- The desired saved figure's file path
569
- figure_filename: str, optional
570
- The desired saved figure's filename
571
- """
572
-
573
- y = C @ self._x.T
574
- self.plot_state_variables(state_variables=y.T,
575
- output_variables_names=output_variables_names)
576
- if save_figure:
577
- self.__save_figure(figure_path=figure_path,
578
- figure_filename=figure_filename)
579
-
580
- @_post_convergence
581
- def __save_figure(self,
582
- figure_path: Optional[Union[str, Path]] = _default_figures_filename,
583
- figure_filename: Optional[str] = _default_figures_filename):
584
- """
585
- Saves the current figure
586
-
587
- Parameters
588
- ----------
589
-
590
- figure_path: str or Path, optional, default = figures sub-folder of the current directory
591
- The desired saved figure's file path
592
- figure_filename: str, optional
593
- The desired saved figure's filename
594
- """
595
-
596
- if self._x_0 is None:
597
- raise RuntimeError('No parameter x_0 was defined to illustrate a figure')
598
-
599
- if figure_filename == PyDiffGame._default_figures_filename:
600
- figure_filename += '0'
601
-
602
- if not figure_path.is_dir():
603
- os.mkdir(figure_path)
604
-
605
- figure_full_filename = Path(fr'{figure_path}/{figure_filename}.png')
606
-
607
- while figure_full_filename.is_file():
608
- curr_name = figure_full_filename.name
609
- curr_num = int(curr_name.split('.png')[0][-1])
610
- next_num = curr_num + 1
611
- figure_full_filename = Path(fr'{figure_path}/image{next_num}.png')
612
-
613
- self._fig.savefig(figure_full_filename)
614
-
615
- def __augment_two_state_space_vectors(self,
616
- other: PyDiffGame,
617
- non_linear: bool = False) -> (np.array, np.array):
618
- """
619
- Plots the state variables of two converged state spaces
620
-
621
- Parameters
622
- ----------
623
-
624
- other: PyDiffGame
625
- Other converged differential game to augment the current one with
626
- non_linear: bool, optional
627
- Indicates whether to augment the non-linear states or not
628
-
629
- Returns
630
- ----------
631
-
632
- longer_period: np.array of len(max(self.L, other.L))
633
- The longer time period of the two games
634
- augmented_state_space: np.array of shape(max(self.L, other.L), self.n + other.n)
635
- Augmented state space with interpolated and extrapolated values for both games
636
- """
637
-
638
- f_self = sp.interpolate.interp1d(x=self._forward_time,
639
- y=self._x if not non_linear else self._x_non_linear.T,
640
- axis=0,
641
- fill_value="extrapolate")
642
-
643
- f_other = sp.interpolate.interp1d(x=other.forward_time,
644
- y=other.x if not non_linear else other._x_non_linear.T,
645
- axis=0,
646
- fill_value="extrapolate")
647
-
648
- longer_period = self._forward_time if max(self._forward_time) > max(other.forward_time) \
649
- else other.forward_time
650
-
651
- self_state_space = f_self(longer_period)
652
- other_state_space = f_other(longer_period)
653
-
654
- augmented_state_space = np.concatenate((self_state_space, other_state_space), axis=1)
655
-
656
- return longer_period, augmented_state_space
657
-
658
- def plot_two_state_spaces(self,
659
- other: PyDiffGame,
660
- non_linear: bool = False):
661
- """
662
- Plots the state variables of two converged state spaces
663
-
664
- Parameters
665
- ----------
666
-
667
- other: PyDiffGame
668
- Other converged differential game to plot along with the current one
669
- non_linear: bool, optional
670
- Indicates whether to plot the non-linear states or not
671
- """
672
-
673
- longer_period, augmented_state_space = self.__augment_two_state_space_vectors(other=other,
674
- non_linear=non_linear)
675
-
676
- self_title = self.__get_temporal_state_variables_title(two_state_spaces=True)
677
- other_title = other.__get_temporal_state_variables_title(two_state_spaces=True)
678
-
679
- self.__plot_temporal_variables(t=longer_period,
680
- temporal_variables=augmented_state_space,
681
- is_P=False,
682
- title=f"{self_title} " + "$(T_{f_1} = " +
683
- f"{self._T_f})$ \nvs\n {other_title} " + "$(T_{f_2} = " + f"{other.T_f})$",
684
- two_state_spaces=True
685
- )
686
-
687
- def __get_are_P_f(self) -> list[np.array]:
688
- """
689
- Solves the uncoupled set of algebraic Riccati equations to use as initial guesses for fsolve and odeint
690
-
691
- Returns
692
- ----------
693
-
694
- P_f: list of numpy arrays, of len(N), of shape(n, n), solution of scipy's solve_are
695
- Final condition for the Riccati equation matrix
696
- """
697
-
698
- are_solver = sp.linalg.solve_continuous_are if self.__continuous else sp.linalg.solve_discrete_are
699
- P_f = []
700
-
701
- for B_i, Q_i, R_ii in zip(self._Bs, self._Qs, self._Rs):
702
- try:
703
- P_f_i = are_solver(self._A, B_i, Q_i, R_ii)
704
- except (np.linalg.LinAlgError, ValueError):
705
- P_f_i = Q_i
706
-
707
- P_f += [P_f_i]
708
-
709
- return P_f
710
-
711
- @abstractmethod
712
- def _update_K_from_last_state(self,
713
- *args,
714
- **kwargs):
715
- """
716
- Updates the controllers K after forward propagation of the state through time
717
- """
718
-
719
- pass
720
-
721
- @abstractmethod
722
- def _get_K_i(self,
723
- *args,
724
- **kwargs) -> np.array:
725
- """
726
- Returns the i'th element of the currently calculated controllers
727
-
728
- Returns
729
- ----------
730
-
731
- K_i: numpy array of numpy arrays, of len(N), of shape(n, n), solution of scipy's solve_are
732
- Final condition for the Riccati equation matrix
733
- """
734
-
735
- pass
736
-
737
- @abstractmethod
738
- def _get_P_f_i(self,
739
- i: int) -> np.array:
740
- """
741
- Returns the i'th element of the final condition matrices P_f
742
-
743
- Parameters
744
- ----------
745
-
746
- i: int
747
- The required index
748
-
749
- Returns
750
- ----------
751
-
752
- P_f_i: numpy array of shape(n, n)
753
- i'th element of the final condition matrices P_f
754
- """
755
-
756
- pass
757
-
758
- def _update_A_cl_from_last_state(self,
759
- k: Optional[int] = None):
760
- """
761
- Updates the closed-loop dynamics with the updated controllers based on the relation:
762
- A_cl = A - sum_{i=1}^N B_i K_i
763
-
764
- Parameters
765
- ----------
766
-
767
- k: int, optional
768
- The current k'th sample index, in case the controller is time-dependant.
769
- In this case the update rule is:
770
- A_cl[k] = A - sum_{i=1}^N B_i K_i[k]
771
- """
772
-
773
- A_cl = self._A - sum([self._Bs[i] @ (self._get_K_i(i, k) if k is not None else self._get_K_i(i))
774
- for i in range(self._N)])
775
- if k is not None:
776
- self._A_cl[k] = A_cl
777
- else:
778
- self._A_cl = A_cl
779
-
780
- @abstractmethod
781
- def _update_Ps_from_last_state(self,
782
- *args,
783
- **kwargs):
784
- """
785
- Updates the matrices {P_i}_{i=1}^N after forward propagation of the state through time
786
- """
787
-
788
- pass
789
-
790
- @abstractmethod
791
- def is_A_cl_stable(self,
792
- *args,
793
- **kwargs) -> bool:
794
- """
795
- Tests Lyapunov stability of the closed loop
796
-
797
- Returns
798
- ----------
799
-
800
- is_stable: bool
801
- Indicates whether the system has Lyapunov stability or not
802
- """
803
-
804
- pass
805
-
806
- @abstractmethod
807
- def _solve_finite_horizon(self):
808
- """
809
- Solves for the finite horizon case
810
- """
811
-
812
- pass
813
-
814
- @_post_convergence
815
- def __plot_finite_horizon_convergence(self):
816
- """
817
- Plots the convergence of the values for the matrices P_i
818
- """
819
-
820
- self.__plot_temporal_variables(t=self._backward_time,
821
- temporal_variables=self._P,
822
- is_P=True)
823
-
824
- def __solve_and_plot_finite_horizon(self):
825
- """
826
- Solves for the finite horizon case and plots the convergence of the values for the matrices P_i
827
- """
828
-
829
- self._solve_finite_horizon()
830
- self.__plot_finite_horizon_convergence()
831
-
832
- @abstractmethod
833
- def _solve_infinite_horizon(self):
834
- """
835
- Solves for the infinite horizon case using the finite horizon case
836
- """
837
-
838
- pass
839
-
840
- @abstractmethod
841
- @_post_convergence
842
- def _solve_state_space(self):
843
- """
844
- Propagates the game through time and solves for it
845
- """
846
-
847
- pass
848
-
849
- def __solve_game(self):
850
- if self.__infinite_horizon:
851
- self._solve_infinite_horizon()
852
- else:
853
- self.__solve_and_plot_finite_horizon()
854
-
855
- self._converged = True
856
-
857
- def __solve_game_and_simulate_state_space(self):
858
- """
859
- Propagates the game through time, solves for it and plots the state with respect to time
860
- """
861
-
862
- self.__solve_game()
863
-
864
- if self._x_0 is not None:
865
- self._solve_state_space()
866
-
867
- def __solve_game_simulate_state_space_and_plot(self,
868
- plot_Mx: Optional[bool] = False,
869
- M: Optional[np.array] = None,
870
- output_variables_names: Optional[Sequence[str]] = None):
871
- """
872
- Propagates the game through time, solves for it and plots the state with respect to time
873
- """
874
-
875
- self.__solve_game_and_simulate_state_space()
876
-
877
- if self._x_0 is not None:
878
- self.__plot_x()
879
-
880
- if plot_Mx and self._n % self._m == 0:
881
- C = np.kron(a=np.eye(int(self._n / self._m)),
882
- b=M)
883
- self.plot_y(C=C,
884
- output_variables_names=output_variables_names)
885
-
886
- def _simulate_curr_x_T(self) -> np.array:
887
- """
888
- Evaluates the current value of the state space vector at the terminal time T_f
889
-
890
- Returns
891
- ----------
892
-
893
- x_T_f: numpy array of shape(n)
894
- Evaluated terminal state vector x(T_f)
895
- """
896
-
897
- pass
898
-
899
- @_post_convergence
900
- def __get_x_l_tilde(self,
901
- x: np.array,
902
- l: int) -> np.array:
903
- """
904
- Returns the state difference at the lth segment
905
-
906
- Parameters
907
- ----------
908
-
909
- x: numpy array of shape(n)
910
- The solved state vector to consider
911
- l: int
912
- The desired segment
913
-
914
- Returns
915
- ----------
916
-
917
- x_l_tilde: numpy array of shape(n)
918
- State difference at the lth segment
919
- """
920
-
921
- x_l = x[l]
922
- x_l_tilde = self.x_T - x_l
923
-
924
- return x_l_tilde
925
-
926
- @_post_convergence
927
- def __get_v_i_l_tilde(self,
928
- x: np.array,
929
- i: int,
930
- l: int,
931
- v_i_T: np.array) -> np.array:
932
- """
933
- Returns the ith virtual input difference at the lth segment
934
-
935
- Parameters
936
- ----------
937
-
938
- x: numpy array of shape(n)
939
- The solved state vector to consider
940
- i: int
941
- The desired input index
942
- l: int
943
- The desired segment
944
- v_i_T: numpy array of shape(m_i)
945
- The terminal value of the ith input
946
-
947
- Returns
948
- ----------
949
-
950
- v_i_l_tilde: numpy array of shape(m_i)
951
- ith virtual input difference at the lth segment
952
- """
953
-
954
- x_l = x[l]
955
-
956
- K_i_l = self._get_K_i(i) if self.__continuous else self._get_K_i(i, l)
957
- v_i_l = - K_i_l @ x_l
958
- v_i_l_tilde = v_i_T - v_i_l
959
-
960
- return v_i_l_tilde
961
-
962
- @_post_convergence
963
- def __get_u_l_tilde(self,
964
- x: np.array,
965
- l: int,
966
- x_l_tilde: np.array) -> np.array:
967
- """
968
- Returns the actual input difference at the lth segment
969
-
970
- Parameters
971
- ----------
972
-
973
- x: numpy array of shape(n)
974
- The solved state vector to consider
975
- l: int
976
- The desired segment
977
- x_l_tilde: numpy array of shape(n)
978
- The state difference at the lth segment
979
-
980
- Returns
981
- ----------
982
-
983
- u_l_tilde: numpy array of shape(m)
984
- Actual input difference at the lth segment
985
- """
986
-
987
- if self.is_LQR():
988
- k_T = self._get_K_i(0) if self.__continuous else self._get_K_i(0, self._L - 1)
989
- u_l_tilde = - k_T @ x_l_tilde
990
- else:
991
- v_l_tilde = []
992
-
993
- for i in range(self._N):
994
- k_i_T = self._get_K_i(i) if self.__continuous else self._get_K_i(i, self._L - 1)
995
- v_i_T = - k_i_T @ self.x_T
996
-
997
- v_i_l_tilde = self.__get_v_i_l_tilde(x=x,
998
- i=i,
999
- l=l,
1000
- v_i_T=v_i_T)
1001
-
1002
- v_l_tilde += [v_i_l_tilde]
1003
-
1004
- v_l_tilde = np.array(v_l_tilde)
1005
- u_l_tilde = self._M_inv @ v_l_tilde
1006
-
1007
- return u_l_tilde
1008
-
1009
- @_post_convergence
1010
- def __get_x_and_u_l_tilde(self,
1011
- x: np.array,
1012
- l: int) -> (np.array, np.array):
1013
- """
1014
- Returns the state and actual input difference at the lth segment
1015
-
1016
- Parameters
1017
- ----------
1018
-
1019
- x: numpy array of shape(n)
1020
- The solved state vector to consider
1021
- l: int
1022
- The desired segment
1023
-
1024
- Returns
1025
- ----------
1026
-
1027
- x_l_tilde: numpy array of shape(n)
1028
- State difference at the lth segment
1029
- u_l_tilde: numpy array of shape(m)
1030
- Actual input difference at the lth segment
1031
- """
1032
-
1033
- x_l_tilde = self.__get_x_l_tilde(x=x,
1034
- l=l)
1035
- u_l_tilde = self.__get_u_l_tilde(x=x,
1036
- l=l,
1037
- x_l_tilde=x_l_tilde)
1038
-
1039
- return x_l_tilde, u_l_tilde
1040
-
1041
- def __get_x(self,
1042
- non_linear: bool) -> np.array:
1043
- """
1044
- Returns the desired state vector to consider
1045
-
1046
- Parameters
1047
- ----------
1048
-
1049
- non_linear: bool
1050
- Whether to return the non-linear state
1051
-
1052
- Returns
1053
- ----------
1054
-
1055
- x: numpy array of shape(n)
1056
- Desired state vector
1057
- """
1058
-
1059
- x = self._x if not non_linear else self._x_non_linear.T
1060
- assert len(x) == self._L
1061
-
1062
- return x
1063
-
1064
- @_post_convergence
1065
- def get_cost(self,
1066
- lqr_objective: Objective,
1067
- non_linear: Optional[bool] = False,
1068
- x_only: Optional[bool] = False) -> float:
1069
- """
1070
- Calculates the cost functionals values using the formula:
1071
- J = int_{t=0}^T_f J(t) dt =
1072
- int_{t=0}^T_f [ x_tilde(t)^T Q x_tilde(t) + u_tilde^T(t) R u_tilde(t) ) ] dt
1073
-
1074
- and the corresponding Trapezoidal rule approximation:
1075
- J ~ delta / 2 sum_{l=1}^{L} [ J(l) + J(l - 1) ]
1076
- = delta * [ sum_{l=1}^{L-1} J(l) + 1/2 ( J(0) + J(L) ) ]
1077
-
1078
- Parameters
1079
- ----------
1080
-
1081
- lqr_objective: LQRObjective
1082
- LQR objective to get Q and R from
1083
- non_linear: bool, optional
1084
- Indicates whether to calculate the cost with respect to the nonlinear state
1085
- x_only: bool, optional
1086
- Indicates whether to calculate the cost only with respect to x(t)
1087
-
1088
- Returns
1089
- ----------
1090
-
1091
- log_cost: float
1092
- game cost
1093
- """
1094
-
1095
- x = self.__get_x(non_linear=non_linear)
1096
-
1097
- cost = 0
1098
- Q = lqr_objective.Q
1099
- R = lqr_objective.R
1100
-
1101
- for l in range(0, self._L):
1102
- x_l_tilde = self.__get_x_l_tilde(x=x,
1103
- l=l)
1104
- cost_l = float(x_l_tilde.T @ Q @ x_l_tilde)
1105
-
1106
- if not x_only:
1107
- u_l_tilde = self.__get_u_l_tilde(x=x,
1108
- l=l,
1109
- x_l_tilde=x_l_tilde)
1110
- cost_l += float(u_l_tilde.T @ R @ u_l_tilde)
1111
-
1112
- if l in [0, self._L - 1]:
1113
- cost_l *= 0.5
1114
-
1115
- cost += cost_l
1116
-
1117
- return cost
1118
-
1119
- @_post_convergence
1120
- def get_agnostic_costs(self,
1121
- non_linear: Optional[bool] = False) -> float:
1122
- """
1123
- Calculates the agnostic functional value using the formula:
1124
- J_agnostic = int_{t=0}^T_f J_agnostic(t) dt =
1125
- int_{t=0}^T_f [ ||x_tilde(t)||^2 + ||u_tilde(t)||^2 ] dt =
1126
- int_{t=0}^T_f [ x_tilde(t)^T x_tilde(t) + u_tilde(t)^T u_tilde(t) ] dt
1127
-
1128
- and the corresponding Trapezoidal rule approximation:
1129
- J ~ delta / 2 sum_{l=1}^L [ J(l) + J(l - 1) ] =
1130
- delta * [ sum_{l=1}^{L-1} J(l) + 1/2 ( J(0) + J(L) ) ]
1131
-
1132
- Parameters
1133
- ----------
1134
-
1135
- non_linear: bool, optional
1136
- Indicates whether to calculate the cost with respect to the nonlinear state
1137
-
1138
- Returns
1139
- ----------
1140
-
1141
- log_cost: float
1142
- agnostic game cost
1143
- """
1144
-
1145
- x = self.__get_x(non_linear=non_linear)
1146
-
1147
- cost = sum((0.5 if l in [0, self._L - 1] else 1) * sum(y_l.T @ y_l
1148
- for y_l in self.__get_x_and_u_l_tilde(x=x,
1149
- l=l))
1150
- for l in range(0, self._L)) * self._delta
1151
-
1152
- return cost
1153
-
1154
- def __call__(self,
1155
- plot_state_space: Optional[bool] = True,
1156
- plot_Mx: Optional[bool] = False,
1157
- M: Optional[np.array] = None,
1158
- output_variables_names: Optional[Sequence[str]] = None,
1159
- save_figure: Optional[bool] = False,
1160
- figure_path: Optional[Union[str, Path]] = _default_figures_path,
1161
- figure_filename: Optional[str] = _default_figures_filename,
1162
- print_characteristic_polynomials: Optional[bool] = False,
1163
- print_eigenvalues: Optional[bool] = False):
1164
- """
1165
- Runs the game simulation
1166
-
1167
- Parameters
1168
- ----------
1169
-
1170
- plot_state_space: bool, optional
1171
- Whether to plot the solved game state space
1172
- save_figure: bool, optional
1173
- Whether to save the state space plot figure
1174
- print_characteristic_polynomials: bool, optional
1175
- Whether to print the characteristic polynomials of the matrices Q_i and R_ii for all 1 <= i <= N
1176
- print_eigenvalues: bool, optional
1177
- Whether to print the eigenvalues of the matrices Q_i and R_ii for all 1 <= i <= N
1178
-
1179
- """
1180
-
1181
- if print_characteristic_polynomials:
1182
- self.__print_characteristic_polynomials()
1183
- if print_eigenvalues:
1184
- self.__print_eigenvalues()
1185
-
1186
- if plot_state_space:
1187
- self.__solve_game_simulate_state_space_and_plot(plot_Mx=plot_Mx,
1188
- M=M,
1189
- output_variables_names=output_variables_names)
1190
- if save_figure:
1191
- self.__save_figure(figure_path=figure_path,
1192
- figure_filename=figure_filename)
1193
- else:
1194
- self.__solve_game_and_simulate_state_space()
1195
-
1196
- def __getitem__(self,
1197
- i: int) -> Objective:
1198
- """
1199
- Returns the i'th objective of the game
1200
-
1201
- Parameters
1202
- ----------
1203
-
1204
- i: int
1205
- The objective index to consider
1206
-
1207
- Returns
1208
- ----------
1209
-
1210
- o_i: Objective
1211
- i'th objective of the game
1212
- """
1213
-
1214
- return self.__objectives[i]
1215
-
1216
- def __len__(self) -> int:
1217
- """
1218
- We define the length of a differential game to be its number of objectives (N)
1219
-
1220
- Returns
1221
- ----------
1222
-
1223
- len: int
1224
- The length of the game
1225
- """
1226
-
1227
- return self._N
1228
-
1229
- @property
1230
- def P(self) -> list[np.array]:
1231
- return self._P
1232
-
1233
- @property
1234
- def K(self) -> list[np.array]:
1235
- return self._K
1236
-
1237
- @property
1238
- def x_0(self) -> np.array:
1239
- return self._x_0
1240
-
1241
- @property
1242
- def x_T(self) -> np.array:
1243
- return self._x_T
1244
-
1245
- @property
1246
- def x(self) -> np.array:
1247
- return self._x
1248
-
1249
- @property
1250
- def forward_time(self) -> np.array:
1251
- return self._forward_time
1252
-
1253
- @property
1254
- def T_f(self) -> float:
1255
- return self._T_f
1256
-
1257
- @property
1258
- def L(self) -> int:
1259
- return self._L
1260
-
1261
- @property
1262
- def Bs(self) -> Sequence:
1263
- return self._Bs
1264
-
1265
- @property
1266
- def M_inv(self) -> np.array:
1267
- return self._M_inv
1268
-
1269
- @property
1270
- def epsilon(self) -> float:
1271
- return self.__epsilon_x
1272
-
1273
- _post_convergence = staticmethod(_post_convergence)