PyDiffGame 2.0.2__py3-none-any.whl → 2.0.4__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/__init__.py +7 -1
- PyDiffGame/robust.py +287 -0
- {pydiffgame-2.0.2.dist-info → pydiffgame-2.0.4.dist-info}/METADATA +69 -12
- {pydiffgame-2.0.2.dist-info → pydiffgame-2.0.4.dist-info}/RECORD +6 -5
- {pydiffgame-2.0.2.dist-info → pydiffgame-2.0.4.dist-info}/WHEEL +0 -0
- {pydiffgame-2.0.2.dist-info → pydiffgame-2.0.4.dist-info}/licenses/LICENSE +0 -0
PyDiffGame/__init__.py
CHANGED
|
@@ -33,8 +33,11 @@ from PyDiffGame.continuous import ContinuousPyDiffGame
|
|
|
33
33
|
from PyDiffGame.discrete import DiscretePyDiffGame
|
|
34
34
|
from PyDiffGame.lqr import ContinuousLQR, DiscreteLQR
|
|
35
35
|
from PyDiffGame.objective import GameObjective, LQRObjective, Objective
|
|
36
|
+
from PyDiffGame.robust import ContinuousHInfinityControl
|
|
36
37
|
|
|
37
|
-
__version__ = "2.0.
|
|
38
|
+
__version__ = "2.0.4"
|
|
39
|
+
__author__ = "Joshua Shay Kricheli"
|
|
40
|
+
__url__ = "https://github.com/krichelj/PyDiffGame"
|
|
38
41
|
|
|
39
42
|
__all__ = [
|
|
40
43
|
"PyDiffGame",
|
|
@@ -42,9 +45,12 @@ __all__ = [
|
|
|
42
45
|
"DiscretePyDiffGame",
|
|
43
46
|
"ContinuousLQR",
|
|
44
47
|
"DiscreteLQR",
|
|
48
|
+
"ContinuousHInfinityControl",
|
|
45
49
|
"Objective",
|
|
46
50
|
"LQRObjective",
|
|
47
51
|
"GameObjective",
|
|
48
52
|
"PyDiffGameLQRComparison",
|
|
49
53
|
"__version__",
|
|
54
|
+
"__author__",
|
|
55
|
+
"__url__",
|
|
50
56
|
]
|
PyDiffGame/robust.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
r"""Robust (:math:`H_\infty`) state feedback as a controller-vs-disturbance game.
|
|
2
|
+
|
|
3
|
+
This completes the differential-games family in :mod:`PyDiffGame`: alongside the
|
|
4
|
+
single-objective :class:`~PyDiffGame.lqr.ContinuousLQR` and the :math:`N`-player
|
|
5
|
+
Nash :class:`~PyDiffGame.continuous.ContinuousPyDiffGame`, the *robust* design is
|
|
6
|
+
the two-player **zero-sum** differential game in which the controller minimises
|
|
7
|
+
and an adversarial disturbance maximises
|
|
8
|
+
|
|
9
|
+
.. math::
|
|
10
|
+
J = \int_0^\infty x^\top Q x + u^\top R u - \gamma^2 w^\top w \; dt ,
|
|
11
|
+
\qquad \dot x = A x + B u + B_w w .
|
|
12
|
+
|
|
13
|
+
Its saddle point is the :math:`H_\infty` state-feedback controller
|
|
14
|
+
:math:`u = -K x`, :math:`K = R^{-1} B^\top P`, where :math:`P` solves the game
|
|
15
|
+
algebraic Riccati equation
|
|
16
|
+
|
|
17
|
+
.. math::
|
|
18
|
+
A^\top P + P A + Q - P\,(B R^{-1} B^\top - \gamma^{-2} B_w B_w^\top)\,P = 0 .
|
|
19
|
+
|
|
20
|
+
The quadratic term is *indefinite* (the disturbance subtracts), which is exactly
|
|
21
|
+
why an ordinary LQR Riccati solver cannot produce it and the game formulation is
|
|
22
|
+
required. The controller minimises the worst-case :math:`L_2` gain from the
|
|
23
|
+
disturbance to the weighted performance output — the robustness an LQR does not
|
|
24
|
+
optimise.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
from scipy.linalg import schur
|
|
31
|
+
|
|
32
|
+
from PyDiffGame._typing import ArrayLike, FloatArray
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _as_matrix(value: ArrayLike) -> FloatArray:
|
|
36
|
+
arr = np.asarray(value, dtype=np.float64)
|
|
37
|
+
return arr.reshape(1, 1) if arr.ndim == 0 else arr
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _symmetric_sqrt(matrix: FloatArray) -> FloatArray:
|
|
41
|
+
"""Symmetric positive-semidefinite square root of ``matrix``."""
|
|
42
|
+
eigenvalues, eigenvectors = np.linalg.eigh(0.5 * (matrix + matrix.T))
|
|
43
|
+
return (eigenvectors * np.sqrt(np.clip(eigenvalues, 0.0, None))) @ eigenvectors.T
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ContinuousHInfinityControl:
|
|
47
|
+
r"""Continuous-time :math:`H_\infty` state feedback via the disturbance game.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
A, B : array_like
|
|
52
|
+
State and control matrices of :math:`\dot x = A x + B u + B_w w`.
|
|
53
|
+
B_w : array_like
|
|
54
|
+
Disturbance input matrix :math:`B_w`.
|
|
55
|
+
Q, R : array_like
|
|
56
|
+
Symmetric weights of the performance output (``Q`` PSD, ``R`` PD).
|
|
57
|
+
gamma : float, optional
|
|
58
|
+
Robustness level. Smaller is more robust but harder; it must exceed the
|
|
59
|
+
optimal :math:`\gamma^\star`. If omitted, :math:`\gamma^\star` is found by
|
|
60
|
+
bisection and ``gamma = gamma_margin * gamma_star`` is used.
|
|
61
|
+
gamma_margin : float, default ``1.3``
|
|
62
|
+
Multiplier applied to :math:`\gamma^\star` when ``gamma`` is not given.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
A: ArrayLike,
|
|
68
|
+
B: ArrayLike,
|
|
69
|
+
B_w: ArrayLike,
|
|
70
|
+
Q: ArrayLike,
|
|
71
|
+
R: ArrayLike,
|
|
72
|
+
*,
|
|
73
|
+
gamma: float | None = None,
|
|
74
|
+
gamma_margin: float = 1.3,
|
|
75
|
+
) -> None:
|
|
76
|
+
self._A = _as_matrix(A)
|
|
77
|
+
self._B = _as_matrix(B)
|
|
78
|
+
self._B_w = _as_matrix(B_w)
|
|
79
|
+
self._Q = _as_matrix(Q)
|
|
80
|
+
self._R = _as_matrix(R)
|
|
81
|
+
self._n = self._A.shape[0]
|
|
82
|
+
if self._A.shape != (self._n, self._n):
|
|
83
|
+
raise ValueError("A must be square")
|
|
84
|
+
for name, mat, rows in (
|
|
85
|
+
("B", self._B, self._n),
|
|
86
|
+
("B_w", self._B_w, self._n),
|
|
87
|
+
("Q", self._Q, self._n),
|
|
88
|
+
):
|
|
89
|
+
if mat.shape[0] != rows:
|
|
90
|
+
raise ValueError(f"{name} must have {rows} rows")
|
|
91
|
+
if gamma_margin <= 1.0:
|
|
92
|
+
raise ValueError("gamma_margin must be > 1")
|
|
93
|
+
|
|
94
|
+
self._gamma_margin = float(gamma_margin)
|
|
95
|
+
self._gamma_star: float | None = None
|
|
96
|
+
self._gamma = None if gamma is None else float(gamma)
|
|
97
|
+
self._P: FloatArray | None = None
|
|
98
|
+
self._K: FloatArray | None = None
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------ #
|
|
101
|
+
# GARE solve (Hamiltonian / ordered Schur)
|
|
102
|
+
# ------------------------------------------------------------------ #
|
|
103
|
+
def _solve_gare(self, gamma: float) -> FloatArray:
|
|
104
|
+
R_inv = np.linalg.inv(self._R)
|
|
105
|
+
S = self._B @ R_inv @ self._B.T - (1.0 / gamma**2) * (self._B_w @ self._B_w.T)
|
|
106
|
+
hamiltonian = np.block([[self._A, -S], [-self._Q, -self._A.T]])
|
|
107
|
+
_, vectors, stable_dim = schur(hamiltonian, sort="lhp")
|
|
108
|
+
if stable_dim != self._n:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"no stabilising H-infinity solution at gamma={gamma:.4g} "
|
|
111
|
+
f"(stable subspace dimension {stable_dim} != {self._n}); gamma is below gamma*"
|
|
112
|
+
)
|
|
113
|
+
basis = vectors[:, : self._n]
|
|
114
|
+
upper, lower = basis[: self._n, :], basis[self._n :, :]
|
|
115
|
+
if abs(np.linalg.det(upper)) < 1e-12:
|
|
116
|
+
raise ValueError(f"degenerate Schur basis at gamma={gamma:.4g}")
|
|
117
|
+
P = np.real(lower @ np.linalg.inv(upper))
|
|
118
|
+
return 0.5 * (P + P.T)
|
|
119
|
+
|
|
120
|
+
def _stabilizing_solution(self, gamma: float) -> tuple[FloatArray | None, bool]:
|
|
121
|
+
"""Solve the GARE at ``gamma`` and validate a stabilising PSD solution.
|
|
122
|
+
|
|
123
|
+
Robust admissibility predicate: rather than trust the (boundary-brittle)
|
|
124
|
+
stable-subspace dimension count alone, ``gamma`` is admissible only if the
|
|
125
|
+
recovered ``P`` is symmetric PSD *and* the closed loop ``A - B K`` is
|
|
126
|
+
strictly Hurwitz with a small margin. This makes admissibility monotone
|
|
127
|
+
through :math:`\\gamma^\\star` and immune to the imaginary-axis eigenvalue
|
|
128
|
+
jitter of the Hamiltonian at the boundary.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
P = self._solve_gare(gamma)
|
|
132
|
+
except ValueError:
|
|
133
|
+
return None, False
|
|
134
|
+
if float(np.min(np.linalg.eigvalsh(P))) < -1e-7:
|
|
135
|
+
return P, False
|
|
136
|
+
K = np.linalg.inv(self._R) @ self._B.T @ P
|
|
137
|
+
spectral_abscissa = float(np.max(np.linalg.eigvals(self._A - self._B @ K).real))
|
|
138
|
+
return P, spectral_abscissa < -1e-7
|
|
139
|
+
|
|
140
|
+
def _is_admissible(self, gamma: float) -> bool:
|
|
141
|
+
return self._stabilizing_solution(gamma)[1]
|
|
142
|
+
|
|
143
|
+
def optimal_gamma(self, *, iterations: int = 100) -> float:
|
|
144
|
+
r"""Smallest :math:`\gamma` admitting a stabilising solution (:math:`\gamma^\star`).
|
|
145
|
+
|
|
146
|
+
Brackets :math:`\gamma^\star` from *both* sides adaptively — doubling the
|
|
147
|
+
upper bound up until admissible and halving the lower bound down until
|
|
148
|
+
inadmissible — so it never silently floors out on systems whose
|
|
149
|
+
:math:`\gamma^\star` is tiny, then bisects on the validated admissibility
|
|
150
|
+
predicate (:meth:`_stabilizing_solution`), which is monotone through the
|
|
151
|
+
boundary.
|
|
152
|
+
"""
|
|
153
|
+
if self._gamma_star is not None:
|
|
154
|
+
return self._gamma_star
|
|
155
|
+
upper = 1.0
|
|
156
|
+
for _ in range(60):
|
|
157
|
+
if self._is_admissible(upper):
|
|
158
|
+
break
|
|
159
|
+
upper *= 2.0
|
|
160
|
+
else:
|
|
161
|
+
raise RuntimeError("could not bracket a feasible upper gamma")
|
|
162
|
+
lower = upper
|
|
163
|
+
for _ in range(60):
|
|
164
|
+
lower *= 0.5
|
|
165
|
+
if not self._is_admissible(lower):
|
|
166
|
+
break
|
|
167
|
+
else:
|
|
168
|
+
# admissible all the way down: gamma* is effectively zero
|
|
169
|
+
self._gamma_star = float(lower)
|
|
170
|
+
return self._gamma_star
|
|
171
|
+
for _ in range(iterations):
|
|
172
|
+
mid = np.sqrt(lower * upper)
|
|
173
|
+
if self._is_admissible(mid):
|
|
174
|
+
upper = mid
|
|
175
|
+
else:
|
|
176
|
+
lower = mid
|
|
177
|
+
if upper / lower < 1.0 + 1e-6:
|
|
178
|
+
break
|
|
179
|
+
self._gamma_star = float(upper)
|
|
180
|
+
return self._gamma_star
|
|
181
|
+
|
|
182
|
+
def solve(self) -> ContinuousHInfinityControl:
|
|
183
|
+
"""Solve the disturbance game and store the controller. Returns ``self``."""
|
|
184
|
+
gamma = self._gamma if self._gamma is not None else self.optimal_gamma() * self._gamma_margin
|
|
185
|
+
self._gamma = float(gamma)
|
|
186
|
+
self._P = self._solve_gare(gamma)
|
|
187
|
+
self._K = np.linalg.inv(self._R) @ self._B.T @ self._P
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------ #
|
|
191
|
+
# Results
|
|
192
|
+
# ------------------------------------------------------------------ #
|
|
193
|
+
def _require_solved(self) -> None:
|
|
194
|
+
if self._K is None:
|
|
195
|
+
raise RuntimeError("call solve() first")
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def K(self) -> FloatArray:
|
|
199
|
+
self._require_solved()
|
|
200
|
+
assert self._K is not None
|
|
201
|
+
return self._K
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def P(self) -> FloatArray:
|
|
205
|
+
self._require_solved()
|
|
206
|
+
assert self._P is not None
|
|
207
|
+
return self._P
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def gamma(self) -> float:
|
|
211
|
+
self._require_solved()
|
|
212
|
+
assert self._gamma is not None
|
|
213
|
+
return self._gamma
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def A_cl(self) -> FloatArray:
|
|
217
|
+
return self._A - self._B @ self.K
|
|
218
|
+
|
|
219
|
+
def is_closed_loop_stable(self, tolerance: float = 1e-9) -> bool:
|
|
220
|
+
return bool(np.all(np.linalg.eigvals(self.A_cl).real < tolerance))
|
|
221
|
+
|
|
222
|
+
def gare_residual(self) -> float:
|
|
223
|
+
"""Max-abs residual of the game algebraic Riccati equation (should be ~0)."""
|
|
224
|
+
self._require_solved()
|
|
225
|
+
P, R_inv = self.P, np.linalg.inv(self._R)
|
|
226
|
+
S = self._B @ R_inv @ self._B.T - (1.0 / self.gamma**2) * (self._B_w @ self._B_w.T)
|
|
227
|
+
res = self._A.T @ P + P @ self._A + self._Q - P @ S @ P
|
|
228
|
+
return float(np.max(np.abs(res)))
|
|
229
|
+
|
|
230
|
+
def worst_case_gain(self, *, n_grid: int = 6000) -> tuple[float, float]:
|
|
231
|
+
r"""Closed-loop worst-case :math:`L_2` gain of this :math:`H_\infty` design.
|
|
232
|
+
|
|
233
|
+
See :func:`worst_case_l2_gain`. Returns ``(peak_gain, peak_frequency)``.
|
|
234
|
+
"""
|
|
235
|
+
self._require_solved()
|
|
236
|
+
return worst_case_l2_gain(self._A, self._B, self.K, self._Q, self._R, self._B_w, n_grid=n_grid)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def worst_case_l2_gain(
|
|
240
|
+
A: ArrayLike,
|
|
241
|
+
B: ArrayLike,
|
|
242
|
+
K: ArrayLike,
|
|
243
|
+
Q: ArrayLike,
|
|
244
|
+
R: ArrayLike,
|
|
245
|
+
B_w: ArrayLike,
|
|
246
|
+
*,
|
|
247
|
+
n_grid: int = 6000,
|
|
248
|
+
) -> tuple[float, float]:
|
|
249
|
+
r"""Worst-case :math:`L_2` gain :math:`\lVert G_{zw}\rVert_\infty` of any state-feedback gain.
|
|
250
|
+
|
|
251
|
+
The induced gain from disturbance ``w`` to the weighted performance output
|
|
252
|
+
:math:`z = [Q^{1/2} x;\, R^{1/2} u]` for the closed loop ``A - B K``, i.e.
|
|
253
|
+
:math:`\max_\omega \sigma_{\max}(C (j\omega I - (A-BK))^{-1} B_w)`, by a
|
|
254
|
+
log-spaced frequency sweep refined around the peak (no slycot needed). Lower
|
|
255
|
+
is more robust; this is the formal robustness metric used to compare an
|
|
256
|
+
:math:`H_\infty` design against, say, an LQR. Returns ``inf`` if unstable.
|
|
257
|
+
"""
|
|
258
|
+
A, B, K = _as_matrix(A), _as_matrix(B), _as_matrix(K)
|
|
259
|
+
Q, R, B_w = _as_matrix(Q), _as_matrix(R), _as_matrix(B_w)
|
|
260
|
+
A_cl = A - B @ K
|
|
261
|
+
eigenvalues = np.linalg.eigvals(A_cl)
|
|
262
|
+
if np.max(eigenvalues.real) >= 0:
|
|
263
|
+
return float("inf"), float("nan")
|
|
264
|
+
C = np.vstack([_symmetric_sqrt(Q), -_symmetric_sqrt(R) @ K])
|
|
265
|
+
eye = np.eye(A_cl.shape[0])
|
|
266
|
+
|
|
267
|
+
def sigma_max(omega: float) -> float:
|
|
268
|
+
transfer = C @ np.linalg.solve(1j * omega * eye - A_cl, B_w)
|
|
269
|
+
return float(np.linalg.svd(transfer, compute_uv=False)[0])
|
|
270
|
+
|
|
271
|
+
# Upper frequency derived from the fastest closed-loop mode so the sweep can
|
|
272
|
+
# never miss a high-frequency peak (with a generous safety factor and floor).
|
|
273
|
+
top = max(1.0e5, 50.0 * float(np.max(np.abs(eigenvalues))))
|
|
274
|
+
grid = np.concatenate([[0.0], np.logspace(-4, np.log10(top), n_grid)])
|
|
275
|
+
values = np.array([sigma_max(w) for w in grid])
|
|
276
|
+
peak = int(np.argmax(values))
|
|
277
|
+
lo = grid[max(peak - 1, 0)]
|
|
278
|
+
hi = grid[min(peak + 1, len(grid) - 1)]
|
|
279
|
+
fine = np.linspace(lo, hi, n_grid // 2)
|
|
280
|
+
fine_values = np.array([sigma_max(w) for w in fine])
|
|
281
|
+
best = int(np.argmax(fine_values))
|
|
282
|
+
if fine_values[best] >= values[peak]:
|
|
283
|
+
return float(fine_values[best]), float(fine[best])
|
|
284
|
+
return float(values[peak]), float(grid[peak])
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
__all__ = ["ContinuousHInfinityControl", "worst_case_l2_gain"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyDiffGame
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.4
|
|
4
4
|
Summary: Nash-equilibrium solutions to linear-quadratic differential games, via a reduction of the Game Hamilton-Jacobi-Bellman equations to coupled algebraic and differential Riccati equations for multi-objective dynamical control systems.
|
|
5
5
|
Project-URL: Homepage, https://krichelj.github.io/PyDiffGame/
|
|
6
6
|
Project-URL: Repository, https://github.com/krichelj/PyDiffGame
|
|
@@ -58,7 +58,7 @@ Requires-Dist: control>=0.10; extra == 'examples'
|
|
|
58
58
|
Description-Content-Type: text/markdown
|
|
59
59
|
|
|
60
60
|
<p align="center">
|
|
61
|
-
<img alt="PyDiffGame logo" src="https://raw.githubusercontent.com/krichelj/PyDiffGame/master/images/logo.
|
|
61
|
+
<img alt="PyDiffGame logo" src="https://raw.githubusercontent.com/krichelj/PyDiffGame/master/images/logo.gif" width="420"/>
|
|
62
62
|
</p>
|
|
63
63
|
|
|
64
64
|
<p align="center">
|
|
@@ -83,7 +83,8 @@ Description-Content-Type: text/markdown
|
|
|
83
83
|
* [Quick start](#quick-start)
|
|
84
84
|
* [Input parameters](#input-parameters)
|
|
85
85
|
* [Tutorial: masses on springs](#tutorial-masses-on-springs)
|
|
86
|
-
* [
|
|
86
|
+
* [Robust control: H∞ as a game](#robust-control-h-as-a-game)
|
|
87
|
+
* [Examples — a walkthrough](#examples--a-walkthrough)
|
|
87
88
|
* [Testing and development](#testing-and-development)
|
|
88
89
|
* [Citing](#citing)
|
|
89
90
|
* [Acknowledgments](#acknowledgments)
|
|
@@ -332,17 +333,73 @@ player, without re-tuning one monolithic cost matrix.
|
|
|
332
333
|
> [`tools/generate_readme_figures.py`](https://github.com/krichelj/PyDiffGame/blob/master/tools/generate_readme_figures.py)
|
|
333
334
|
> (`uv run python tools/generate_readme_figures.py`), so they always match the current code.
|
|
334
335
|
|
|
335
|
-
#
|
|
336
|
+
# Robust control: H∞ as a game
|
|
336
337
|
|
|
337
|
-
The
|
|
338
|
-
|
|
338
|
+
A game ties the centralized LQR on a shared cost — it cannot beat it. The place a
|
|
339
|
+
differential game **provably wins** is *robustness*: classical **H∞** state feedback **is**
|
|
340
|
+
the saddle point of a two-player zero-sum game in which the controller minimises and an
|
|
341
|
+
adversarial disturbance maximises. PyDiffGame ships it as `ContinuousHInfinityControl`,
|
|
342
|
+
completing the family next to the LQR and the N-player Nash game:
|
|
339
343
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
344
|
+
```python
|
|
345
|
+
import numpy as np
|
|
346
|
+
from PyDiffGame import ContinuousHInfinityControl
|
|
347
|
+
|
|
348
|
+
A = np.array([[0.0, 1.0], [0.0, 0.0]]) # a cart: position, velocity
|
|
349
|
+
B = np.array([[0.0], [1.0]]) # control force
|
|
350
|
+
B_w = np.array([[0.0], [1.0]]) # disturbance force
|
|
351
|
+
Q, R = np.diag([1.0, 0.0]), np.array([[1.0]])
|
|
352
|
+
|
|
353
|
+
robust = ContinuousHInfinityControl(A, B, B_w, Q, R).solve() # picks gamma = 1.3 * gamma*
|
|
354
|
+
print(robust.K) # robust feedback gain, u = -K x
|
|
355
|
+
print(robust.worst_case_gain()[0]) # closed-loop ||G_zw||inf — provably below the LQR's
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
It solves the **game** algebraic Riccati equation whose quadratic term
|
|
359
|
+
`B R⁻¹ Bᵀ − γ⁻² B_w B_wᵀ` is *indefinite* — exactly what an ordinary LQR Riccati solver
|
|
360
|
+
cannot do — via the Hamiltonian/Schur method, auto-finds the optimal robustness level
|
|
361
|
+
`γ*`, and reports the formal worst-case L2 gain. Across a
|
|
362
|
+
[13-system benchmark](https://github.com/krichelj/PyDiffGame/tree/master/benchmarks)
|
|
363
|
+
(carts, vehicles, aircraft, drones, flexible structures) it reduces the worst-case
|
|
364
|
+
disturbance gain on every system, **practically significantly on 10/13** — most where the
|
|
365
|
+
LQR leaves a sharp resonant peak — each at a documented nominal-cost price:
|
|
366
|
+
|
|
367
|
+
<p align="center">
|
|
368
|
+
<img alt="H-infinity game vs LQR under worst-case disturbance (inverted pendulum)" src="https://raw.githubusercontent.com/krichelj/PyDiffGame/bbd010f15ee13adc2bba5e7b99e1a3ecc0238583/benchmarks/results/robust_inverted_pendulum.gif" width="860"/>
|
|
369
|
+
</p>
|
|
370
|
+
|
|
371
|
+
Left: the pendulum-angle response to the worst-case disturbance (H∞ roughly halves
|
|
372
|
+
it). Right: the `σmax(ω)` curves whose peak *is* the worst-case gain — the LQR's
|
|
373
|
+
resonant peak (≈1.92) flattened by the H∞ game to ≈1.25. The
|
|
374
|
+
[**robustness showcase**](https://github.com/krichelj/PyDiffGame/blob/master/benchmarks/README.md)
|
|
375
|
+
animates every system.
|
|
376
|
+
|
|
377
|
+
# Examples — a walkthrough
|
|
378
|
+
|
|
379
|
+
The package ships four worked **LQR-vs-game comparisons** under
|
|
380
|
+
[`src/PyDiffGame/examples`](https://github.com/krichelj/PyDiffGame/tree/master/src/PyDiffGame/examples);
|
|
381
|
+
each builds a system, designs an LQR and a decomposed game on it, runs both and reports the
|
|
382
|
+
costs. Run any of them with `uv run python -m PyDiffGame.examples.<name>`:
|
|
383
|
+
|
|
384
|
+
| Example | System | What it shows |
|
|
385
|
+
| --- | --- | --- |
|
|
386
|
+
| [`MassesWithSpringsComparison`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/MassesWithSpringsComparison.py) | Chain of masses coupled by springs | The **lossless** modal decomposition (the tutorial above): the game reproduces the monolithic LQR optimum |
|
|
387
|
+
| [`InvertedPendulumComparison`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/InvertedPendulumComparison.py) | Inverted pendulum on a cart | An **unstable, underactuated** plant; the nonlinear closed loop can be simulated from the designed gains |
|
|
388
|
+
| [`PVTOLComparison`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/PVTOLComparison.py) · [`PVTOL`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/PVTOL.py) | Planar vertical take-off & landing aircraft | A 6-state aircraft with input decomposition across players |
|
|
389
|
+
| [`QuadRotorControl`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/QuadRotorControl.py) | Quadrotor attitude / position control | A larger nonlinear vehicle with a cascaded design |
|
|
390
|
+
|
|
391
|
+
For the **full study** — PyDiffGame vs [python-control](https://python-control.readthedocs.io/)
|
|
392
|
+
across 13 systems on both the *nominal* cost (lossless tie / price of anarchy) and the
|
|
393
|
+
*robustness* metric, with rendered GIFs, a metrics report and an adversarial review of the
|
|
394
|
+
methodology — see
|
|
395
|
+
[`benchmarks/`](https://github.com/krichelj/PyDiffGame/tree/master/benchmarks) and its
|
|
396
|
+
[README](https://github.com/krichelj/PyDiffGame/blob/master/benchmarks/README.md):
|
|
397
|
+
|
|
398
|
+
```bash
|
|
399
|
+
uv run --extra dev python -m benchmarks.run_masses # nominal: game == LQR (lossless)
|
|
400
|
+
uv run --extra dev python -m benchmarks.run_anarchy # nominal: the price of anarchy
|
|
401
|
+
uv run --extra dev python -m benchmarks.run_robust_suite # the 13-system robustness suite + report
|
|
402
|
+
```
|
|
346
403
|
|
|
347
404
|
# Testing and development
|
|
348
405
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
PyDiffGame/__init__.py,sha256=
|
|
1
|
+
PyDiffGame/__init__.py,sha256=3h1YwXc7Klt4KXljtH4ng9mYCeYqg5H6QtlaruYFvww,1868
|
|
2
2
|
PyDiffGame/_typing.py,sha256=HErUHmfzvkWLPWT1YJl_gpFYZctjkPqsnw3TeHuuggs,912
|
|
3
3
|
PyDiffGame/base.py,sha256=hurrmsjCRQ049rHQGDa78JUBkCbLs-ZufDF8eHW5oNk,17287
|
|
4
4
|
PyDiffGame/comparison.py,sha256=lJVhceZE7FqaNUGu8q1mA6GS0dbBwYFF1mHn0hzKuwM,4145
|
|
@@ -7,6 +7,7 @@ PyDiffGame/discrete.py,sha256=s62Fhx6H4okfalsopOwW2kp9onnnArHK7tkLnAUd3Zc,8127
|
|
|
7
7
|
PyDiffGame/lqr.py,sha256=tcmL2wY_PtNon0XxdWehPJRvLdc44UqzJY9fC-ZnaJ0,1057
|
|
8
8
|
PyDiffGame/objective.py,sha256=mnUPEauTZJl5HE3PScXdzDX5W0pm4fU-Jfi7Lvzgz1o,3910
|
|
9
9
|
PyDiffGame/plotting.py,sha256=gvcgd-T0Y5uTcq15lJX3V4l39YqjpA0jFJsP-RzWTR0,2777
|
|
10
|
+
PyDiffGame/robust.py,sha256=OrwHOnc1TJ1SnBO5F6ASTePJJmQLDi1HjsibXkTFkiM,11665
|
|
10
11
|
PyDiffGame/examples/InvertedPendulumComparison.py,sha256=6jhtrHariZAblaWxfnrvOwjuGKKfTVhTyxDI5k3jam8,8615
|
|
11
12
|
PyDiffGame/examples/MassesWithSpringsComparison.py,sha256=JKUyaDfxKnK_N3DEUjWBIntijquZ59fEHLfmNPxSWPk,4626
|
|
12
13
|
PyDiffGame/examples/PVTOL.py,sha256=AQz_CFYw6oi9Qc2SXNFx1OIdtQTSDhqNSK02nPCDwuk,6542
|
|
@@ -33,7 +34,7 @@ PyDiffGame/examples/figures/PVTOL/PVTOL1.png,sha256=FBgKQSal5ZNyBsGz4RaVye0HBBQU
|
|
|
33
34
|
PyDiffGame/examples/figures/PVTOL/PVTOL10.png,sha256=XyoLMz5WKfg3ny2JxVxVd1Mxjz9WQQn7N87-8YDLisY,77894
|
|
34
35
|
PyDiffGame/examples/figures/PVTOL/PVTOL100.png,sha256=wkMdqrn9hSsIdU-Yhiki3X2fQ7dRRkfLyvBCFx5cMWI,75698
|
|
35
36
|
PyDiffGame/examples/figures/PVTOL/PVTOL1000.png,sha256=Hg_hsf9gnc1HqC4JVjATVay7uDlnIqBRkG8lzpcfiZ0,77630
|
|
36
|
-
pydiffgame-2.0.
|
|
37
|
-
pydiffgame-2.0.
|
|
38
|
-
pydiffgame-2.0.
|
|
39
|
-
pydiffgame-2.0.
|
|
37
|
+
pydiffgame-2.0.4.dist-info/METADATA,sha256=Q1Bd3uLy5PI_yxcLJxOUlZoC_2rrbL-Xja2c2484jVw,24273
|
|
38
|
+
pydiffgame-2.0.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
39
|
+
pydiffgame-2.0.4.dist-info/licenses/LICENSE,sha256=IYfdsBdqL9SmCEWsXUBTYh303LOWQQRCOUOfZI7PWz8,1139
|
|
40
|
+
pydiffgame-2.0.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|