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.
- cranebench-0.1.2/LICENSE.txt +8 -0
- cranebench-0.1.2/PKG-INFO +159 -0
- cranebench-0.1.2/README.md +139 -0
- cranebench-0.1.2/cranebench/__init__.py +38 -0
- cranebench-0.1.2/cranebench/batch.py +338 -0
- cranebench-0.1.2/cranebench/controllers/__init__.py +8 -0
- cranebench-0.1.2/cranebench/controllers/base.py +62 -0
- cranebench-0.1.2/cranebench/controllers/classical.py +140 -0
- cranebench-0.1.2/cranebench/controllers/sliding.py +90 -0
- cranebench-0.1.2/cranebench/integrate.py +112 -0
- cranebench-0.1.2/cranebench/ledger.py +58 -0
- cranebench-0.1.2/cranebench/metrics.py +125 -0
- cranebench-0.1.2/cranebench/plants/__init__.py +9 -0
- cranebench-0.1.2/cranebench/plants/_generated.py +67 -0
- cranebench-0.1.2/cranebench/plants/base.py +216 -0
- cranebench-0.1.2/cranebench/plants/dual.py +202 -0
- cranebench-0.1.2/cranebench/plants/planar.py +146 -0
- cranebench-0.1.2/cranebench/plants/spatial.py +187 -0
- cranebench-0.1.2/cranebench/reference.py +100 -0
- cranebench-0.1.2/cranebench/runner.py +153 -0
- cranebench-0.1.2/cranebench/stats.py +179 -0
- cranebench-0.1.2/cranebench/uncertainty.py +80 -0
- cranebench-0.1.2/cranebench/wind/__init__.py +8 -0
- cranebench-0.1.2/cranebench/wind/dryden.py +83 -0
- cranebench-0.1.2/cranebench/wind/kaimal.py +85 -0
- cranebench-0.1.2/cranebench.egg-info/PKG-INFO +159 -0
- cranebench-0.1.2/cranebench.egg-info/SOURCES.txt +37 -0
- cranebench-0.1.2/cranebench.egg-info/dependency_links.txt +1 -0
- cranebench-0.1.2/cranebench.egg-info/requires.txt +7 -0
- cranebench-0.1.2/cranebench.egg-info/top_level.txt +1 -0
- cranebench-0.1.2/pyproject.toml +29 -0
- cranebench-0.1.2/setup.cfg +4 -0
- cranebench-0.1.2/tests/test_batch.py +39 -0
- cranebench-0.1.2/tests/test_dynamics.py +63 -0
- cranebench-0.1.2/tests/test_harness.py +80 -0
- cranebench-0.1.2/tests/test_manuscript.py +35 -0
- cranebench-0.1.2/tests/test_mcnemar_revision.py +18 -0
- cranebench-0.1.2/tests/test_symbolic.py +100 -0
- cranebench-0.1.2/tests/test_wind.py +44 -0
|
@@ -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"]
|