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/lqr.py ADDED
@@ -0,0 +1,30 @@
1
+ """Convenience Linear-Quadratic Regulator (LQR) classes.
2
+
3
+ An LQR is a differential game with a single objective and no input
4
+ decomposition. These thin wrappers offer the familiar ``(A, B, Q, R)``
5
+ signature on top of the general game solvers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from PyDiffGame._typing import ArrayLike
11
+ from PyDiffGame.continuous import ContinuousPyDiffGame
12
+ from PyDiffGame.discrete import DiscretePyDiffGame
13
+ from PyDiffGame.objective import LQRObjective
14
+
15
+
16
+ class ContinuousLQR(ContinuousPyDiffGame):
17
+ """Continuous-time LQR: ``dx/dt = A x + B u``."""
18
+
19
+ def __init__(self, A: ArrayLike, B: ArrayLike, Q: ArrayLike, R: ArrayLike, **kwargs) -> None:
20
+ super().__init__(A, [LQRObjective(Q=Q, R=R)], B=B, **kwargs)
21
+
22
+
23
+ class DiscreteLQR(DiscretePyDiffGame):
24
+ """Discrete-time LQR: ``x[k+1] = A x[k] + B u[k]``."""
25
+
26
+ def __init__(self, A: ArrayLike, B: ArrayLike, Q: ArrayLike, R: ArrayLike, **kwargs) -> None:
27
+ super().__init__(A, [LQRObjective(Q=Q, R=R)], B=B, **kwargs)
28
+
29
+
30
+ __all__ = ["ContinuousLQR", "DiscreteLQR"]
@@ -0,0 +1,108 @@
1
+ """Control objectives for a differential game.
2
+
3
+ A differential game is specified by a collection of :class:`Objective` instances,
4
+ one per *player* (a.k.a. virtual control objective). Each objective carries its
5
+ own state-weight matrix :math:`Q_i`, input-weight matrix :math:`R_{ii}` and,
6
+ optionally, a *decomposition* matrix :math:`M_i` that maps the shared physical
7
+ input space onto the player's virtual input space.
8
+
9
+ A plain Linear-Quadratic Regulator (LQR) is simply a game with a single
10
+ objective and no decomposition matrix.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+ import numpy as np
18
+
19
+ from PyDiffGame._typing import ArrayLike, FloatArray
20
+
21
+
22
+ def _as_matrix(value: ArrayLike) -> FloatArray:
23
+ """Coerce ``value`` into a 2-D float array.
24
+
25
+ Scalars and 1-D inputs are promoted so that an input weight given as a bare
26
+ number (a common shorthand for single-input players) becomes a ``1 x 1``
27
+ matrix.
28
+ """
29
+
30
+ array = np.atleast_2d(np.asarray(value, dtype=np.float64))
31
+ return array
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class Objective:
36
+ """A single player's quadratic cost objective.
37
+
38
+ Parameters
39
+ ----------
40
+ Q:
41
+ State-weight matrix of shape ``(n, n)``. Must be symmetric positive
42
+ semi-definite.
43
+ R:
44
+ Input-weight matrix of shape ``(m_i, m_i)`` (or a scalar for a
45
+ single-input player). Must be symmetric positive definite.
46
+ M:
47
+ Optional decomposition matrix of shape ``(m_i, m)`` mapping the physical
48
+ input onto this player's virtual input. ``None`` denotes a plain LQR
49
+ objective acting directly on the full input.
50
+ """
51
+
52
+ Q: FloatArray
53
+ R: FloatArray
54
+ M: FloatArray | None = None
55
+ #: Whether this objective participates as an LQR (no decomposition).
56
+ is_lqr: bool = field(init=False)
57
+
58
+ def __post_init__(self) -> None:
59
+ # ``frozen=True`` forbids normal attribute assignment, so we go through
60
+ # ``object.__setattr__`` to normalise the matrices once at construction.
61
+ object.__setattr__(self, "Q", _as_matrix(self.Q))
62
+ object.__setattr__(self, "R", _as_matrix(self.R))
63
+ if self.M is not None:
64
+ object.__setattr__(self, "M", _as_matrix(self.M))
65
+ object.__setattr__(self, "is_lqr", self.M is None)
66
+ self._validate()
67
+
68
+ def _validate(self) -> None:
69
+ n = self.Q.shape[0]
70
+ if self.Q.shape != (n, n):
71
+ raise ValueError(f"Q must be square, got shape {self.Q.shape}")
72
+ if not np.allclose(self.Q, self.Q.T):
73
+ raise ValueError("Q must be symmetric")
74
+ q_eigvals = np.linalg.eigvalsh(self.Q)
75
+ if np.any(q_eigvals < -1e-7):
76
+ raise ValueError("Q must be positive semi-definite")
77
+
78
+ m_i = self.R.shape[0]
79
+ if self.R.shape != (m_i, m_i):
80
+ raise ValueError(f"R must be square, got shape {self.R.shape}")
81
+ if not np.allclose(self.R, self.R.T):
82
+ raise ValueError("R must be symmetric")
83
+ if np.any(np.linalg.eigvalsh(self.R) <= 0):
84
+ raise ValueError("R must be positive definite")
85
+
86
+ if self.M is not None and self.M.shape[0] != m_i:
87
+ raise ValueError(f"M must have m_i = {m_i} rows to match R, got {self.M.shape[0]}")
88
+
89
+ @property
90
+ def m_i(self) -> int:
91
+ """Number of (virtual) inputs controlled by this objective."""
92
+
93
+ return self.R.shape[0]
94
+
95
+
96
+ def LQRObjective(Q: ArrayLike, R: ArrayLike) -> Objective:
97
+ """Convenience constructor for a plain LQR objective (no decomposition)."""
98
+
99
+ return Objective(Q=_as_matrix(Q), R=_as_matrix(R), M=None)
100
+
101
+
102
+ def GameObjective(Q: ArrayLike, R: ArrayLike, M: ArrayLike) -> Objective:
103
+ """Convenience constructor for a game player's objective with decomposition ``M``."""
104
+
105
+ return Objective(Q=_as_matrix(Q), R=_as_matrix(R), M=_as_matrix(M))
106
+
107
+
108
+ __all__ = ["Objective", "LQRObjective", "GameObjective"]
PyDiffGame/plotting.py ADDED
@@ -0,0 +1,98 @@
1
+ """Small, dependency-light plotting helpers used by the solvers.
2
+
3
+ Keeping plotting in its own module means the numerical core has no hard
4
+ dependency on a particular figure layout, and makes the behaviour easy to test
5
+ (or to swap for a non-interactive backend in headless environments).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from collections.abc import Sequence
12
+ from pathlib import Path
13
+
14
+ import matplotlib.pyplot as plt
15
+ import numpy as np
16
+ from matplotlib.figure import Figure
17
+
18
+ from PyDiffGame._typing import FloatArray, PathInput
19
+
20
+
21
+ def plot_temporal(
22
+ time: FloatArray,
23
+ values: FloatArray,
24
+ *,
25
+ labels: Sequence[str] | None = None,
26
+ title: str | None = None,
27
+ show_legend: bool = True,
28
+ y_label: str | None = None,
29
+ ) -> Figure:
30
+ """Plot one or more time series and return the :class:`~matplotlib.figure.Figure`.
31
+
32
+ Parameters
33
+ ----------
34
+ time:
35
+ 1-D array of sample times.
36
+ values:
37
+ 2-D array of shape ``(len(time), k)`` (or ``(len(time),)``).
38
+ labels:
39
+ Optional per-series legend labels.
40
+ title, y_label:
41
+ Optional figure title and y-axis label.
42
+ show_legend:
43
+ Whether to draw a legend (only if ``labels`` are supplied).
44
+ """
45
+
46
+ values = np.asarray(values)
47
+ fig, ax = plt.subplots(figsize=(8, 6), dpi=150)
48
+ ax.plot(time[: values.shape[0]], values)
49
+ ax.set_xlabel("Time $[s]$", fontsize=14)
50
+ if y_label:
51
+ ax.set_ylabel(y_label, fontsize=14)
52
+ if title:
53
+ ax.set_title(title)
54
+
55
+ if values.size and np.nanmax(np.abs(values)) > 1e3:
56
+ ax.ticklabel_format(style="sci", axis="y", scilimits=(0, 0))
57
+
58
+ if show_legend and labels is not None:
59
+ ax.legend(
60
+ labels,
61
+ loc="upper right",
62
+ ncol=2 if len(labels) <= 8 else 4,
63
+ framealpha=0.4,
64
+ )
65
+
66
+ ax.grid(True)
67
+ fig.tight_layout()
68
+ return fig
69
+
70
+
71
+ def save_figure(fig: Figure, figure_path: PathInput, figure_filename: str) -> Path:
72
+ """Save ``fig`` under ``figure_path``, avoiding clobbering existing files.
73
+
74
+ A numeric suffix is appended until an unused filename is found, mirroring the
75
+ original package behaviour but without its off-by-one filename bug.
76
+ """
77
+
78
+ directory = Path(figure_path)
79
+ directory.mkdir(parents=True, exist_ok=True)
80
+
81
+ candidate = directory / f"{figure_filename}.png"
82
+ index = 0
83
+ while candidate.is_file():
84
+ index += 1
85
+ candidate = directory / f"{figure_filename}_{index}.png"
86
+
87
+ fig.savefig(candidate, bbox_inches="tight")
88
+ return candidate
89
+
90
+
91
+ def show() -> None:
92
+ """Show all open figures unless running headless (``MPLBACKEND=Agg``)."""
93
+
94
+ if os.environ.get("MPLBACKEND", "").lower() != "agg":
95
+ plt.show()
96
+
97
+
98
+ __all__ = ["plot_temporal", "save_figure", "show"]
@@ -0,0 +1,408 @@
1
+ Metadata-Version: 2.4
2
+ Name: PyDiffGame
3
+ Version: 2.0.0
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
+ Project-URL: Homepage, https://krichelj.github.io/PyDiffGame/
6
+ Project-URL: Repository, https://github.com/krichelj/PyDiffGame
7
+ Project-URL: Bug Tracker, https://github.com/krichelj/PyDiffGame/issues
8
+ Author-email: Joshua Shay Kricheli <skricheli2@gmail.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2021-2024 Joshua Shay Kricheli, Dr. Aviran Sadon, Dr. Shai Arogeti and Prof. Gera Weiss
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: control-theory,differential-games,lqr,nash-equilibrium,optimal-control,riccati
32
+ Classifier: Development Status :: 5 - Production/Stable
33
+ Classifier: Intended Audience :: Science/Research
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Operating System :: OS Independent
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Programming Language :: Python :: 3.13
40
+ Classifier: Programming Language :: Python :: 3.14
41
+ Classifier: Topic :: Scientific/Engineering
42
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
43
+ Classifier: Typing :: Typed
44
+ Requires-Python: >=3.11
45
+ Requires-Dist: matplotlib>=3.7
46
+ Requires-Dist: numpy>=1.24
47
+ Requires-Dist: scipy>=1.10
48
+ Requires-Dist: tqdm>=4.63
49
+ Provides-Extra: dev
50
+ Requires-Dist: control>=0.10; extra == 'dev'
51
+ Requires-Dist: mypy>=1.10; extra == 'dev'
52
+ Requires-Dist: pre-commit>=3.7; extra == 'dev'
53
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
54
+ Requires-Dist: pytest>=8.0; extra == 'dev'
55
+ Requires-Dist: ruff>=0.6; extra == 'dev'
56
+ Provides-Extra: examples
57
+ Requires-Dist: control>=0.10; extra == 'examples'
58
+ Description-Content-Type: text/markdown
59
+
60
+ <p align="center">
61
+ <img alt="PyDiffGame logo" src="images/logo.png" width="420"/>
62
+ </p>
63
+
64
+ <p align="center">
65
+ <i>Nash-equilibrium control for multi-objective dynamical systems, built on coupled Riccati equations.</i>
66
+ </p>
67
+
68
+ <p align="center">
69
+ <a href="https://github.com/krichelj/PyDiffGame/actions/workflows/tests.yml"><img alt="Tests" src="https://github.com/krichelj/PyDiffGame/actions/workflows/tests.yml/badge.svg?branch=master"></a>
70
+ <a href="https://pypi.org/project/PyDiffGame/"><img alt="PyPI" src="https://img.shields.io/pypi/v/PyDiffGame.svg"></a>
71
+ <a href="https://www.python.org/"><img alt="Python" src="https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue.svg"></a>
72
+ <a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg"></a>
73
+ <a href="https://github.com/astral-sh/ruff"><img alt="Ruff" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json"></a>
74
+ <a href="https://mypy-lang.org/"><img alt="Checked with mypy" src="https://img.shields.io/badge/mypy-checked-2a6db2.svg"></a>
75
+ <a href="https://docs.astral.sh/uv/"><img alt="uv" src="https://img.shields.io/badge/managed%20with-uv-261230.svg"></a>
76
+ </p>
77
+
78
+ ---
79
+
80
+ * [What is this?](#what-is-this)
81
+ * [Why differential games?](#why-differential-games)
82
+ * [Installation](#installation)
83
+ * [Quick start](#quick-start)
84
+ * [Input parameters](#input-parameters)
85
+ * [Tutorial: masses on springs](#tutorial-masses-on-springs)
86
+ * [More examples](#more-examples)
87
+ * [Testing and development](#testing-and-development)
88
+ * [Citing](#citing)
89
+ * [Acknowledgments](#acknowledgments)
90
+ * [Star history](#star-history)
91
+
92
+ # What is this?
93
+
94
+ [`PyDiffGame`](https://github.com/krichelj/PyDiffGame) is a Python implementation of a
95
+ **Nash-equilibrium solution to differential games**. It reduces the Game
96
+ Hamilton–Jacobi–Bellman (GHJB) equations to a set of *coupled* Game Algebraic and
97
+ Differential Riccati equations, and solves them to synthesize feedback controllers for
98
+ multi-objective dynamical control systems.
99
+
100
+ In one sentence: where a classical Linear-Quadratic Regulator (LQR) folds every control
101
+ task into **one** quadratic cost and solves **one** Riccati equation, `PyDiffGame` keeps
102
+ each task as a separate *player*, and solves the coupled Riccati system whose fixed point
103
+ is their Nash equilibrium. A plain LQR is simply the one-player special case.
104
+
105
+ The method follows the formulation in:
106
+
107
+ - The thesis _“Differential Games for Compositional Handling of Competing Control Tasks”_
108
+ ([ResearchGate](https://www.researchgate.net/publication/359819808_Differential_Games_for_Compositional_Handling_of_Competing_Control_Tasks))
109
+ - The conference paper _“Composition of Dynamic Control Objectives Based on Differential Games”_
110
+ ([IEEE](https://ieeexplore.ieee.org/document/9480269) ·
111
+ [ResearchGate](https://www.researchgate.net/publication/353452024_Composition_of_Dynamic_Control_Objectives_Based_on_Differential_Games))
112
+
113
+ The package requires **Python ≥ 3.11** and is tested on CPython 3.11, 3.12, 3.13 and 3.14.
114
+
115
+ # Why differential games?
116
+
117
+ | Classical LQR | `PyDiffGame` |
118
+ | --- | --- |
119
+ | A single, hand-blended cost $\int x^\top Q x + u^\top R u$ | One objective **per task / player**, each with its own $Q_i, R_i$ |
120
+ | One Algebraic Riccati Equation | A **coupled** system of Riccati equations solved to its Nash fixed point |
121
+ | Re-tune the whole weight matrix to add a task | **Compose** tasks by adding a player — the others keep their own cost |
122
+ | Continuous time only, by convention | Continuous **and** discrete time, finite or infinite horizon |
123
+
124
+ `PyDiffGame` ships both regulation (drive the state to the origin) and **signal tracking**
125
+ (drive the state to a target $x_T$), a one-call **LQR-vs-game comparison** harness, cost
126
+ accounting on a common yardstick, and ready-made plotting.
127
+
128
+ # Installation
129
+
130
+ Install the latest release from PyPI:
131
+
132
+ ```bash
133
+ pip install PyDiffGame
134
+ ```
135
+
136
+ To run the bundled examples (which additionally need
137
+ [`python-control`](https://python-control.readthedocs.io/)):
138
+
139
+ ```bash
140
+ pip install "PyDiffGame[examples]"
141
+ ```
142
+
143
+ To work on the package itself, this project is managed with
144
+ [**uv**](https://docs.astral.sh/uv/). Clone it and sync the locked development
145
+ environment:
146
+
147
+ ```bash
148
+ git clone https://github.com/krichelj/PyDiffGame.git
149
+ cd PyDiffGame
150
+ uv sync --extra dev # creates .venv with the exact locked dependencies
151
+ uv run pre-commit install # enable the formatting / lint / type-check hooks
152
+ ```
153
+
154
+ Then run anything through `uv run` (`uv run pytest`, `uv run python -m PyDiffGame.examples.MassesWithSpringsComparison`, …).
155
+
156
+ # Quick start
157
+
158
+ A plain Linear-Quadratic Regulator is just a one-objective game. The continuous solver
159
+ matches `scipy.linalg.solve_continuous_are` exactly:
160
+
161
+ ```python
162
+ import numpy as np
163
+ from PyDiffGame import ContinuousLQR
164
+
165
+ A = np.array([[0.0, 1.0],
166
+ [0.0, 0.0]])
167
+ B = np.array([[0.0],
168
+ [1.0]])
169
+
170
+ lqr = ContinuousLQR(A=A, B=B, Q=np.eye(2), R=1.0).solve()
171
+
172
+ print(lqr.K[0]) # optimal feedback gain
173
+ print(lqr.is_closed_loop_stable()) # True
174
+ ```
175
+
176
+ For a multi-player differential game, give one [`Objective`](#input-parameters) per
177
+ player. Each player owns a slice of the physical input through a decomposition matrix `M`:
178
+
179
+ ```python
180
+ import numpy as np
181
+ from PyDiffGame import ContinuousPyDiffGame, GameObjective
182
+
183
+ A = np.array([[0.0, 1.0, 0.0, 0.0],
184
+ [0.0, 0.0, 0.0, 0.0],
185
+ [0.0, 0.0, 0.0, 1.0],
186
+ [0.0, 0.0, 0.0, 0.0]])
187
+ B = np.eye(4)[:, [1, 3]]
188
+
189
+ objectives = [
190
+ GameObjective(Q=np.diag([1.0, 0.1, 0.0, 0.0]), R=1.0, M=np.array([[1.0, 0.0]])),
191
+ GameObjective(Q=np.diag([0.0, 0.0, 1.0, 0.1]), R=1.0, M=np.array([[0.0, 1.0]])),
192
+ ]
193
+
194
+ game = ContinuousPyDiffGame(A=A, objectives=objectives, B=B).solve()
195
+
196
+ # Each converged P_i drives its coupled algebraic Riccati residual to ~0:
197
+ print(max(np.max(np.abs(r)) for r in game.algebraic_riccati_residuals())) # ~1e-14
198
+ print(game.is_closed_loop_stable()) # True
199
+ ```
200
+
201
+ # Input parameters
202
+
203
+ A game is described by a system matrix `A`, a set of
204
+ [`Objective`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/objective.py)
205
+ objects (one per player), and an input description (`B` together with each objective's
206
+ decomposition matrix `M`, or per-player matrices `Bs`). Construct one of the concrete
207
+ solvers —
208
+ [`ContinuousPyDiffGame`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/continuous.py)
209
+ or
210
+ [`DiscretePyDiffGame`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/discrete.py)
211
+ — with the following parameters:
212
+
213
+ | Parameter | Type | Meaning |
214
+ | --- | --- | --- |
215
+ | `A` | `np.ndarray` $(n, n)$ | System dynamics matrix |
216
+ | `objectives` | `Sequence[Objective]` of length $N$ | One objective per player; each carries $Q_i$, $R_{ii}$ and optionally $M_i$ |
217
+ | `B` | `np.ndarray` $(n, m)$, optional | Full input matrix, used with each objective's decomposition matrix $M_i$ |
218
+ | `Bs` | `Sequence[np.ndarray]`, optional | Per-player input matrices $B_i$ of shape $(n, m_i)$ — an alternative to `B` + `M` |
219
+ | `x_0` | `np.ndarray` $(n,)$, optional | Initial state vector |
220
+ | `x_T` | `np.ndarray` $(n,)$, optional | Final (target) state for signal tracking |
221
+ | `T_f` | positive `float`, optional | Finite-horizon length; omit (or `None`) for the infinite-horizon problem |
222
+ | `P_f` | `Sequence[np.ndarray]`, optional | Terminal Riccati condition (default: uncoupled algebraic Riccati solutions) |
223
+ | `L` | positive `int`, default `1000` | Number of time samples |
224
+ | `eta` | positive `int`, default `5` | Number of trailing matrix norms inspected for convergence |
225
+ | `epsilon_x`, `epsilon_P` | `float` in $(0, 1)$, optional | Convergence tolerances for the state and the Riccati matrices |
226
+ | `state_variables_names` | `Sequence[str]` of length $n$, optional | LaTeX names (without `$`) for state variables, used in plots |
227
+ | `show_legend` | `bool`, default `True` | Whether plots include a legend |
228
+ | `debug` | `bool`, default `False` | Emit verbose diagnostics while solving |
229
+
230
+ An [`Objective`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/objective.py) takes:
231
+
232
+ * `Q` — `np.ndarray` $(n, n)$, symmetric positive **semi**-definite state weight
233
+ * `R` — `np.ndarray` $(m_i, m_i)$ (or a scalar), symmetric positive definite input weight
234
+ * `M` — `np.ndarray` $(m_i, m)$, optional decomposition matrix (`None` for a plain LQR objective)
235
+
236
+ The helpers `LQRObjective(Q, R)` and `GameObjective(Q, R, M)` are thin constructors for
237
+ the two common cases.
238
+
239
+ # Tutorial: masses on springs
240
+
241
+ To show the package in action we compare a differential game against an LQR on a chain of
242
+ masses connected by springs — a textbook coupled, oscillatory system:
243
+
244
+ <p align="center">
245
+ <img alt="Two masses connected by springs between two walls" src="images/readme/masses_schematic.png" width="760"/>
246
+ </p>
247
+
248
+ The physical input space is decomposed along the **modal** directions of $M^{-1}K$, so each
249
+ vibration mode becomes one player of a game. The full example lives in
250
+ [`MassesWithSpringsComparison.py`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/MassesWithSpringsComparison.py);
251
+ here is its essence:
252
+
253
+ ```python
254
+ import numpy as np
255
+ from PyDiffGame import GameObjective, LQRObjective, PyDiffGameLQRComparison
256
+
257
+ N, m, k, r = 2, 50.0, 10.0, 1.0
258
+ q = [500.0, 2000.0] # per-mode state weights
259
+
260
+ I_N, Z_N = np.eye(N), np.zeros((N, N))
261
+ mass_inv_stiffness = (1.0 / m) * k * (2 * I_N - np.eye(N, k=1) - np.eye(N, k=-1))
262
+
263
+ # Modal decomposition (orthonormal, so the modal transform is orthogonal):
264
+ _, eigenvectors = np.linalg.eigh(mass_inv_stiffness)
265
+ Ms = [eigenvectors[:, i].reshape(1, N) for i in range(N)]
266
+ modal_to_state = np.kron(np.eye(2), np.concatenate(Ms, axis=0))
267
+
268
+ A = np.block([[Z_N, I_N], [-mass_inv_stiffness, Z_N]])
269
+ B = np.block([[Z_N], [(1.0 / m) * I_N]])
270
+
271
+ game_objectives = []
272
+ for i, (q_i, M_i) in enumerate(zip(q, Ms)):
273
+ modal_weight = np.diag([0.0] * i + [q_i] + [0.0] * (N - 1) + [q_i] + [0.0] * (N - i - 1))
274
+ game_objectives.append(GameObjective(Q=modal_to_state.T @ modal_weight @ modal_to_state, R=r, M=M_i))
275
+
276
+ # The LQR baseline optimizes exactly the aggregate of the game's objectives
277
+ # (sum of the per-mode Q_i, and the matching physical input weight r * I), so
278
+ # it is the genuine monolithic optimum to compare the decomposed game against:
279
+ modal_weight_total = np.diag(q + q)
280
+ lqr_objective = [LQRObjective(Q=modal_to_state.T @ modal_weight_total @ modal_to_state, R=r * I_N)]
281
+
282
+ x_0 = np.array([10.0, 20.0, 0.0, 0.0])
283
+ comparison = PyDiffGameLQRComparison(
284
+ A=A, B=B,
285
+ games_objectives=[lqr_objective, game_objectives],
286
+ x_0=x_0, x_T=x_0 * 10.0, T_f=25.0, L=300,
287
+ )
288
+
289
+ comparison.run(plot_state_spaces=True)
290
+ lqr_cost, game_cost = comparison.costs()
291
+ print(f"LQR cost = {lqr_cost:.4g}, game cost = {game_cost:.4g}")
292
+ ```
293
+
294
+ Run the bundled example end-to-end:
295
+
296
+ ```bash
297
+ uv run python -m PyDiffGame.examples.MassesWithSpringsComparison
298
+ ```
299
+
300
+ The monolithic LQR is, by construction, the optimal controller for the *aggregate* of the
301
+ game's objectives. The **fully decomposed** modal game — where each vibration mode is
302
+ solved as an independent player, coupled only through the shared dynamics — reproduces that
303
+ monolithic optimum **to numerical precision**: the two state trajectories coincide
304
+ (they differ by ~10⁻⁷) and the costs are equal:
305
+
306
+ <p align="center">
307
+ <img alt="State trajectories: the decomposed game reproduces the monolithic LQR" src="images/readme/masses_game_vs_lqr.png" width="860"/>
308
+ </p>
309
+
310
+ <p align="center">
311
+ <img alt="Cost comparison: the modal game recovers the LQR optimum" src="images/readme/masses_cost.png" width="440"/>
312
+ </p>
313
+
314
+ For this modally-decoupled system the decomposition is **lossless** — and it buys
315
+ **compositionality**: you can add, drop or re-weight a control task by editing a single
316
+ player, without re-tuning one monolithic cost matrix.
317
+
318
+ > The figures above are regenerated from the live solver by
319
+ > [`tools/generate_readme_figures.py`](tools/generate_readme_figures.py)
320
+ > (`uv run python tools/generate_readme_figures.py`), so they always match the current code.
321
+
322
+ # More examples
323
+
324
+ The [`src/PyDiffGame/examples`](https://github.com/krichelj/PyDiffGame/tree/master/src/PyDiffGame/examples)
325
+ directory contains further worked comparisons:
326
+
327
+ | Example | System |
328
+ | --- | --- |
329
+ | [`MassesWithSpringsComparison.py`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/MassesWithSpringsComparison.py) | Chain of masses coupled by springs (the tutorial above) |
330
+ | [`InvertedPendulumComparison.py`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/InvertedPendulumComparison.py) | Inverted pendulum on a cart |
331
+ | [`PVTOL.py`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/PVTOL.py) · [`PVTOLComparison.py`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/PVTOLComparison.py) | Planar vertical take-off & landing aircraft |
332
+ | [`QuadRotorControl.py`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/QuadRotorControl.py) | Quadrotor attitude / position control |
333
+
334
+ # Testing and development
335
+
336
+ The package ships with a `pytest` suite that validates the solvers against analytical
337
+ ground truth — LQR solutions are checked against `scipy`'s algebraic Riccati solvers, and
338
+ the games against their coupled-Riccati residuals and closed-loop stability. All tooling
339
+ runs through uv:
340
+
341
+ ```bash
342
+ uv sync --extra dev
343
+ uv run pytest # run the test suite
344
+ uv run ruff format src/PyDiffGame tests # auto-format (black-compatible)
345
+ uv run ruff check src/PyDiffGame tests # lint
346
+ uv run mypy src/PyDiffGame # type-check
347
+ ```
348
+
349
+ Continuous integration runs the formatter check, linter, type checker and full suite on
350
+ Python 3.11–3.14. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
351
+
352
+ # Citing
353
+
354
+ If you use this work, please cite our paper:
355
+
356
+ ```bibtex
357
+ @inproceedings{pydiffgame_paper,
358
+ author={Kricheli, Joshua Shay and Sadon, Aviran and Arogeti, Shai and Regev, Shimon and Weiss, Gera},
359
+ booktitle={29th Mediterranean Conference on Control and Automation (MED 2021)},
360
+ title={{Composition of Dynamic Control Objectives Based on Differential Games}},
361
+ year={2021},
362
+ pages={298-304},
363
+ doi={10.1109/MED51440.2021.9480269}}
364
+ ```
365
+
366
+ Further details can be found in the [citation document](CITATIONS.bib).
367
+
368
+ # Acknowledgments
369
+
370
+ This research was supported in part by the Leona M. and Harry B. Helmsley Charitable Trust
371
+ through the _‘Agricultural, Biological and Cognitive Robotics Initiative’_ (‘ABC’) and by
372
+ the Marcus Endowment Fund, both at Ben-Gurion University of the Negev, Israel. It was also
373
+ supported by The _‘Israeli Smart Transportation Research Center’_ (‘ISTRC’) by The Technion
374
+ and Bar-Ilan Universities, Israel.
375
+
376
+ <p align="center">
377
+ <a href="https://istrc.net.technion.ac.il/">
378
+ <img src="images/Logo_ISTRC_Green_English.png" height="80" alt="ISTRC"/>
379
+ </a>
380
+ &emsp;
381
+ <a href="https://in.bgu.ac.il/en/Pages/default.aspx">
382
+ <picture>
383
+ <source media="(prefers-color-scheme: dark)" srcset="https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTZ8GbtJiX8lNUygX7-inRBuWESK438jWbRjQ&s">
384
+ <source media="(prefers-color-scheme: light)" srcset="https://tamrur.bgu.ac.il/restore/BGU.sig.png">
385
+ <img alt="Ben-Gurion University of the Negev" src="https://tamrur.bgu.ac.il/restore/BGU.sig.png" height="80">
386
+ </picture>
387
+ </a>
388
+ &emsp;
389
+ <a href="https://helmsleytrust.org/">
390
+ <picture>
391
+ <source media="(prefers-color-scheme: dark)" srcset="https://helmsleytrust.org/wp-content/themes/helmsley/assets/img/helmsley-charitable-trust-logo-white.png">
392
+ <source media="(prefers-color-scheme: light)" srcset="https://helmsleytrust.org/wp-content/themes/helmsley/assets/img/helmsley-charitable-trust-logo-blue.png">
393
+ <img alt="Helmsley Charitable Trust" src="https://helmsleytrust.org/wp-content/themes/helmsley/assets/img/helmsley-charitable-trust-logo-blue.png" height="80">
394
+ </picture>
395
+ </a>
396
+ </p>
397
+
398
+ # Star history
399
+
400
+ <p align="center">
401
+ <a href="https://star-history.com/#krichelj/PyDiffGame&Date">
402
+ <picture>
403
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=krichelj/PyDiffGame&type=Date&theme=dark"/>
404
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=krichelj/PyDiffGame&type=Date"/>
405
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=krichelj/PyDiffGame&type=Date" width="600"/>
406
+ </picture>
407
+ </a>
408
+ </p>