cranebench 0.1.2__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.
- cranebench/__init__.py +38 -0
- cranebench/batch.py +338 -0
- cranebench/controllers/__init__.py +8 -0
- cranebench/controllers/base.py +62 -0
- cranebench/controllers/classical.py +140 -0
- cranebench/controllers/sliding.py +90 -0
- cranebench/integrate.py +112 -0
- cranebench/ledger.py +58 -0
- cranebench/metrics.py +125 -0
- cranebench/plants/__init__.py +9 -0
- cranebench/plants/_generated.py +67 -0
- cranebench/plants/base.py +216 -0
- cranebench/plants/dual.py +202 -0
- cranebench/plants/planar.py +146 -0
- cranebench/plants/spatial.py +187 -0
- cranebench/reference.py +100 -0
- cranebench/runner.py +153 -0
- cranebench/stats.py +179 -0
- cranebench/uncertainty.py +80 -0
- cranebench/wind/__init__.py +8 -0
- cranebench/wind/dryden.py +83 -0
- cranebench/wind/kaimal.py +85 -0
- cranebench-0.1.2.dist-info/METADATA +159 -0
- cranebench-0.1.2.dist-info/RECORD +27 -0
- cranebench-0.1.2.dist-info/WHEEL +5 -0
- cranebench-0.1.2.dist-info/licenses/LICENSE.txt +8 -0
- cranebench-0.1.2.dist-info/top_level.txt +1 -0
cranebench/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cranebench -- a reproducible benchmark for underactuated crane control.
|
|
3
|
+
|
|
4
|
+
The package deliberately contains *baseline* controllers and *infrastructure*
|
|
5
|
+
only. It fixes the plants, the disturbance models, the uncertainty design, the
|
|
6
|
+
frozen metric module and the seed ledger, so that any new controller can be
|
|
7
|
+
evaluated on the same bench against the same realisations.
|
|
8
|
+
|
|
9
|
+
Design rules (docs/DESIGN.md):
|
|
10
|
+
|
|
11
|
+
1. The metric module is frozen. Metrics are computed by a single function whose
|
|
12
|
+
source hash is recorded in every result file.
|
|
13
|
+
2. Plants are pure: derivatives depend only on (t, state, input, disturbance,
|
|
14
|
+
parameters). No controller may mutate plant state.
|
|
15
|
+
3. Every stochastic quantity comes from an explicitly seeded generator whose
|
|
16
|
+
seed is written to the ledger.
|
|
17
|
+
4. Uncertainty realisations are drawn once and reused across controllers, so
|
|
18
|
+
every comparison is paired.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.2"
|
|
22
|
+
|
|
23
|
+
from .metrics import METRIC_HASH, Metrics, compute_metrics
|
|
24
|
+
from .uncertainty import UncertaintyDesign, lhs_design
|
|
25
|
+
from .ledger import Ledger
|
|
26
|
+
from .runner import run_campaign, run_single
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"__version__",
|
|
30
|
+
"METRIC_HASH",
|
|
31
|
+
"Metrics",
|
|
32
|
+
"compute_metrics",
|
|
33
|
+
"UncertaintyDesign",
|
|
34
|
+
"lhs_design",
|
|
35
|
+
"Ledger",
|
|
36
|
+
"run_campaign",
|
|
37
|
+
"run_single",
|
|
38
|
+
]
|
cranebench/batch.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Batched campaign path for the planar plant.
|
|
2
|
+
|
|
3
|
+
The scalar path of :mod:`cranebench.runner` is the reference implementation:
|
|
4
|
+
readable, general over all three plants, and the one the verification tests are
|
|
5
|
+
written against. It is also slow -- roughly 0.8 s per 40 s run -- which puts a
|
|
6
|
+
2500-run campaign at about half an hour of single-core time and a 10^4-sample
|
|
7
|
+
campaign out of reach.
|
|
8
|
+
|
|
9
|
+
This module integrates the whole Monte Carlo ensemble at once: the state is an
|
|
10
|
+
``(N, nx)`` array, the plant parameters are ``(N,)`` arrays, and one RK4 step
|
|
11
|
+
advances every sample together. The controllers are the same control laws
|
|
12
|
+
written elementwise. Gains that require a per-sample setup (the LQR
|
|
13
|
+
linearisation and Riccati solution, the equilibrium input, the ZVD shaper
|
|
14
|
+
timing) are computed by calling the *scalar* code on a scalar plant built from
|
|
15
|
+
that sample's parameters, so the batched path cannot drift away from the
|
|
16
|
+
reference by re-deriving them.
|
|
17
|
+
|
|
18
|
+
``tests/test_batch.py`` asserts that the two paths agree to 1e-9 on every metric
|
|
19
|
+
over a full paired design. Only the planar plant is batched; the assembled
|
|
20
|
+
spatial and dual plants keep the scalar path.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import copy
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Dict, List
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from .controllers.base import input_matrix, state_matrix, trim
|
|
32
|
+
from .metrics import compute_metrics
|
|
33
|
+
from .plants import PlanarCrane
|
|
34
|
+
from .plants.planar import G
|
|
35
|
+
from .reference import Manoeuvre
|
|
36
|
+
from .uncertainty import apply_factors
|
|
37
|
+
from .wind import WIND_PARAMS, WINDS
|
|
38
|
+
|
|
39
|
+
FIELDS = ("m_trolley", "m_payload", "m_winch", "b_x", "b_l", "c_theta",
|
|
40
|
+
"l0", "area", "cd")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------- #
|
|
44
|
+
# plant
|
|
45
|
+
# --------------------------------------------------------------------- #
|
|
46
|
+
@dataclass
|
|
47
|
+
class BatchPlanar:
|
|
48
|
+
"""Planar crane replicated over ``N`` parameter samples."""
|
|
49
|
+
|
|
50
|
+
n: int
|
|
51
|
+
par: Dict[str, np.ndarray]
|
|
52
|
+
u_max: np.ndarray # (N, 2)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_samples(cls, plants: List[PlanarCrane]):
|
|
56
|
+
par = {f: np.array([getattr(p.p, f) for p in plants], float)
|
|
57
|
+
for f in FIELDS}
|
|
58
|
+
u_max = np.array([p.p.u_max for p in plants], float)
|
|
59
|
+
return cls(len(plants), par, u_max)
|
|
60
|
+
|
|
61
|
+
def initial_state(self) -> np.ndarray:
|
|
62
|
+
X = np.zeros((self.n, 6))
|
|
63
|
+
X[:, 1] = self.par["l0"]
|
|
64
|
+
return X
|
|
65
|
+
|
|
66
|
+
def dynamics(self, t, X, U, fw):
|
|
67
|
+
p = self.par
|
|
68
|
+
l = np.maximum(X[:, 1], 1e-3)
|
|
69
|
+
th = X[:, 2]
|
|
70
|
+
xd, ld, thd = X[:, 3], X[:, 4], X[:, 5]
|
|
71
|
+
s, c = np.sin(th), np.cos(th)
|
|
72
|
+
m, mt, mw = p["m_payload"], p["m_trolley"], p["m_winch"]
|
|
73
|
+
|
|
74
|
+
M = np.empty((self.n, 3, 3))
|
|
75
|
+
M[:, 0, 0] = mt + m
|
|
76
|
+
M[:, 0, 1] = M[:, 1, 0] = m * s
|
|
77
|
+
M[:, 0, 2] = M[:, 2, 0] = m * l * c
|
|
78
|
+
M[:, 1, 1] = m + mw
|
|
79
|
+
M[:, 1, 2] = M[:, 2, 1] = 0.0
|
|
80
|
+
M[:, 2, 2] = m * l * l
|
|
81
|
+
|
|
82
|
+
rhs = np.empty((self.n, 3))
|
|
83
|
+
Uc = np.clip(U, -self.u_max, self.u_max)
|
|
84
|
+
# generalised forces: drives, damping, wind through the payload Jacobian
|
|
85
|
+
rhs[:, 0] = Uc[:, 0] - p["b_x"] * xd + fw
|
|
86
|
+
rhs[:, 1] = Uc[:, 1] - p["b_l"] * ld + fw * s
|
|
87
|
+
rhs[:, 2] = -p["c_theta"] * thd + fw * l * c
|
|
88
|
+
# Coriolis / centrifugal
|
|
89
|
+
rhs[:, 0] -= 2.0 * m * ld * thd * c - m * l * thd * thd * s
|
|
90
|
+
rhs[:, 1] -= -m * l * thd * thd
|
|
91
|
+
rhs[:, 2] -= 2.0 * m * l * ld * thd
|
|
92
|
+
# gravity
|
|
93
|
+
rhs[:, 1] -= -m * G * c
|
|
94
|
+
rhs[:, 2] -= m * G * l * s
|
|
95
|
+
|
|
96
|
+
qdd = np.linalg.solve(M, rhs[:, :, None])[:, :, 0]
|
|
97
|
+
return np.concatenate([X[:, 3:], qdd], axis=1)
|
|
98
|
+
|
|
99
|
+
def payload_velocity_x(self, X):
|
|
100
|
+
"""Horizontal payload velocity for the relative-wind drag law."""
|
|
101
|
+
l, th = X[:, 1], X[:, 2]
|
|
102
|
+
return X[:, 3] + X[:, 4] * np.sin(th) + l * X[:, 5] * np.cos(th)
|
|
103
|
+
|
|
104
|
+
def outputs(self, X):
|
|
105
|
+
return {"cart": X[:, 0], "swing": X[:, 2],
|
|
106
|
+
"yaw": np.zeros(X.shape[0])}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --------------------------------------------------------------------- #
|
|
110
|
+
# controllers
|
|
111
|
+
# --------------------------------------------------------------------- #
|
|
112
|
+
class BatchController:
|
|
113
|
+
name = "base"
|
|
114
|
+
|
|
115
|
+
def setup(self, plants, man, batch):
|
|
116
|
+
self.plants, self.man, self.b = plants, man, batch
|
|
117
|
+
self.X0 = batch.initial_state()
|
|
118
|
+
self.u_eq = np.array([trim(p)[1] for p in plants], float)
|
|
119
|
+
|
|
120
|
+
def __call__(self, t, X):
|
|
121
|
+
raise NotImplementedError
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class BPD(BatchController):
|
|
125
|
+
name = "PD"
|
|
126
|
+
|
|
127
|
+
def __init__(self, ref):
|
|
128
|
+
self.r = ref # the scalar controller, for its gains
|
|
129
|
+
|
|
130
|
+
def _pd(self, t, X, pos, vel):
|
|
131
|
+
r = self.r
|
|
132
|
+
U = self.u_eq.copy()
|
|
133
|
+
U[:, 0] += r.kp * (pos - X[:, 0]) + r.kd * (vel - X[:, 3])
|
|
134
|
+
rope = self.X0[:, 1] + self.man.hoist * self.man._s(t)
|
|
135
|
+
U[:, 1] += r.kph * (rope - X[:, 1]) + r.kdh * (self.man.rope_rate(t) - X[:, 4])
|
|
136
|
+
return U
|
|
137
|
+
|
|
138
|
+
def __call__(self, t, X):
|
|
139
|
+
return self._pd(t, X, self.man.position(t), self.man.velocity(t))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class BZVD(BPD):
|
|
143
|
+
name = "ZVD"
|
|
144
|
+
|
|
145
|
+
def setup(self, plants, man, batch):
|
|
146
|
+
super().setup(plants, man, batch)
|
|
147
|
+
r = self.r
|
|
148
|
+
z = r.zeta
|
|
149
|
+
wn = np.sqrt(G / np.maximum(self.X0[:, 1], 1e-3))
|
|
150
|
+
wd = wn * np.sqrt(max(1.0 - z * z, 1e-9))
|
|
151
|
+
K = np.exp(-z * np.pi / np.sqrt(max(1.0 - z * z, 1e-9)))
|
|
152
|
+
self.amp = np.stack([np.full(batch.n, 1.0), np.full(batch.n, 2.0 * K),
|
|
153
|
+
np.full(batch.n, K * K)], 1) / (1.0 + K) ** 2
|
|
154
|
+
self.tau = np.stack([np.zeros(batch.n), np.pi / wd, 2.0 * np.pi / wd], 1)
|
|
155
|
+
self.r = r.inner # inner PD carries the tracking gains
|
|
156
|
+
self.r.kph, self.r.kdh = r.inner.kph, r.inner.kdh
|
|
157
|
+
|
|
158
|
+
def __call__(self, t, X):
|
|
159
|
+
td = t - self.tau
|
|
160
|
+
pos = np.sum(self.amp * self.man.position_v(td), axis=1)
|
|
161
|
+
vel = np.sum(self.amp * self.man.velocity_v(td), axis=1)
|
|
162
|
+
return self._pd(t, X, pos, vel)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class BLQR(BatchController):
|
|
166
|
+
name = "LQR"
|
|
167
|
+
|
|
168
|
+
def __init__(self, ref):
|
|
169
|
+
self.r = ref
|
|
170
|
+
|
|
171
|
+
def setup(self, plants, man, batch):
|
|
172
|
+
super().setup(plants, man, batch)
|
|
173
|
+
from scipy.linalg import solve_continuous_are
|
|
174
|
+
r = self.r
|
|
175
|
+
Ks = []
|
|
176
|
+
for i, p in enumerate(plants):
|
|
177
|
+
x0, ue = p.initial_state(), self.u_eq[i]
|
|
178
|
+
A = state_matrix(p, x0, ue)
|
|
179
|
+
B = input_matrix(p, x0, ue)
|
|
180
|
+
q = np.full(p.nx, r.q_rate)
|
|
181
|
+
for j in p.actuated:
|
|
182
|
+
q[j] = r.q_pos
|
|
183
|
+
for j in p.unactuated:
|
|
184
|
+
q[j] = r.q_swing
|
|
185
|
+
R = np.eye(p.nu) * r.r
|
|
186
|
+
P = solve_continuous_are(A, B, np.diag(q), R)
|
|
187
|
+
Ks.append(np.linalg.solve(R, B.T @ P))
|
|
188
|
+
self.K = np.array(Ks)
|
|
189
|
+
|
|
190
|
+
def __call__(self, t, X):
|
|
191
|
+
Xr = self.X0.copy()
|
|
192
|
+
Xr[:, 0] = self.man.position(t)
|
|
193
|
+
Xr[:, 3] = self.man.velocity(t)
|
|
194
|
+
Xr[:, 1] = self.X0[:, 1] + self.man.hoist * self.man._s(t)
|
|
195
|
+
Xr[:, 4] = self.man.rope_rate(t)
|
|
196
|
+
return self.u_eq - np.einsum("nij,nj->ni", self.K, X - Xr)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class BSMC(BatchController):
|
|
200
|
+
name = "SMC"
|
|
201
|
+
|
|
202
|
+
def __init__(self, ref):
|
|
203
|
+
self.r = ref
|
|
204
|
+
|
|
205
|
+
def _surfaces(self, t, X):
|
|
206
|
+
r, man = self.r, self.man
|
|
207
|
+
s0 = (X[:, 3] - man.velocity(t)) + r.c * (X[:, 0] - man.position(t))
|
|
208
|
+
rope = self.X0[:, 1] + man.hoist * man._s(t)
|
|
209
|
+
s1 = (X[:, 4] - man.rope_rate(t)) + r.ch * (X[:, 1] - rope)
|
|
210
|
+
return s0, s1
|
|
211
|
+
|
|
212
|
+
def __call__(self, t, X):
|
|
213
|
+
r = self.r
|
|
214
|
+
s0, s1 = self._surfaces(t, X)
|
|
215
|
+
U = self.u_eq.copy()
|
|
216
|
+
U[:, 0] += -r.k * s0 - r.eta * np.tanh(s0 / r.phi)
|
|
217
|
+
U[:, 1] += -r.kh * s1 - r.etah * np.tanh(s1 / r.phi)
|
|
218
|
+
return U
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class BHSMC(BSMC):
|
|
222
|
+
name = "HSMC"
|
|
223
|
+
|
|
224
|
+
def __call__(self, t, X):
|
|
225
|
+
r = self.r
|
|
226
|
+
s0, s1 = self._surfaces(t, X)
|
|
227
|
+
s0 = s0 + r.lam * (X[:, 5] + r.c_swing * X[:, 2])
|
|
228
|
+
U = self.u_eq.copy()
|
|
229
|
+
U[:, 0] += -r.k * s0 - r.eta * np.tanh(s0 / r.phi)
|
|
230
|
+
U[:, 1] += -r.kh * s1 - r.etah * np.tanh(s1 / r.phi)
|
|
231
|
+
return U
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
BATCH_OF = {"PD": BPD, "LQR": BLQR, "ZVD": BZVD, "SMC": BSMC, "HSMC": BHSMC}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# --------------------------------------------------------------------- #
|
|
238
|
+
# campaign
|
|
239
|
+
# --------------------------------------------------------------------- #
|
|
240
|
+
def _build(design, campaign):
|
|
241
|
+
plants, winds = [], []
|
|
242
|
+
base_p = PlanarCrane().p.__class__()
|
|
243
|
+
base_w = WIND_PARAMS.get(campaign.wind, WIND_PARAMS["kaimal"])()
|
|
244
|
+
for i, fac in enumerate(design.as_dicts()):
|
|
245
|
+
pp, wp = apply_factors(copy.deepcopy(base_p), copy.deepcopy(base_w), fac)
|
|
246
|
+
plants.append(PlanarCrane(pp))
|
|
247
|
+
if campaign.wind not in (None, "none"):
|
|
248
|
+
rng = np.random.default_rng(int(design.wind_seeds[i]))
|
|
249
|
+
winds.append(WINDS[campaign.wind](campaign.manoeuvre.t_total, wp, rng))
|
|
250
|
+
return plants, winds
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def run_campaign_batch(campaign, design, progress=True, relative=True,
|
|
254
|
+
rate_limit=True, rate_scale=1.0):
|
|
255
|
+
"""Integrate the whole ensemble at once; returns the same dict as the scalar path."""
|
|
256
|
+
campaign.build()
|
|
257
|
+
man = campaign.manoeuvre
|
|
258
|
+
dt = campaign.dt
|
|
259
|
+
plants, winds = _build(design, campaign)
|
|
260
|
+
batch = BatchPlanar.from_samples(plants)
|
|
261
|
+
n = design.n
|
|
262
|
+
|
|
263
|
+
if winds:
|
|
264
|
+
grid_dt = winds[0].dt
|
|
265
|
+
turb = np.array([w.turb for w in winds]) # (N, ngrid)
|
|
266
|
+
u_mean = np.array([w.p.u_mean for w in winds])
|
|
267
|
+
ngrid = turb.shape[1]
|
|
268
|
+
rho = 1.225
|
|
269
|
+
kdrag = 0.5 * rho * batch.par["cd"] * batch.par["area"]
|
|
270
|
+
|
|
271
|
+
def wind_force(t, X=None):
|
|
272
|
+
if not winds:
|
|
273
|
+
return np.zeros(n)
|
|
274
|
+
s = t / grid_dt
|
|
275
|
+
i = int(s)
|
|
276
|
+
if i < 0:
|
|
277
|
+
v = u_mean + turb[:, 0]
|
|
278
|
+
elif i >= ngrid - 1:
|
|
279
|
+
v = u_mean + turb[:, -1]
|
|
280
|
+
else:
|
|
281
|
+
f = s - i
|
|
282
|
+
v = u_mean + (1 - f) * turb[:, i] + f * turb[:, i + 1]
|
|
283
|
+
if X is not None and relative:
|
|
284
|
+
v = v - batch.payload_velocity_x(X)
|
|
285
|
+
return kdrag * v * np.abs(v)
|
|
286
|
+
|
|
287
|
+
nt = int(round(man.t_total / dt)) + 1
|
|
288
|
+
tgrid = np.arange(nt) * dt
|
|
289
|
+
every = max(1, int(round(campaign.control_dt / dt)))
|
|
290
|
+
|
|
291
|
+
out = {}
|
|
292
|
+
for cname, scalar_ctrl in campaign.controllers.items():
|
|
293
|
+
ctrl = BATCH_OF[cname](scalar_ctrl)
|
|
294
|
+
ctrl.setup(plants, man, batch)
|
|
295
|
+
X = batch.initial_state()
|
|
296
|
+
cart = np.empty((n, nt))
|
|
297
|
+
swing = np.empty((n, nt))
|
|
298
|
+
Uh = np.empty((n, nt, 2))
|
|
299
|
+
u_rate = (np.asarray(plants[0].p.u_rate, float) * rate_scale
|
|
300
|
+
if rate_limit else None)
|
|
301
|
+
U = ctrl(0.0, X)
|
|
302
|
+
for k in range(nt):
|
|
303
|
+
if k % every == 0:
|
|
304
|
+
cmd = ctrl(tgrid[k], X)
|
|
305
|
+
if u_rate is not None:
|
|
306
|
+
lim = u_rate * campaign.control_dt
|
|
307
|
+
U = U + np.clip(cmd - U, -lim, lim)
|
|
308
|
+
else:
|
|
309
|
+
U = cmd
|
|
310
|
+
cart[:, k], swing[:, k] = X[:, 0], X[:, 2]
|
|
311
|
+
Uh[:, k] = U
|
|
312
|
+
if k == nt - 1:
|
|
313
|
+
break
|
|
314
|
+
t = tgrid[k]
|
|
315
|
+
# the disturbance is frozen across the four RK4 stages, exactly as
|
|
316
|
+
# the scalar reference path does it; re-evaluating it mid-stage
|
|
317
|
+
# would be defensible but would no longer be the same experiment
|
|
318
|
+
fw = wind_force(t, X)
|
|
319
|
+
k1 = batch.dynamics(t, X, U, fw)
|
|
320
|
+
k2 = batch.dynamics(t + .5 * dt, X + .5 * dt * k1, U, fw)
|
|
321
|
+
k3 = batch.dynamics(t + .5 * dt, X + .5 * dt * k2, U, fw)
|
|
322
|
+
k4 = batch.dynamics(t + dt, X + dt * k3, U, fw)
|
|
323
|
+
X = X + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
|
|
324
|
+
ref = man.position_v(tgrid)
|
|
325
|
+
rows = []
|
|
326
|
+
for i in range(n):
|
|
327
|
+
outs = [{"cart": cart[i, k], "swing": swing[i, k], "yaw": 0.0}
|
|
328
|
+
for k in range(nt)]
|
|
329
|
+
rows.append(compute_metrics(tgrid, outs, Uh[i], ref,
|
|
330
|
+
horizontal_inputs=(0,),
|
|
331
|
+
swing_bound_deg=campaign.swing_bound_deg
|
|
332
|
+
).as_dict())
|
|
333
|
+
out[cname] = rows
|
|
334
|
+
if progress:
|
|
335
|
+
print(f" {cname}: {n} runs done", flush=True)
|
|
336
|
+
keys = out[next(iter(out))][0].keys()
|
|
337
|
+
return {c: {k: np.array([r[k] for r in rows], float) for k in keys}
|
|
338
|
+
for c, rows in out.items()}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from .base import Controller, input_matrix, state_matrix, trim
|
|
2
|
+
from .classical import LQR, PD, ZVD
|
|
3
|
+
from .sliding import HSMC, SMC
|
|
4
|
+
|
|
5
|
+
BASELINES = {"PD": PD, "LQR": LQR, "ZVD": ZVD, "SMC": SMC, "HSMC": HSMC}
|
|
6
|
+
|
|
7
|
+
__all__ = ["Controller", "PD", "LQR", "ZVD", "SMC", "HSMC", "BASELINES",
|
|
8
|
+
"trim", "state_matrix", "input_matrix"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Controller interface and linearisation helpers.
|
|
2
|
+
|
|
3
|
+
Every controller in the baseline set is a *published classical* design. The
|
|
4
|
+
benchmark deliberately contains no novel controller: its purpose is to fix the
|
|
5
|
+
bench, not to compete on it. A new design is added by subclassing
|
|
6
|
+
:class:`Controller` in the user's own package and passing it to the runner.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Controller(ABC):
|
|
17
|
+
name: str = "base"
|
|
18
|
+
|
|
19
|
+
def reset(self, plant, manoeuvre) -> None:
|
|
20
|
+
self.plant = plant
|
|
21
|
+
self.man = manoeuvre
|
|
22
|
+
self.x0 = plant.initial_state()
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def __call__(self, t: float, x: np.ndarray) -> np.ndarray:
|
|
26
|
+
"""Control input at time ``t``. Must not mutate ``x``."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def input_matrix(plant, x0, u0, h=1e-4):
|
|
30
|
+
"""B = df/du by central differences (dynamics are affine in u, so exact)."""
|
|
31
|
+
B = np.zeros((plant.nx, plant.nu))
|
|
32
|
+
for j in range(plant.nu):
|
|
33
|
+
up, um = np.array(u0, float), np.array(u0, float)
|
|
34
|
+
up[j] += h
|
|
35
|
+
um[j] -= h
|
|
36
|
+
d = np.zeros(3)
|
|
37
|
+
B[:, j] = (plant.dynamics(0.0, x0, up, d)
|
|
38
|
+
- plant.dynamics(0.0, x0, um, d)) / (2 * h)
|
|
39
|
+
return B
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def state_matrix(plant, x0, u0, h=1e-6):
|
|
43
|
+
A = np.zeros((plant.nx, plant.nx))
|
|
44
|
+
d = np.zeros(3)
|
|
45
|
+
for j in range(plant.nx):
|
|
46
|
+
xp, xm = np.array(x0, float), np.array(x0, float)
|
|
47
|
+
s = h * max(1.0, abs(x0[j]))
|
|
48
|
+
xp[j] += s
|
|
49
|
+
xm[j] -= s
|
|
50
|
+
A[:, j] = (plant.dynamics(0.0, xp, u0, d)
|
|
51
|
+
- plant.dynamics(0.0, xm, u0, d)) / (2 * s)
|
|
52
|
+
return A
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def trim(plant):
|
|
56
|
+
"""Equilibrium input at the nominal initial state (least squares)."""
|
|
57
|
+
x0 = plant.initial_state()
|
|
58
|
+
u0 = np.zeros(plant.nu)
|
|
59
|
+
f0 = plant.dynamics(0.0, x0, u0, np.zeros(3))
|
|
60
|
+
B = input_matrix(plant, x0, u0)
|
|
61
|
+
u_eq, *_ = np.linalg.lstsq(B, -f0, rcond=None)
|
|
62
|
+
return x0, u_eq
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""PD, LQR and ZVD input shaping -- the three standard baselines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.linalg import solve_continuous_are
|
|
7
|
+
|
|
8
|
+
from .base import Controller, input_matrix, state_matrix, trim
|
|
9
|
+
|
|
10
|
+
G = 9.80665
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PD(Controller):
|
|
14
|
+
"""Position-only proportional-derivative control on the actuated axes.
|
|
15
|
+
|
|
16
|
+
Carries no payload state, and is therefore the control experiment for any
|
|
17
|
+
claim that a controller "needs" the swing state: whatever a richer design
|
|
18
|
+
achieves must be measured against what this achieves without it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
name = "PD"
|
|
22
|
+
|
|
23
|
+
def __init__(self, kp=6.0e3, kd=2.4e4, kp_hoist=4.0e4, kd_hoist=6.0e4):
|
|
24
|
+
self.kp, self.kd = kp, kd
|
|
25
|
+
self.kph, self.kdh = kp_hoist, kd_hoist
|
|
26
|
+
|
|
27
|
+
def reset(self, plant, manoeuvre):
|
|
28
|
+
super().reset(plant, manoeuvre)
|
|
29
|
+
_, self.u_eq = trim(plant)
|
|
30
|
+
|
|
31
|
+
def __call__(self, t, x):
|
|
32
|
+
p, man = self.plant, self.man
|
|
33
|
+
u = np.array(self.u_eq, float)
|
|
34
|
+
nq = len(p.state_names) // 2
|
|
35
|
+
transfer = getattr(p, "transfer_axes", (p.actuated[0],))
|
|
36
|
+
for k, i in enumerate(p.actuated):
|
|
37
|
+
if p.state_names[i] == "l":
|
|
38
|
+
u[k] += (self.kph * (man.rope(t, self.x0[i]) - x[i])
|
|
39
|
+
+ self.kdh * (man.rope_rate(t) - x[nq + i]))
|
|
40
|
+
elif i in transfer:
|
|
41
|
+
# axes that execute the transfer follow it from where they started
|
|
42
|
+
u[k] += (self.kp * (self.x0[i] + man.position(t) - x[i])
|
|
43
|
+
+ self.kd * (man.velocity(t) - x[nq + i]))
|
|
44
|
+
else:
|
|
45
|
+
u[k] += self.kp * (self.x0[i] - x[i]) + self.kd * (0.0 - x[nq + i])
|
|
46
|
+
return u
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LQR(Controller):
|
|
50
|
+
"""Infinite-horizon LQR on the numerically linearised plant.
|
|
51
|
+
|
|
52
|
+
The linearisation point, the weights and the solver are all recorded, so
|
|
53
|
+
the baseline is reproducible rather than "an LQR we tuned".
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
name = "LQR"
|
|
57
|
+
|
|
58
|
+
def __init__(self, q_pos=60.0, q_swing=400.0, q_rate=8.0, r=2.0e-7):
|
|
59
|
+
self.q_pos, self.q_swing, self.q_rate, self.r = q_pos, q_swing, q_rate, r
|
|
60
|
+
|
|
61
|
+
def reset(self, plant, manoeuvre):
|
|
62
|
+
super().reset(plant, manoeuvre)
|
|
63
|
+
x0, self.u_eq = trim(plant)
|
|
64
|
+
A = state_matrix(plant, x0, self.u_eq)
|
|
65
|
+
B = input_matrix(plant, x0, self.u_eq)
|
|
66
|
+
nq = plant.nx // 2
|
|
67
|
+
q = np.full(plant.nx, self.q_rate)
|
|
68
|
+
for i in plant.actuated:
|
|
69
|
+
q[i] = self.q_pos
|
|
70
|
+
for i in plant.unactuated:
|
|
71
|
+
q[i] = self.q_swing
|
|
72
|
+
Q = np.diag(q)
|
|
73
|
+
R = np.eye(plant.nu) * self.r
|
|
74
|
+
P = solve_continuous_are(A, B, Q, R)
|
|
75
|
+
self.K = np.linalg.solve(R, B.T @ P)
|
|
76
|
+
self.nq = nq
|
|
77
|
+
|
|
78
|
+
def _xref(self, t):
|
|
79
|
+
p, man = self.plant, self.man
|
|
80
|
+
xr = p.reference_state(self.x0, man.position(t), man.velocity(t))
|
|
81
|
+
for j in p.actuated:
|
|
82
|
+
if p.state_names[j] == "l":
|
|
83
|
+
xr[j] = man.rope(t, self.x0[j])
|
|
84
|
+
xr[self.nq + j] = man.rope_rate(t)
|
|
85
|
+
return xr
|
|
86
|
+
|
|
87
|
+
def __call__(self, t, x):
|
|
88
|
+
return self.u_eq - self.K @ (x - self._xref(t))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ZVD(Controller):
|
|
92
|
+
"""Zero-vibration-derivative input shaping in cascade with PD tracking.
|
|
93
|
+
|
|
94
|
+
The shaper is applied to the *reference*, not to the control signal, which
|
|
95
|
+
is the standard command-shaping architecture. It is tuned to the pendulum
|
|
96
|
+
frequency at the initial rope length and is therefore expected to degrade
|
|
97
|
+
when the rope length or the payload mass departs from nominal -- that
|
|
98
|
+
degradation is part of what the benchmark is meant to expose.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
name = "ZVD"
|
|
102
|
+
|
|
103
|
+
def __init__(self, zeta=0.02, kp=6.0e3, kd=2.4e4, kp_hoist=4.0e4, kd_hoist=6.0e4):
|
|
104
|
+
self.zeta = zeta
|
|
105
|
+
self.inner = PD(kp, kd, kp_hoist, kd_hoist)
|
|
106
|
+
|
|
107
|
+
def reset(self, plant, manoeuvre):
|
|
108
|
+
super().reset(plant, manoeuvre)
|
|
109
|
+
self.inner.reset(plant, manoeuvre)
|
|
110
|
+
l0 = plant.initial_state()[list(plant.state_names).index("l")] \
|
|
111
|
+
if "l" in plant.state_names else 12.0
|
|
112
|
+
wn = np.sqrt(G / max(l0, 1e-3))
|
|
113
|
+
z = self.zeta
|
|
114
|
+
wd = wn * np.sqrt(max(1.0 - z * z, 1e-9))
|
|
115
|
+
K = np.exp(-z * np.pi / np.sqrt(max(1.0 - z * z, 1e-9)))
|
|
116
|
+
den = (1.0 + K) ** 2
|
|
117
|
+
self.amp = np.array([1.0, 2.0 * K, K * K]) / den
|
|
118
|
+
self.tau = np.array([0.0, np.pi / wd, 2.0 * np.pi / wd])
|
|
119
|
+
|
|
120
|
+
def __call__(self, t, x):
|
|
121
|
+
man = self.man
|
|
122
|
+
pos = float(np.sum(self.amp * [man.position(t - d) for d in self.tau]))
|
|
123
|
+
vel = float(np.sum(self.amp * [man.velocity(t - d) for d in self.tau]))
|
|
124
|
+
|
|
125
|
+
class _Shaped:
|
|
126
|
+
def position(_s, tt):
|
|
127
|
+
return pos
|
|
128
|
+
|
|
129
|
+
def velocity(_s, tt):
|
|
130
|
+
return vel
|
|
131
|
+
|
|
132
|
+
rope = man.rope
|
|
133
|
+
rope_rate = man.rope_rate
|
|
134
|
+
|
|
135
|
+
saved = self.inner.man
|
|
136
|
+
self.inner.man = _Shaped()
|
|
137
|
+
try:
|
|
138
|
+
return self.inner(t, x)
|
|
139
|
+
finally:
|
|
140
|
+
self.inner.man = saved
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Boundary-layer SMC and hierarchical SMC -- the two sliding baselines.
|
|
2
|
+
|
|
3
|
+
Both are the textbook forms. ``SMC`` slides on the actuated tracking error
|
|
4
|
+
only and therefore has no mechanism to damp the payload; ``HSMC`` folds the
|
|
5
|
+
unactuated coordinate into a composite surface, which is the standard
|
|
6
|
+
hierarchical construction. Neither is a contribution of this package, and the
|
|
7
|
+
gains are exposed so that a user can re-tune them on their own bench.
|
|
8
|
+
|
|
9
|
+
The switching term uses ``tanh(s / phi)`` rather than ``sign(s)``. This is not
|
|
10
|
+
cosmetic: with ``sign`` the measured control effort of a sliding controller
|
|
11
|
+
depends on the integrator step, which makes effort comparisons between
|
|
12
|
+
controllers meaningless. The boundary layer is reported with the results.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from .base import Controller, trim
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SMC(Controller):
|
|
23
|
+
"""Sliding-mode control on the actuated coordinates, boundary layer phi."""
|
|
24
|
+
|
|
25
|
+
name = "SMC"
|
|
26
|
+
|
|
27
|
+
def __init__(self, c=1.1, k=2.2e4, eta=6.0e3, phi=0.06,
|
|
28
|
+
c_hoist=2.0, k_hoist=6.0e4, eta_hoist=1.0e4):
|
|
29
|
+
self.c, self.k, self.eta, self.phi = c, k, eta, phi
|
|
30
|
+
self.ch, self.kh, self.etah = c_hoist, k_hoist, eta_hoist
|
|
31
|
+
|
|
32
|
+
def reset(self, plant, manoeuvre):
|
|
33
|
+
super().reset(plant, manoeuvre)
|
|
34
|
+
_, self.u_eq = trim(plant)
|
|
35
|
+
self.nq = plant.nx // 2
|
|
36
|
+
|
|
37
|
+
def _surfaces(self, t, x):
|
|
38
|
+
p, man, nq = self.plant, self.man, self.nq
|
|
39
|
+
transfer = getattr(p, "transfer_axes", (p.actuated[0],))
|
|
40
|
+
s, gains = [], []
|
|
41
|
+
for k, i in enumerate(p.actuated):
|
|
42
|
+
if i in transfer:
|
|
43
|
+
e = x[i] - (self.x0[i] + man.position(t))
|
|
44
|
+
ed = x[nq + i] - man.velocity(t)
|
|
45
|
+
s.append(ed + self.c * e)
|
|
46
|
+
gains.append((self.k, self.eta))
|
|
47
|
+
elif p.state_names[i] == "l":
|
|
48
|
+
e = x[i] - man.rope(t, self.x0[i])
|
|
49
|
+
ed = x[nq + i] - man.rope_rate(t)
|
|
50
|
+
s.append(ed + self.ch * e)
|
|
51
|
+
gains.append((self.kh, self.etah))
|
|
52
|
+
else:
|
|
53
|
+
e = x[i] - self.x0[i]
|
|
54
|
+
s.append(x[nq + i] + self.c * e)
|
|
55
|
+
gains.append((self.k, self.eta))
|
|
56
|
+
return np.array(s), gains
|
|
57
|
+
|
|
58
|
+
def __call__(self, t, x):
|
|
59
|
+
s, gains = self._surfaces(t, x)
|
|
60
|
+
u = np.array(self.u_eq, float)
|
|
61
|
+
for j, (kj, etaj) in enumerate(gains):
|
|
62
|
+
u[j] += -kj * s[j] - etaj * np.tanh(s[j] / self.phi)
|
|
63
|
+
return u
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class HSMC(SMC):
|
|
67
|
+
"""Hierarchical SMC: composite surface S = s_actuated + lam * s_swing.
|
|
68
|
+
|
|
69
|
+
The swing sub-surface is ``s_sw = thetad + c_sw * theta``. Only the first
|
|
70
|
+
actuated axis carries the composite surface; the remaining axes keep their
|
|
71
|
+
own first-layer surfaces, which is the usual arrangement for a crane whose
|
|
72
|
+
hoist is not used for anti-sway.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
name = "HSMC"
|
|
76
|
+
|
|
77
|
+
def __init__(self, lam=0.40, c_swing=1.30, **kw):
|
|
78
|
+
super().__init__(**kw)
|
|
79
|
+
self.lam, self.c_swing = lam, c_swing
|
|
80
|
+
|
|
81
|
+
def __call__(self, t, x):
|
|
82
|
+
s, gains = self._surfaces(t, x)
|
|
83
|
+
theta, theta_dot = self.plant.swing_state(x)
|
|
84
|
+
s_sw = theta_dot + self.c_swing * theta
|
|
85
|
+
s = s.copy()
|
|
86
|
+
s[0] = s[0] + self.lam * s_sw
|
|
87
|
+
u = np.array(self.u_eq, float)
|
|
88
|
+
for j, (kj, etaj) in enumerate(gains):
|
|
89
|
+
u[j] += -kj * s[j] - etaj * np.tanh(s[j] / self.phi)
|
|
90
|
+
return u
|