Skip to content

Commit 2f8fa20

Browse files
authored
Robust H∞ control + 13-system PyDiffGame-vs-python-control benchmark study (#30)
Robust H∞ control + a 13-system PyDiffGame-vs-python-control benchmark study
2 parents 110a08a + 0d3d755 commit 2f8fa20

52 files changed

Lines changed: 2587 additions & 21 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
* [Quick start](#quick-start)
2525
* [Input parameters](#input-parameters)
2626
* [Tutorial: masses on springs](#tutorial-masses-on-springs)
27-
* [More examples](#more-examples)
27+
* [Robust control: H∞ as a game](#robust-control-h-as-a-game)
28+
* [Examples — a walkthrough](#examples--a-walkthrough)
2829
* [Testing and development](#testing-and-development)
2930
* [Citing](#citing)
3031
* [Acknowledgments](#acknowledgments)
@@ -273,17 +274,73 @@ player, without re-tuning one monolithic cost matrix.
273274
> [`tools/generate_readme_figures.py`](https://github.com/krichelj/PyDiffGame/blob/master/tools/generate_readme_figures.py)
274275
> (`uv run python tools/generate_readme_figures.py`), so they always match the current code.
275276
276-
# More examples
277+
# Robust control: H∞ as a game
277278

278-
The [`src/PyDiffGame/examples`](https://github.com/krichelj/PyDiffGame/tree/master/src/PyDiffGame/examples)
279-
directory contains further worked comparisons:
279+
A game ties the centralized LQR on a shared cost — it cannot beat it. The place a
280+
differential game **provably wins** is *robustness*: classical **H∞** state feedback **is**
281+
the saddle point of a two-player zero-sum game in which the controller minimises and an
282+
adversarial disturbance maximises. PyDiffGame ships it as `ContinuousHInfinityControl`,
283+
completing the family next to the LQR and the N-player Nash game:
280284

281-
| Example | System |
282-
| --- | --- |
283-
| [`MassesWithSpringsComparison.py`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/MassesWithSpringsComparison.py) | Chain of masses coupled by springs (the tutorial above) |
284-
| [`InvertedPendulumComparison.py`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/InvertedPendulumComparison.py) | Inverted pendulum on a cart |
285-
| [`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 |
286-
| [`QuadRotorControl.py`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/QuadRotorControl.py) | Quadrotor attitude / position control |
285+
```python
286+
import numpy as np
287+
from PyDiffGame import ContinuousHInfinityControl
288+
289+
A = np.array([[0.0, 1.0], [0.0, 0.0]]) # a cart: position, velocity
290+
B = np.array([[0.0], [1.0]]) # control force
291+
B_w = np.array([[0.0], [1.0]]) # disturbance force
292+
Q, R = np.diag([1.0, 0.0]), np.array([[1.0]])
293+
294+
robust = ContinuousHInfinityControl(A, B, B_w, Q, R).solve() # picks gamma = 1.3 * gamma*
295+
print(robust.K) # robust feedback gain, u = -K x
296+
print(robust.worst_case_gain()[0]) # closed-loop ||G_zw||inf — provably below the LQR's
297+
```
298+
299+
It solves the **game** algebraic Riccati equation whose quadratic term
300+
`B R⁻¹ Bᵀ − γ⁻² B_w B_wᵀ` is *indefinite* — exactly what an ordinary LQR Riccati solver
301+
cannot do — via the Hamiltonian/Schur method, auto-finds the optimal robustness level
302+
`γ*`, and reports the formal worst-case L2 gain. Across a
303+
[13-system benchmark](https://github.com/krichelj/PyDiffGame/tree/master/benchmarks)
304+
(carts, vehicles, aircraft, drones, flexible structures) it reduces the worst-case
305+
disturbance gain on every system, **practically significantly on 10/13** — most where the
306+
LQR leaves a sharp resonant peak — each at a documented nominal-cost price:
307+
308+
<p align="center">
309+
<img alt="H-infinity game vs LQR under worst-case disturbance (inverted pendulum)" src="https://raw.githubusercontent.com/krichelj/PyDiffGame/bbd010f15ee13adc2bba5e7b99e1a3ecc0238583/benchmarks/results/robust_inverted_pendulum.gif" width="860"/>
310+
</p>
311+
312+
Left: the pendulum-angle response to the worst-case disturbance (H∞ roughly halves
313+
it). Right: the `σmax(ω)` curves whose peak *is* the worst-case gain — the LQR's
314+
resonant peak (≈1.92) flattened by the H∞ game to ≈1.25. The
315+
[**robustness showcase**](https://github.com/krichelj/PyDiffGame/blob/master/benchmarks/README.md)
316+
animates every system.
317+
318+
# Examples — a walkthrough
319+
320+
The package ships four worked **LQR-vs-game comparisons** under
321+
[`src/PyDiffGame/examples`](https://github.com/krichelj/PyDiffGame/tree/master/src/PyDiffGame/examples);
322+
each builds a system, designs an LQR and a decomposed game on it, runs both and reports the
323+
costs. Run any of them with `uv run python -m PyDiffGame.examples.<name>`:
324+
325+
| Example | System | What it shows |
326+
| --- | --- | --- |
327+
| [`MassesWithSpringsComparison`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/MassesWithSpringsComparison.py) | Chain of masses coupled by springs | The **lossless** modal decomposition (the tutorial above): the game reproduces the monolithic LQR optimum |
328+
| [`InvertedPendulumComparison`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/InvertedPendulumComparison.py) | Inverted pendulum on a cart | An **unstable, underactuated** plant; the nonlinear closed loop can be simulated from the designed gains |
329+
| [`PVTOLComparison`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/PVTOLComparison.py) · [`PVTOL`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/PVTOL.py) | Planar vertical take-off & landing aircraft | A 6-state aircraft with input decomposition across players |
330+
| [`QuadRotorControl`](https://github.com/krichelj/PyDiffGame/blob/master/src/PyDiffGame/examples/QuadRotorControl.py) | Quadrotor attitude / position control | A larger nonlinear vehicle with a cascaded design |
331+
332+
For the **full study** — PyDiffGame vs [python-control](https://python-control.readthedocs.io/)
333+
across 13 systems on both the *nominal* cost (lossless tie / price of anarchy) and the
334+
*robustness* metric, with rendered GIFs, a metrics report and an adversarial review of the
335+
methodology — see
336+
[`benchmarks/`](https://github.com/krichelj/PyDiffGame/tree/master/benchmarks) and its
337+
[README](https://github.com/krichelj/PyDiffGame/blob/master/benchmarks/README.md):
338+
339+
```bash
340+
uv run --extra dev python -m benchmarks.run_masses # nominal: game == LQR (lossless)
341+
uv run --extra dev python -m benchmarks.run_anarchy # nominal: the price of anarchy
342+
uv run --extra dev python -m benchmarks.run_robust_suite # the 13-system robustness suite + report
343+
```
287344

288345
# Testing and development
289346

benchmarks/README.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# PyDiffGame benchmark study — where a differential game actually helps
2+
3+
This directory is an honest, reproducible head-to-head between **PyDiffGame** and
4+
the **[python-control](https://python-control.readthedocs.io/)** package across a
5+
catalogue of standard control systems (carts, vehicles, aircraft, drones and
6+
flexible structures), with rendered GIFs and a formal verification of *where*
7+
the differential-game approach beats classical optimal control — and where it
8+
honestly does not.
9+
10+
## TL;DR (the honest scientific bottom line)
11+
12+
1. **On a single shared quadratic cost, a differential game cannot beat a
13+
centralized LQR — at best it ties it.** This is not a tuning failure, it is
14+
theory: the centralized LQR is the optimum of that cost, and a Nash game is a
15+
*constrained* (decomposed) design, so its cost is `>= LQR` (price of anarchy),
16+
with equality when the decomposition is lossless. We verify the *lossless*
17+
case directly: on the masses-on-springs system PyDiffGame's modal game
18+
reproduces the python-control LQR **to ~5e-12 on every metric**, disturbances
19+
included. No boost — and we say so.
20+
21+
2. **The real, formally-verifiable win is robustness.** Robust (H-infinity)
22+
control *is* a differential game — the saddle point of a controller-vs-
23+
adversarial-disturbance zero-sum game. PyDiffGame now ships this as
24+
`ContinuousHInfinityControl`, and it **provably reduces the worst-case
25+
disturbance gain** an LQR leaves on the table — at a documented nominal-cost
26+
price, and only when the plant has worst-case gain to recover.
27+
28+
## What is measured
29+
30+
For every system we design two state-feedback controllers on the **same**
31+
weights `(Q, R)`:
32+
33+
| controller | what it optimizes |
34+
| --- | --- |
35+
| `control.lqr` (python-control) | nominal cost (no disturbance) |
36+
| `PyDiffGame.ContinuousHInfinityControl` | worst-case disturbance gain (the game) |
37+
38+
and report, on the same closed loop:
39+
40+
- **`‖G_zw‖∞`** — the closed-loop worst-case L2 gain from the disturbance to the
41+
weighted performance output `z = [Q^{1/2}x; R^{1/2}u]` (the formal robustness
42+
metric; lower is more robust). Computed slycot-free by a refined frequency
43+
sweep.
44+
- **time-domain peak** of the output under the single worst-case sinusoidal
45+
disturbance (at the LQR's most vulnerable frequency).
46+
- **nominal LQ cost penalty** — how much nominal performance the robust design
47+
gives up (always `>= 0`, since the LQR is the nominal optimum; this is the
48+
*price* of robustness, reported honestly alongside the gain).
49+
50+
## Nominal regime (two honest outcomes)
51+
52+
On the shared cost a game never beats the centralized LQR; it either ties it or
53+
pays a small price. Both happen, and both are shown:
54+
55+
- **Lossless tie**`run_masses.py`: with the *modal* decomposition the
56+
objectives decouple, so PyDiffGame's Nash game reproduces the LQR to ~5e-12 on
57+
every metric (`results/masses_pdg_vs_lqr.gif`).
58+
- **Price of anarchy**`run_anarchy.py`: two carts with *competing*
59+
per-cart objectives and one actuator each. The decentralized Nash equilibrium
60+
costs **+0.33%** on the joint objective vs the centralized LQR — while using
61+
*less* control energy and overshoot (`results/coupled_carts_anarchy.gif`). The
62+
tiny price buys decentralization and compositionality.
63+
64+
## Results (robustness)
65+
66+
See [`results/ROBUSTNESS_REPORT.md`](results/ROBUSTNESS_REPORT.md) for the full
67+
auto-generated table, and `results/robust_<system>.gif` for each animation
68+
(left: time response to the worst-case disturbance; right: the `σmax(ω)` curves
69+
whose peak *is* `‖G_zw‖∞`).
70+
71+
<p align="center">
72+
<img alt="H-infinity game vs LQR under worst-case disturbance (inverted pendulum)" src="results/robust_inverted_pendulum.gif" width="820"/>
73+
</p>
74+
75+
*Inverted pendulum: the LQR leaves a sharp resonant peak (`σmax ≈ 1.92`) that the
76+
H∞ game flattens to `≈ 1.25` (−35% worst-case gain), roughly halving the
77+
pendulum-angle response to the worst-case disturbance — at a documented nominal-cost
78+
price. The honest high-frequency trade-off (H∞ slightly higher past the peak) is
79+
visible too.*
80+
81+
Headline (honest): all 13 systems show a *relative* worst-case-gain reduction,
82+
but only the ones with a **non-negligible absolute gain** matter in practice —
83+
**10/13 are practically significant** (inverted pendulum +35%, PVTOL/quadrotor
84+
+26%, seismic building +24%, flexible two-mass / cart / DC motor ~+22%, gantry
85+
crane / active suspension / aircraft ~+15%, ...), exactly the lightly-damped and
86+
unstable plants where the LQR leaves a sharp resonant peak. The two cars
87+
(cruise, bicycle) have an absolute worst-case gain of ~0 — the disturbance is
88+
already rejected by *any* reasonable controller — so their real relative
89+
reductions are **not practically meaningful**, and we say so rather than headline
90+
a "10/10 win". (The bicycle's earlier apparent "tie" turned out to be a `γ*`
91+
numerical artifact, caught by the methodology review and fixed; the corrected
92+
result is a real-but-immaterial relative reduction.)
93+
94+
## Reproduce
95+
96+
```bash
97+
uv run --extra dev python -m benchmarks.run_masses # nominal: game == LQR (lossless)
98+
uv run --extra dev python -m benchmarks.robust_compare # one robust comparison + GIF
99+
uv run --extra dev python -m benchmarks.run_robust_suite # the full 10-system suite + report
100+
```
101+
102+
## Rigor
103+
104+
The catalogue models were verified entry-for-entry against the controls /
105+
vehicle-dynamics / flight-dynamics literature, and the comparison methodology
106+
(GARE solve, `γ*` search, the worst-case-gain metric, the nominal-cost
107+
accounting, and the fairness of scoring both controllers on the same output) was
108+
adversarially reviewed; the review hardened PyDiffGame's `γ*` search against a
109+
boundary numerical instability (now regression-tested).

benchmarks/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)