cranebench 0.1.2__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.
Files changed (39) hide show
  1. cranebench-0.1.2/LICENSE.txt +8 -0
  2. cranebench-0.1.2/PKG-INFO +159 -0
  3. cranebench-0.1.2/README.md +139 -0
  4. cranebench-0.1.2/cranebench/__init__.py +38 -0
  5. cranebench-0.1.2/cranebench/batch.py +338 -0
  6. cranebench-0.1.2/cranebench/controllers/__init__.py +8 -0
  7. cranebench-0.1.2/cranebench/controllers/base.py +62 -0
  8. cranebench-0.1.2/cranebench/controllers/classical.py +140 -0
  9. cranebench-0.1.2/cranebench/controllers/sliding.py +90 -0
  10. cranebench-0.1.2/cranebench/integrate.py +112 -0
  11. cranebench-0.1.2/cranebench/ledger.py +58 -0
  12. cranebench-0.1.2/cranebench/metrics.py +125 -0
  13. cranebench-0.1.2/cranebench/plants/__init__.py +9 -0
  14. cranebench-0.1.2/cranebench/plants/_generated.py +67 -0
  15. cranebench-0.1.2/cranebench/plants/base.py +216 -0
  16. cranebench-0.1.2/cranebench/plants/dual.py +202 -0
  17. cranebench-0.1.2/cranebench/plants/planar.py +146 -0
  18. cranebench-0.1.2/cranebench/plants/spatial.py +187 -0
  19. cranebench-0.1.2/cranebench/reference.py +100 -0
  20. cranebench-0.1.2/cranebench/runner.py +153 -0
  21. cranebench-0.1.2/cranebench/stats.py +179 -0
  22. cranebench-0.1.2/cranebench/uncertainty.py +80 -0
  23. cranebench-0.1.2/cranebench/wind/__init__.py +8 -0
  24. cranebench-0.1.2/cranebench/wind/dryden.py +83 -0
  25. cranebench-0.1.2/cranebench/wind/kaimal.py +85 -0
  26. cranebench-0.1.2/cranebench.egg-info/PKG-INFO +159 -0
  27. cranebench-0.1.2/cranebench.egg-info/SOURCES.txt +37 -0
  28. cranebench-0.1.2/cranebench.egg-info/dependency_links.txt +1 -0
  29. cranebench-0.1.2/cranebench.egg-info/requires.txt +7 -0
  30. cranebench-0.1.2/cranebench.egg-info/top_level.txt +1 -0
  31. cranebench-0.1.2/pyproject.toml +29 -0
  32. cranebench-0.1.2/setup.cfg +4 -0
  33. cranebench-0.1.2/tests/test_batch.py +39 -0
  34. cranebench-0.1.2/tests/test_dynamics.py +63 -0
  35. cranebench-0.1.2/tests/test_harness.py +80 -0
  36. cranebench-0.1.2/tests/test_manuscript.py +35 -0
  37. cranebench-0.1.2/tests/test_mcnemar_revision.py +18 -0
  38. cranebench-0.1.2/tests/test_symbolic.py +100 -0
  39. cranebench-0.1.2/tests/test_wind.py +44 -0
