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 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