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/__init__.py +50 -0
- PyDiffGame/_typing.py +25 -0
- PyDiffGame/base.py +468 -0
- PyDiffGame/comparison.py +121 -0
- PyDiffGame/continuous.py +223 -0
- PyDiffGame/discrete.py +211 -0
- PyDiffGame/examples/InvertedPendulumComparison.py +211 -236
- PyDiffGame/examples/MassesWithSpringsComparison.py +109 -208
- PyDiffGame/examples/PVTOL.py +143 -149
- PyDiffGame/examples/PVTOLComparison.py +75 -69
- PyDiffGame/examples/QuadRotorControl.py +394 -304
- PyDiffGame/lqr.py +30 -0
- PyDiffGame/objective.py +108 -0
- PyDiffGame/plotting.py +98 -0
- pydiffgame-2.0.0.dist-info/METADATA +408 -0
- {pydiffgame-0.1.2.dist-info → pydiffgame-2.0.0.dist-info}/RECORD +18 -16
- {pydiffgame-0.1.2.dist-info → pydiffgame-2.0.0.dist-info}/WHEEL +1 -1
- PyDiffGame/ContinuousPyDiffGame.py +0 -275
- PyDiffGame/DiscretePyDiffGame.py +0 -359
- PyDiffGame/LQR.py +0 -73
- PyDiffGame/Objective.py +0 -62
- PyDiffGame/PyDiffGame.py +0 -1273
- PyDiffGame/PyDiffGameLQRComparison.py +0 -169
- pydiffgame-0.1.2.dist-info/METADATA +0 -306
- {pydiffgame-0.1.2.dist-info → pydiffgame-2.0.0.dist-info}/licenses/LICENSE +0 -0
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"]
|
PyDiffGame/objective.py
ADDED
|
@@ -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
|
+
 
|
|
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
|
+
 
|
|
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>
|