@@ -0,0 +1,8 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, the cranebench authors.
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the conditions of the BSD 3-Clause
8
+ License are met.
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: cranebench
3
+ Version: 0.1.2
4
+ Summary: A reproducible benchmark for underactuated crane control
5
+ Author: S. Podliesnyi, O. Sheremet, B. Vorobiov
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Repository, https://github.com/spodlesny2318-arch/Cranebench
8
+ Project-URL: Issues, https://github.com/spodlesny2318-arch/Cranebench/issues
9
+ Keywords: crane,underactuated,benchmark,reproducibility,sliding mode control,Monte Carlo
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE.txt
13
+ Requires-Dist: numpy>=1.24
14
+ Requires-Dist: scipy>=1.10
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7; extra == "dev"
17
+ Requires-Dist: matplotlib>=3.6; extra == "dev"
18
+ Requires-Dist: sympy>=1.12; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # cranebench
22
+
23
+
24
+ A reproducible benchmark for the control of underactuated crane systems.
25
+
26
+ Crane control papers are almost never comparable with one another. Each one
27
+ defines its own plant, its own manoeuvre, its own wind, its own uncertainty
28
+ range and its own metrics, and then reports that the proposed controller beats
29
+ the baselines the authors implemented themselves. `cranebench` fixes the bench
30
+ so that the controller becomes the only thing that varies.
31
+
32
+ The package contains **no novel controller**. That is deliberate. Its content
33
+ is the plants, the disturbance models, the paired uncertainty design, the frozen
34
+ metric module, the provenance ledger and five classical baselines.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ python -m pip install ".[dev]"
40
+ python -m pytest -q
41
+ ```
42
+
43
+ These commands apply to the source checkout. The separately supplied article
44
+ reproducibility bundle contains retained campaign data and the revision audit.
45
+ Installing the library alone does not install those campaign archives.
46
+
47
+ Requires Python >= 3.10, NumPy >= 1.24 and SciPy >= 1.10.
48
+
49
+ The development build `0.1.2.dev0` was uploaded to TestPyPI and the wheel was
50
+ installed from that test index in a clean environment. TestPyPI is separate from
51
+ the production PyPI index. The versioned source distribution is published with the GitHub release, and that same release is archived on Zenodo. A package-name installation route is available from the production PyPI index after publication.
52
+ Compatibility profiles and the GitHub Actions workflow are described in
53
+ [`ci/README.md`](ci/README.md). Reported CI success requires an actual completed
54
+ workflow run; the presence of the workflow file alone is not evidence of it.
55
+
56
+ ## Minimal working example
57
+
58
+ ```python
59
+ from cranebench.reference import Manoeuvre
60
+ from cranebench.runner import Campaign, run_single
61
+ from cranebench.controllers import LQR
62
+
63
+ camp = Campaign(plant="planar", wind="kaimal",
64
+ manoeuvre=Manoeuvre(distance=20.0, t_ramp=20.0, t_total=40.0)).build()
65
+ metrics, (t, X, U) = run_single(LQR(), camp, factors=None, wind_seed=7)
66
+ print(metrics["peak_swing"], metrics["residual_swing"])
67
+ ```
68
+
69
+ ## Evaluating your own controller
70
+
71
+ Subclass `Controller`, implement `__call__(t, x) -> u`, and hand it to the
72
+ runner. Nothing else changes: same plants, same seeds, same metrics.
73
+
74
+ ```python
75
+ from cranebench.controllers import Controller
76
+ from cranebench.runner import Campaign, run_campaign
77
+ from cranebench.uncertainty import lhs_design
78
+ from cranebench.controllers import BASELINES
79
+
80
+ class MyController(Controller):
81
+ name = "mine"
82
+ def __call__(self, t, x):
83
+ ...
84
+
85
+ camp = Campaign(controllers={**{k: v() for k, v in BASELINES.items()},
86
+ "mine": MyController()})
87
+ res = run_campaign(camp, lhs_design(n=500, seed=20260729))
88
+ ```
89
+
90
+ Because the design is paired, `res["mine"]["residual_swing"] -
91
+ res["PD"]["residual_swing"]` is a sample-by-sample contrast, not a difference of
92
+ two independent clouds.
93
+
94
+ ## What is in the box
95
+
96
+ | Component | Contents |
97
+ |---|---|
98
+ | Plants | planar crane with hoisting; 3-D crane with a yawing payload; cooperative dual crane with a rigid beam on visco-elastic falls |
99
+ | Disturbances | Kaimal spectral synthesis (exact realised variance); Dryden shaping filter (exact ZOH statistics, unbounded support) |
100
+ | Baselines | PD, LQR, ZVD input shaper, boundary-layer SMC, hierarchical SMC |
101
+ | Design | centred Latin hypercube over five multiplicative factors, paired across controllers |
102
+ | Metrics | ISE, settling time, peak/RMS/residual swing, peak/RMS yaw, effort, peak input, command total variation (CTV), bound satisfaction |
103
+ | Statistics | paired bootstrap CI, Wilcoxon signed-rank, running-mean convergence |
104
+ | Provenance | ledger with source hashes, metric hash, every seed, solver and environment |
105
+ | Execution | scalar reference path (all plants) and a batched path for the planar plant that integrates the whole ensemble at once, 70x faster and verified equal to 1.4e-14 |
106
+
107
+ ## Design decisions that are not obvious
108
+
109
+ Documented in full in [`docs/DESIGN.md`](docs/DESIGN.md). The short list:
110
+
111
+ * **The wind record lives on its own grid.** If the disturbance realisation is
112
+ indexed by the integrator step, refining the step changes the disturbance and
113
+ a step-convergence study can never converge. Ours is synthesised at 100 Hz and
114
+ interpolated.
115
+ * **Effort excludes the hoist channel.** The hoist carries the static weight, so
116
+ including it makes every controller's effort equal to `(mg)^2 T` to three
117
+ digits and destroys the comparison.
118
+ * **The switching function is `tanh`, not `sign`.** With `sign`, the measured
119
+ effort of a sliding controller is a function of the integrator step.
120
+ * **The metric module is hashed.** Change a metric and old results stop
121
+ comparing equal, by design.
122
+ * **Kinematic Jacobians use the complex step.** The Coriolis term differentiates
123
+ the mass matrix, so a finite-difference Jacobian would be differentiated twice
124
+ and its noise floor would surface in the accelerations at 1e-4.
125
+ * **The batched path is pinned to the scalar one.** A second implementation of
126
+ the same experiment is a liability unless a test requires the two to agree;
127
+ `tests/test_batch.py` does, on every metric over a full paired design.
128
+
129
+ ## Verification
130
+
131
+ The package does not assert its models, it checks them:
132
+
133
+ * the hand-derived planar equations agree with an independently assembled
134
+ Lagrangian model to 6e-10 over random states;
135
+ * all three plants conserve energy to better than 1e-10 relative over 3 s with
136
+ damping removed;
137
+ * the Kaimal record reproduces the target spectrum with log-PSD correlation
138
+ above 0.95, and its realised variance is exact;
139
+ * the Dryden filter is stationary from its first sample;
140
+ * every reported metric is converged to better than 0.03 % in the integrator
141
+ step;
142
+ * the batched execution path reproduces the scalar reference path to 1.4e-14 on
143
+ every metric.
144
+
145
+ ## Reproducing the paper
146
+
147
+ ```bash
148
+ for c in calm reference dryden stress; do
149
+ python examples/run_batch_campaign.py $c 500
150
+ done
151
+ python examples/summarise_batch.py # tables
152
+ python examples/make_figures.py # figures
153
+ ```
154
+
155
+ Six illustrative campaigns across three plants comprise 11,250 closed-loop controller runs. The full reproducibility procedure is documented in `VERIFY.md`.
156
+
157
+ ## Licence
158
+
159
+ BSD-3-Clause. If you use it, cite the SoftwareX paper (see `CITATION.cff`).
@@ -0,0 +1,139 @@
1
+ # cranebench
2
+
3
+
4
+ A reproducible benchmark for the control of underactuated crane systems.
5
+
6
+ Crane control papers are almost never comparable with one another. Each one
7
+ defines its own plant, its own manoeuvre, its own wind, its own uncertainty
8
+ range and its own metrics, and then reports that the proposed controller beats
9
+ the baselines the authors implemented themselves. `cranebench` fixes the bench
10
+ so that the controller becomes the only thing that varies.
11
+
12
+ The package contains **no novel controller**. That is deliberate. Its content
13
+ is the plants, the disturbance models, the paired uncertainty design, the frozen
14
+ metric module, the provenance ledger and five classical baselines.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ python -m pip install ".[dev]"
20
+ python -m pytest -q
21
+ ```
22
+
23
+ These commands apply to the source checkout. The separately supplied article
24
+ reproducibility bundle contains retained campaign data and the revision audit.
25
+ Installing the library alone does not install those campaign archives.
26
+
27
+ Requires Python >= 3.10, NumPy >= 1.24 and SciPy >= 1.10.
28
+
29
+ The development build `0.1.2.dev0` was uploaded to TestPyPI and the wheel was
30
+ installed from that test index in a clean environment. TestPyPI is separate from
31
+ the production PyPI index. The versioned source distribution is published with the GitHub release, and that same release is archived on Zenodo. A package-name installation route is available from the production PyPI index after publication.
32
+ Compatibility profiles and the GitHub Actions workflow are described in
33
+ [`ci/README.md`](ci/README.md). Reported CI success requires an actual completed
34
+ workflow run; the presence of the workflow file alone is not evidence of it.
35
+
36
+ ## Minimal working example
37
+
38
+ ```python
39
+ from cranebench.reference import Manoeuvre
40
+ from cranebench.runner import Campaign, run_single
41
+ from cranebench.controllers import LQR
42
+
43
+ camp = Campaign(plant="planar", wind="kaimal",
44
+ manoeuvre=Manoeuvre(distance=20.0, t_ramp=20.0, t_total=40.0)).build()
45
+ metrics, (t, X, U) = run_single(LQR(), camp, factors=None, wind_seed=7)
46
+ print(metrics["peak_swing"], metrics["residual_swing"])
47
+ ```
48
+
49
+ ## Evaluating your own controller
50
+
51
+ Subclass `Controller`, implement `__call__(t, x) -> u`, and hand it to the
52
+ runner. Nothing else changes: same plants, same seeds, same metrics.
53
+
54
+ ```python
55
+ from cranebench.controllers import Controller
56
+ from cranebench.runner import Campaign, run_campaign
57
+ from cranebench.uncertainty import lhs_design
58
+ from cranebench.controllers import BASELINES
59
+
60
+ class MyController(Controller):
61
+ name = "mine"
62
+ def __call__(self, t, x):
63
+ ...
64
+
65
+ camp = Campaign(controllers={**{k: v() for k, v in BASELINES.items()},
66
+ "mine": MyController()})
67
+ res = run_campaign(camp, lhs_design(n=500, seed=20260729))
68
+ ```
69
+
70
+ Because the design is paired, `res["mine"]["residual_swing"] -
71
+ res["PD"]["residual_swing"]` is a sample-by-sample contrast, not a difference of
72
+ two independent clouds.
73
+
74
+ ## What is in the box
75
+
76
+ | Component | Contents |
77
+ |---|---|
78
+ | Plants | planar crane with hoisting; 3-D crane with a yawing payload; cooperative dual crane with a rigid beam on visco-elastic falls |
79
+ | Disturbances | Kaimal spectral synthesis (exact realised variance); Dryden shaping filter (exact ZOH statistics, unbounded support) |
80
+ | Baselines | PD, LQR, ZVD input shaper, boundary-layer SMC, hierarchical SMC |
81
+ | Design | centred Latin hypercube over five multiplicative factors, paired across controllers |
82
+ | Metrics | ISE, settling time, peak/RMS/residual swing, peak/RMS yaw, effort, peak input, command total variation (CTV), bound satisfaction |
83
+ | Statistics | paired bootstrap CI, Wilcoxon signed-rank, running-mean convergence |
84
+ | Provenance | ledger with source hashes, metric hash, every seed, solver and environment |
85
+ | Execution | scalar reference path (all plants) and a batched path for the planar plant that integrates the whole ensemble at once, 70x faster and verified equal to 1.4e-14 |
86
+
87
+ ## Design decisions that are not obvious
88
+
89
+ Documented in full in [`docs/DESIGN.md`](docs/DESIGN.md). The short list:
90
+
91
+ * **The wind record lives on its own grid.** If the disturbance realisation is
92
+ indexed by the integrator step, refining the step changes the disturbance and
93
+ a step-convergence study can never converge. Ours is synthesised at 100 Hz and
94
+ interpolated.
95
+ * **Effort excludes the hoist channel.** The hoist carries the static weight, so
96
+ including it makes every controller's effort equal to `(mg)^2 T` to three
97
+ digits and destroys the comparison.
98
+ * **The switching function is `tanh`, not `sign`.** With `sign`, the measured
99
+ effort of a sliding controller is a function of the integrator step.
100
+ * **The metric module is hashed.** Change a metric and old results stop
101
+ comparing equal, by design.
102
+ * **Kinematic Jacobians use the complex step.** The Coriolis term differentiates
103
+ the mass matrix, so a finite-difference Jacobian would be differentiated twice
104
+ and its noise floor would surface in the accelerations at 1e-4.
105
+ * **The batched path is pinned to the scalar one.** A second implementation of
106
+ the same experiment is a liability unless a test requires the two to agree;
107
+ `tests/test_batch.py` does, on every metric over a full paired design.
108
+
109
+ ## Verification
110
+
111
+ The package does not assert its models, it checks them:
112
+
113
+ * the hand-derived planar equations agree with an independently assembled
114
+ Lagrangian model to 6e-10 over random states;
115
+ * all three plants conserve energy to better than 1e-10 relative over 3 s with
116
+ damping removed;
117
+ * the Kaimal record reproduces the target spectrum with log-PSD correlation
118
+ above 0.95, and its realised variance is exact;
119
+ * the Dryden filter is stationary from its first sample;
120
+ * every reported metric is converged to better than 0.03 % in the integrator
121
+ step;
122
+ * the batched execution path reproduces the scalar reference path to 1.4e-14 on
123
+ every metric.
124
+
125
+ ## Reproducing the paper
126
+
127
+ ```bash
128
+ for c in calm reference dryden stress; do
129
+ python examples/run_batch_campaign.py $c 500
130
+ done
131
+ python examples/summarise_batch.py # tables
132
+ python examples/make_figures.py # figures
133
+ ```
134
+
135
+ Six illustrative campaigns across three plants comprise 11,250 closed-loop controller runs. The full reproducibility procedure is documented in `VERIFY.md`.
136
+
137
+ ## Licence
138
+
139
+ BSD-3-Clause. If you use it, cite the SoftwareX paper (see `CITATION.cff`).
@@ -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
+ ]
@@ -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"]