PyDiffGame 0.1.2__tar.gz → 2.0.0__tar.gz
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-0.1.2 → pydiffgame-2.0.0}/.github/workflows/python-publish.yml +8 -7
- pydiffgame-2.0.0/.github/workflows/tests.yml +51 -0
- pydiffgame-2.0.0/.gitignore +26 -0
- pydiffgame-2.0.0/.pre-commit-config.yaml +29 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/CODE_OF_CONDUCT.md +1 -1
- pydiffgame-2.0.0/CONTRIBUTING.md +44 -0
- pydiffgame-2.0.0/PKG-INFO +408 -0
- pydiffgame-2.0.0/README.md +349 -0
- pydiffgame-2.0.0/docs/README.md +266 -0
- pydiffgame-2.0.0/images/readme/masses_cost.png +0 -0
- pydiffgame-2.0.0/images/readme/masses_game_vs_lqr.png +0 -0
- pydiffgame-2.0.0/images/readme/masses_schematic.png +0 -0
- pydiffgame-2.0.0/pyproject.toml +94 -0
- pydiffgame-2.0.0/requirements.txt +8 -0
- pydiffgame-2.0.0/src/PyDiffGame/__init__.py +50 -0
- pydiffgame-2.0.0/src/PyDiffGame/_typing.py +25 -0
- pydiffgame-2.0.0/src/PyDiffGame/base.py +468 -0
- pydiffgame-2.0.0/src/PyDiffGame/comparison.py +121 -0
- pydiffgame-2.0.0/src/PyDiffGame/continuous.py +223 -0
- pydiffgame-2.0.0/src/PyDiffGame/discrete.py +211 -0
- pydiffgame-2.0.0/src/PyDiffGame/examples/InvertedPendulumComparison.py +232 -0
- pydiffgame-2.0.0/src/PyDiffGame/examples/MassesWithSpringsComparison.py +119 -0
- pydiffgame-2.0.0/src/PyDiffGame/examples/PVTOL.py +216 -0
- pydiffgame-2.0.0/src/PyDiffGame/examples/PVTOLComparison.py +117 -0
- pydiffgame-2.0.0/src/PyDiffGame/examples/QuadRotorControl.py +638 -0
- pydiffgame-2.0.0/src/PyDiffGame/lqr.py +30 -0
- pydiffgame-2.0.0/src/PyDiffGame/objective.py +108 -0
- pydiffgame-2.0.0/src/PyDiffGame/plotting.py +98 -0
- pydiffgame-2.0.0/tests/conftest.py +9 -0
- pydiffgame-2.0.0/tests/test_discrete.py +50 -0
- pydiffgame-2.0.0/tests/test_examples.py +75 -0
- pydiffgame-2.0.0/tests/test_game.py +45 -0
- pydiffgame-2.0.0/tests/test_lqr.py +66 -0
- pydiffgame-2.0.0/tests/test_objective.py +45 -0
- pydiffgame-2.0.0/tests/test_simulation.py +60 -0
- pydiffgame-2.0.0/tools/generate_readme_figures.py +336 -0
- pydiffgame-2.0.0/uv.lock +1298 -0
- pydiffgame-0.1.2/CONTRIBUTING.md +0 -18
- pydiffgame-0.1.2/PKG-INFO +0 -306
- pydiffgame-0.1.2/README.md +0 -292
- pydiffgame-0.1.2/docs/README.md +0 -277
- pydiffgame-0.1.2/pyproject.toml +0 -26
- pydiffgame-0.1.2/requirements.txt +0 -6
- pydiffgame-0.1.2/src/PyDiffGame/ContinuousPyDiffGame.py +0 -275
- pydiffgame-0.1.2/src/PyDiffGame/DiscretePyDiffGame.py +0 -359
- pydiffgame-0.1.2/src/PyDiffGame/LQR.py +0 -73
- pydiffgame-0.1.2/src/PyDiffGame/Objective.py +0 -62
- pydiffgame-0.1.2/src/PyDiffGame/PyDiffGame.py +0 -1273
- pydiffgame-0.1.2/src/PyDiffGame/PyDiffGameLQRComparison.py +0 -169
- pydiffgame-0.1.2/src/PyDiffGame/__init__.py +0 -0
- pydiffgame-0.1.2/src/PyDiffGame/examples/InvertedPendulumComparison.py +0 -257
- pydiffgame-0.1.2/src/PyDiffGame/examples/MassesWithSpringsComparison.py +0 -218
- pydiffgame-0.1.2/src/PyDiffGame/examples/PVTOL.py +0 -222
- pydiffgame-0.1.2/src/PyDiffGame/examples/PVTOLComparison.py +0 -111
- pydiffgame-0.1.2/src/PyDiffGame/examples/QuadRotorControl.py +0 -548
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/CITATIONS.bib +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/LICENSE +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/_config.yml +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/images/Logo_ISTRC_Green_English.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/images/logo.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/images/logo_abc.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/2/2-players_large_1.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/2/2-players_large_2.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/2/LQR_large_1.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/2/LQR_large_2.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/2/two_masses_tikz.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/4/4-players_large_1.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/4/4-players_large_2.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/4/LQR_large_1.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/4/LQR_large_2.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/8/8-players_large_1.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/8/8-players_large_2.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/8/LQR_large_1.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/8/LQR_large_2.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/PVTOL/PVTOL1.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/PVTOL/PVTOL10.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/PVTOL/PVTOL100.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/PVTOL/PVTOL1000.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/PVTOL0001.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/PVTOL001.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/PVTOL01.png +0 -0
- {pydiffgame-0.1.2 → pydiffgame-2.0.0}/src/PyDiffGame/examples/figures/PVTOL1.png +0 -0
|
@@ -11,6 +11,7 @@ name: Upload Python Package
|
|
|
11
11
|
on:
|
|
12
12
|
release:
|
|
13
13
|
types: [published]
|
|
14
|
+
workflow_dispatch:
|
|
14
15
|
|
|
15
16
|
permissions:
|
|
16
17
|
contents: read
|
|
@@ -22,15 +23,11 @@ jobs:
|
|
|
22
23
|
steps:
|
|
23
24
|
- uses: actions/checkout@v4
|
|
24
25
|
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
python-version: "3.10"
|
|
26
|
+
- name: Install uv
|
|
27
|
+
uses: astral-sh/setup-uv@v4
|
|
28
28
|
|
|
29
29
|
- name: Build release distributions
|
|
30
|
-
run:
|
|
31
|
-
# NOTE: put your own distribution build steps here.
|
|
32
|
-
python -m pip install build
|
|
33
|
-
python -m build
|
|
30
|
+
run: uv build
|
|
34
31
|
|
|
35
32
|
- name: Upload distributions
|
|
36
33
|
uses: actions/upload-artifact@v4
|
|
@@ -64,6 +61,10 @@ jobs:
|
|
|
64
61
|
name: release-dists
|
|
65
62
|
path: dist/
|
|
66
63
|
|
|
64
|
+
# Authentication is via PyPI Trusted Publishing (OIDC) — no token needed.
|
|
65
|
+
# Configure the trusted publisher once on PyPI (see below) with:
|
|
66
|
+
# owner: krichelj repo: PyDiffGame
|
|
67
|
+
# workflow: python-publish.yml environment: pypi
|
|
67
68
|
- name: Publish release distributions to PyPI
|
|
68
69
|
uses: pypa/gh-action-pypi-publish@release/v1
|
|
69
70
|
with:
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master, main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: tests-${{ github.ref }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
test:
|
|
18
|
+
name: Python ${{ matrix.python-version }}
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
strategy:
|
|
21
|
+
fail-fast: false
|
|
22
|
+
matrix:
|
|
23
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
24
|
+
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
|
|
28
|
+
- name: Install uv
|
|
29
|
+
uses: astral-sh/setup-uv@v4
|
|
30
|
+
with:
|
|
31
|
+
enable-cache: true
|
|
32
|
+
|
|
33
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
34
|
+
run: uv python install ${{ matrix.python-version }}
|
|
35
|
+
|
|
36
|
+
- name: Sync the environment (locked)
|
|
37
|
+
run: uv sync --extra dev --python ${{ matrix.python-version }} --frozen
|
|
38
|
+
|
|
39
|
+
- name: Lint with ruff
|
|
40
|
+
run: uv run ruff check src/PyDiffGame tests --exclude src/PyDiffGame/examples
|
|
41
|
+
|
|
42
|
+
- name: Check formatting with ruff
|
|
43
|
+
run: uv run ruff format --check src/PyDiffGame tests
|
|
44
|
+
|
|
45
|
+
- name: Type-check with mypy
|
|
46
|
+
run: uv run mypy src/PyDiffGame --exclude 'examples'
|
|
47
|
+
|
|
48
|
+
- name: Run the test suite
|
|
49
|
+
env:
|
|
50
|
+
MPLBACKEND: Agg
|
|
51
|
+
run: uv run pytest -q
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
.eggs/
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.so
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv*/
|
|
13
|
+
venv/
|
|
14
|
+
env/
|
|
15
|
+
|
|
16
|
+
# Test / coverage
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.coverage
|
|
19
|
+
htmlcov/
|
|
20
|
+
.mypy_cache/
|
|
21
|
+
.ruff_cache/
|
|
22
|
+
|
|
23
|
+
# OS / editors
|
|
24
|
+
.DS_Store
|
|
25
|
+
.idea/
|
|
26
|
+
.vscode/
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Pre-commit hooks for PyDiffGame.
|
|
2
|
+
# Install once with: pre-commit install
|
|
3
|
+
# Run on everything: pre-commit run --all-files
|
|
4
|
+
repos:
|
|
5
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
6
|
+
rev: v5.0.0
|
|
7
|
+
hooks:
|
|
8
|
+
- id: trailing-whitespace
|
|
9
|
+
- id: end-of-file-fixer
|
|
10
|
+
- id: check-yaml
|
|
11
|
+
- id: check-toml
|
|
12
|
+
- id: check-added-large-files
|
|
13
|
+
- id: check-merge-conflict
|
|
14
|
+
|
|
15
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
16
|
+
rev: v0.8.4
|
|
17
|
+
hooks:
|
|
18
|
+
# Linter (with autofix), then the black-compatible formatter.
|
|
19
|
+
- id: ruff
|
|
20
|
+
args: [--fix]
|
|
21
|
+
- id: ruff-format
|
|
22
|
+
|
|
23
|
+
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
24
|
+
rev: v1.13.0
|
|
25
|
+
hooks:
|
|
26
|
+
- id: mypy
|
|
27
|
+
files: ^src/PyDiffGame/
|
|
28
|
+
exclude: ^src/PyDiffGame/examples/
|
|
29
|
+
additional_dependencies: [numpy]
|
|
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
|
|
60
60
|
|
|
61
61
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
62
62
|
reported to the community leaders responsible for enforcement at
|
|
63
|
-
.
|
|
63
|
+
skricheli2@gmail.com.
|
|
64
64
|
All complaints will be reviewed and investigated promptly and fairly.
|
|
65
65
|
|
|
66
66
|
All community leaders are obligated to respect the privacy and security of the
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Contribution Guidelines
|
|
2
|
+
|
|
3
|
+
This repo is part of a research conducted at Ben Gurion University,
|
|
4
|
+
and is thus open source. We would love to receive your input!
|
|
5
|
+
|
|
6
|
+
Please ensure your pull request adheres to the following guidelines:
|
|
7
|
+
|
|
8
|
+
- Search previous suggestions before making a new one, as yours may be a duplicate.
|
|
9
|
+
- Make an individual pull request for each suggestion.
|
|
10
|
+
- New categories or improvements to the existing categorization are welcome.
|
|
11
|
+
- Check your spelling and grammar.
|
|
12
|
+
- Make sure your text editor is set to remove trailing whitespace.
|
|
13
|
+
- The pull request and commit should have a useful title.
|
|
14
|
+
|
|
15
|
+
## Development setup
|
|
16
|
+
|
|
17
|
+
PyDiffGame requires **Python >= 3.11** and uses [uv](https://docs.astral.sh/uv/)
|
|
18
|
+
for environment and dependency management. Install uv, sync the locked
|
|
19
|
+
development environment, and enable the pre-commit hooks so the code-quality
|
|
20
|
+
tools run automatically on every commit:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# install uv: https://docs.astral.sh/uv/getting-started/installation/
|
|
24
|
+
uv sync --extra dev
|
|
25
|
+
uv run pre-commit install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`uv sync` creates the virtual environment and installs the exact, locked
|
|
29
|
+
dependencies. Run the tooling through `uv run`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv run ruff format src/PyDiffGame tests # auto-format (black-compatible)
|
|
33
|
+
uv run ruff check src/PyDiffGame tests # lint
|
|
34
|
+
uv run mypy src/PyDiffGame # type-check
|
|
35
|
+
uv run pytest # test suite
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Continuous integration runs the formatter check, the linter, the type checker
|
|
39
|
+
and the full suite (all via uv) across Python 3.11–3.14, so please make sure
|
|
40
|
+
they pass locally.
|
|
41
|
+
|
|
42
|
+
Thank you for your contribution!
|
|
43
|
+
|
|
44
|
+
Joshua Shay Kricheli
|
|
@@ -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>
|