virtualmodelcontrol 0.1.0__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.
- virtualmodelcontrol/__init__.py +75 -0
- virtualmodelcontrol/_version.py +24 -0
- virtualmodelcontrol/compiler.py +158 -0
- virtualmodelcontrol/control/__init__.py +5 -0
- virtualmodelcontrol/control/controller.py +76 -0
- virtualmodelcontrol/core/__init__.py +24 -0
- virtualmodelcontrol/core/params.py +244 -0
- virtualmodelcontrol/core/registry.py +54 -0
- virtualmodelcontrol/core/signals.py +46 -0
- virtualmodelcontrol/core/space.py +162 -0
- virtualmodelcontrol/core/symbolic.py +67 -0
- virtualmodelcontrol/core/units.py +34 -0
- virtualmodelcontrol/dynamics.py +149 -0
- virtualmodelcontrol/mechanisms/__init__.py +67 -0
- virtualmodelcontrol/mechanisms/components/__init__.py +33 -0
- virtualmodelcontrol/mechanisms/components/base.py +71 -0
- virtualmodelcontrol/mechanisms/components/dissipation.py +50 -0
- virtualmodelcontrol/mechanisms/components/inertance.py +56 -0
- virtualmodelcontrol/mechanisms/components/sources.py +63 -0
- virtualmodelcontrol/mechanisms/components/storage.py +272 -0
- virtualmodelcontrol/mechanisms/coordinates/__init__.py +24 -0
- virtualmodelcontrol/mechanisms/coordinates/base.py +117 -0
- virtualmodelcontrol/mechanisms/coordinates/frames.py +62 -0
- virtualmodelcontrol/mechanisms/coordinates/joints.py +51 -0
- virtualmodelcontrol/mechanisms/coordinates/ops.py +146 -0
- virtualmodelcontrol/mechanisms/coordinates/references.py +43 -0
- virtualmodelcontrol/mechanisms/mechanism.py +88 -0
- virtualmodelcontrol/models/__init__.py +22 -0
- virtualmodelcontrol/models/actuation.py +196 -0
- virtualmodelcontrol/models/assembly.py +154 -0
- virtualmodelcontrol/models/continuum/__init__.py +5 -0
- virtualmodelcontrol/models/continuum/pcc.py +120 -0
- virtualmodelcontrol/models/kinematic.py +41 -0
- virtualmodelcontrol/models/rigid/__init__.py +6 -0
- virtualmodelcontrol/models/rigid/couplings.py +64 -0
- virtualmodelcontrol/models/rigid/poe.py +95 -0
- virtualmodelcontrol/py.typed +0 -0
- virtualmodelcontrol/robots/__init__.py +5 -0
- virtualmodelcontrol/robots/adapt.py +98 -0
- virtualmodelcontrol/robots/helyx.py +79 -0
- virtualmodelcontrol/sim/__init__.py +7 -0
- virtualmodelcontrol/sim/model_plant.py +79 -0
- virtualmodelcontrol/sim/plant.py +40 -0
- virtualmodelcontrol/sim/run.py +73 -0
- virtualmodelcontrol/system.py +55 -0
- virtualmodelcontrol-0.1.0.dist-info/METADATA +82 -0
- virtualmodelcontrol-0.1.0.dist-info/RECORD +49 -0
- virtualmodelcontrol-0.1.0.dist-info/WHEEL +4 -0
- virtualmodelcontrol-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Piecewise constant curvature (PCC) kinematics of a continuum robot with n segments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import casadi as ca
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ...core.params import ParamSet, as_param
|
|
12
|
+
from ...core.registry import register
|
|
13
|
+
from ...core.space import Euclidean
|
|
14
|
+
from ...core.units import M
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def segment_frame(dx: Any, dy: Any, dl: Any, L0: Any, d: Any, s: Any, eps: float) -> tuple:
|
|
18
|
+
"""(R, t) of one PCC segment at local arc fraction s ∈ [0, 1].
|
|
19
|
+
|
|
20
|
+
The segment is an arc of radius d (L0 + Dl) / D, D = √(Dx² + Dy² + ε), bent by s D / d
|
|
21
|
+
about the axis (−Dy, Dx, 0) / D. Its tangent is R's z axis.
|
|
22
|
+
"""
|
|
23
|
+
D = ca.sqrt(dx**2 + dy**2 + eps)
|
|
24
|
+
theta = s * D / d
|
|
25
|
+
c, sn = ca.cos(theta), ca.sin(theta)
|
|
26
|
+
R = ca.vertcat(
|
|
27
|
+
ca.horzcat(1 + dx**2 / D**2 * (c - 1), dx * dy / D**2 * (c - 1), dx / D * sn),
|
|
28
|
+
ca.horzcat(dx * dy / D**2 * (c - 1), 1 + dy**2 / D**2 * (c - 1), dy / D * sn),
|
|
29
|
+
ca.horzcat(-dx / D * sn, -dy / D * sn, c),
|
|
30
|
+
)
|
|
31
|
+
k = d * (L0 + dl) / D**2
|
|
32
|
+
t = ca.vertcat(k * dx * (1 - c), k * dy * (1 - c), k * D * sn)
|
|
33
|
+
return R, t
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@register("model", "pcc")
|
|
37
|
+
class PCC:
|
|
38
|
+
"""Continuum robot of n PCC segments; q = Δ = [Dx, Dy, Dl] per segment, base to tip [m].
|
|
39
|
+
|
|
40
|
+
Per segment i (from 1): ``seg{i}.L0`` rest length and ``seg{i}.d`` section radius [m],
|
|
41
|
+
``design`` Params. The arc parameter s ∈ [0, 1] is uniform in arc length (the breakpoints
|
|
42
|
+
follow from the rest lengths). Sites: ``base``, ``seg{i}`` (end of segment i), ``tip``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
q_unit = M
|
|
46
|
+
|
|
47
|
+
def __init__(self, L0: Sequence[Any], d: Any, *, eps: float = 1e-12) -> None:
|
|
48
|
+
n = len(L0)
|
|
49
|
+
d_list = list(d) if isinstance(d, (list, tuple, np.ndarray)) else [d] * n
|
|
50
|
+
if len(d_list) != n:
|
|
51
|
+
raise ValueError(f"{n} rest lengths but {len(d_list)} section radii")
|
|
52
|
+
self.n_segments = n
|
|
53
|
+
self.space = Euclidean(3 * n)
|
|
54
|
+
self.eps = eps
|
|
55
|
+
self.params = ParamSet()
|
|
56
|
+
for i in range(n):
|
|
57
|
+
for name, value in (("L0", L0[i]), ("d", d_list[i])):
|
|
58
|
+
key = f"seg{i + 1}.{name}"
|
|
59
|
+
param = as_param(value, key, unit=M, bounds=(0.0, np.inf), scope="design")
|
|
60
|
+
self.params.add(param, key)
|
|
61
|
+
self.sites = ("base", *(f"seg{i + 1}" for i in range(n)), "tip")
|
|
62
|
+
|
|
63
|
+
def frame(self, q: Any, at: Any, p: dict[str, Any]) -> tuple[Any, Any]:
|
|
64
|
+
"""(R, position) at a site or at arc parameter s (clipped to [0, 1])."""
|
|
65
|
+
n = self.n_segments
|
|
66
|
+
L0 = [p[f"seg{i + 1}.L0"] for i in range(n)]
|
|
67
|
+
d = [p[f"seg{i + 1}.d"] for i in range(n)]
|
|
68
|
+
seg = [q[3 * i : 3 * i + 3] for i in range(n)]
|
|
69
|
+
|
|
70
|
+
def local(i: int, s: Any) -> tuple:
|
|
71
|
+
return segment_frame(seg[i][0], seg[i][1], seg[i][2], L0[i], d[i], s, self.eps)
|
|
72
|
+
|
|
73
|
+
if isinstance(at, str):
|
|
74
|
+
if at not in self.sites:
|
|
75
|
+
raise KeyError(f"unknown site {at!r}; sites: {self.sites}")
|
|
76
|
+
count = n if at == "tip" else 0 if at == "base" else int(at[3:])
|
|
77
|
+
R, pos = ca.DM.eye(3), ca.DM.zeros(3, 1)
|
|
78
|
+
for i in range(count):
|
|
79
|
+
Ri, ti = local(i, 1.0)
|
|
80
|
+
R, pos = ca.mtimes(R, Ri), pos + ca.mtimes(R, ti)
|
|
81
|
+
return R, pos
|
|
82
|
+
|
|
83
|
+
cum = [0]
|
|
84
|
+
for i in range(n):
|
|
85
|
+
cum.append(cum[-1] + L0[i])
|
|
86
|
+
b = [c / cum[-1] for c in cum] # breakpoints of s at the segment ends
|
|
87
|
+
s = ca.fmin(ca.fmax(at, 0.0), 1.0)
|
|
88
|
+
R_base, p_base = ca.DM.eye(3), ca.DM.zeros(3, 1)
|
|
89
|
+
candidates = []
|
|
90
|
+
for i in range(n):
|
|
91
|
+
Ri, ti = local(i, (s - b[i]) / (b[i + 1] - b[i]))
|
|
92
|
+
candidates.append((ca.mtimes(R_base, Ri), p_base + ca.mtimes(R_base, ti)))
|
|
93
|
+
Re, te = local(i, 1.0)
|
|
94
|
+
R_base, p_base = ca.mtimes(R_base, Re), p_base + ca.mtimes(R_base, te)
|
|
95
|
+
R, pos = candidates[-1]
|
|
96
|
+
for i in reversed(range(n - 1)):
|
|
97
|
+
inside = s <= b[i + 1]
|
|
98
|
+
R = ca.if_else(inside, candidates[i][0], R)
|
|
99
|
+
pos = ca.if_else(inside, candidates[i][1], pos)
|
|
100
|
+
return R, pos
|
|
101
|
+
|
|
102
|
+
def breakpoints(self) -> np.ndarray:
|
|
103
|
+
"""Arc parameter at the base and at each segment end, at the current rest lengths."""
|
|
104
|
+
L0 = np.array([float(self.params[f"seg{i + 1}.L0"].value) for i in range(self.n_segments)])
|
|
105
|
+
return np.concatenate([[0.0], np.cumsum(L0)]) / L0.sum()
|
|
106
|
+
|
|
107
|
+
def to_dict(self) -> dict[str, Any]:
|
|
108
|
+
"""Constructor arguments at the current Param values."""
|
|
109
|
+
n = range(1, self.n_segments + 1)
|
|
110
|
+
return {
|
|
111
|
+
"type": "pcc",
|
|
112
|
+
"L0": [float(self.params[f"seg{i}.L0"].value) for i in n],
|
|
113
|
+
"d": [float(self.params[f"seg{i}.d"].value) for i in n],
|
|
114
|
+
"eps": self.eps,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_dict(cls, data: dict[str, Any]) -> PCC:
|
|
119
|
+
"""Inverse of ``to_dict``."""
|
|
120
|
+
return cls(data["L0"], data["d"], eps=data.get("eps", 1e-12))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Kinematic model contract: a space, Params, named sites and frames written in CasADi."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
import casadi as ca
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import ArrayLike
|
|
10
|
+
|
|
11
|
+
from ..core.params import ParamSet, constants
|
|
12
|
+
from ..core.registry import get
|
|
13
|
+
from ..core.space import Space
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KinematicModel(Protocol):
|
|
17
|
+
"""Frames of a robot as smooth functions of q and the Params.
|
|
18
|
+
|
|
19
|
+
``frame(q, at, p)`` returns (R, position) in the base frame; ``at`` is a site name or, for a
|
|
20
|
+
continuous body, an arc parameter s (possibly symbolic); ``p`` maps the model's Param names
|
|
21
|
+
to CasADi expressions.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
space: Space
|
|
25
|
+
params: ParamSet
|
|
26
|
+
sites: tuple[str, ...]
|
|
27
|
+
|
|
28
|
+
def frame(self, q: Any, at: Any, p: dict[str, Any]) -> tuple[Any, Any]:
|
|
29
|
+
"""Rotation (3, 3) and position (3, 1) at ``at``."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def evaluate_frame(model: KinematicModel, q: ArrayLike, at: Any) -> tuple[np.ndarray, np.ndarray]:
|
|
34
|
+
"""Numeric (R, position) at the model's current Param values."""
|
|
35
|
+
R, p = model.frame(ca.DM(np.asarray(q, dtype=float)), at, constants(model.params))
|
|
36
|
+
return np.array(ca.evalf(R)), np.array(ca.evalf(p)).ravel()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def from_dict(data: dict[str, Any], kind: str = "model") -> Any:
|
|
40
|
+
"""Build a registered model (or actuation, with ``kind``) from ``to_dict()`` output."""
|
|
41
|
+
return get(kind, data["type"]).from_dict(data)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Couplings: a model whose joints are driven together, joint angles = matrix · q."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import casadi as ca
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ...core.params import Param, ParamSet
|
|
11
|
+
from ...core.registry import get, register
|
|
12
|
+
from ...core.space import Euclidean
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register("model", "linear_coupling")
|
|
16
|
+
class LinearCoupling:
|
|
17
|
+
"""Wraps a joint-space model so that its joint angles are ``coupling`` · q.
|
|
18
|
+
|
|
19
|
+
Use it when one motor drives several joints (pulleys, mimic joints): q holds one entry per
|
|
20
|
+
motor. ``coupling`` (n_joints × n_q) is a ``design`` Param; the wrapped model's Params keep
|
|
21
|
+
their names.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, model: Any, coupling: Any) -> None:
|
|
25
|
+
value = np.atleast_2d(np.asarray(getattr(coupling, "value", coupling), dtype=float))
|
|
26
|
+
if value.shape[0] != model.space.nq:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
f"coupling has {value.shape[0]} rows but the model has {model.space.nq} joints"
|
|
29
|
+
)
|
|
30
|
+
self.model = model
|
|
31
|
+
self.space = Euclidean(value.shape[1])
|
|
32
|
+
self.params = ParamSet()
|
|
33
|
+
self.params.merge(model.params)
|
|
34
|
+
free = (-np.inf, np.inf)
|
|
35
|
+
self.coupling = (
|
|
36
|
+
coupling
|
|
37
|
+
if isinstance(coupling, Param)
|
|
38
|
+
else Param("coupling", value, scope="design", bounds=free)
|
|
39
|
+
)
|
|
40
|
+
self.params.add(self.coupling, "coupling")
|
|
41
|
+
self.sites = tuple(model.sites)
|
|
42
|
+
self.q_unit = getattr(model, "q_unit", "")
|
|
43
|
+
|
|
44
|
+
def joint_angles(self, q: Any, p: dict[str, Any]) -> Any:
|
|
45
|
+
"""Joint angles of the wrapped model."""
|
|
46
|
+
return ca.mtimes(p["coupling"], q)
|
|
47
|
+
|
|
48
|
+
def frame(self, q: Any, at: Any, p: dict[str, Any]) -> tuple[Any, Any]:
|
|
49
|
+
"""Frame of the wrapped model at the coupled joint angles."""
|
|
50
|
+
return self.model.frame(self.joint_angles(q, p), at, p)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, Any]:
|
|
53
|
+
"""The wrapped model and the coupling matrix."""
|
|
54
|
+
return {
|
|
55
|
+
"type": "linear_coupling",
|
|
56
|
+
"model": self.model.to_dict(),
|
|
57
|
+
"coupling": self.coupling.value.tolist(),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, data: dict[str, Any]) -> LinearCoupling:
|
|
62
|
+
"""Inverse of ``to_dict``."""
|
|
63
|
+
inner = data["model"]
|
|
64
|
+
return cls(get("model", inner["type"]).from_dict(inner), data["coupling"])
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Serial chains by the product of exponentials, with all geometry as Params."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import casadi as ca
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ...core.params import ParamSet, as_param
|
|
12
|
+
from ...core.registry import register
|
|
13
|
+
from ...core.space import Euclidean
|
|
14
|
+
from ...core.symbolic import exp_so3
|
|
15
|
+
from ...core.units import M
|
|
16
|
+
|
|
17
|
+
JOINT_TYPES = ("revolute", "prismatic")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@register("model", "poe")
|
|
21
|
+
class SerialChain:
|
|
22
|
+
"""Serial chain of revolute and prismatic joints, as a product of exponentials.
|
|
23
|
+
|
|
24
|
+
Joint i (from 1) has ``j{i}.axis`` (direction at q = 0, normalized internally) and, if revolute,
|
|
25
|
+
``j{i}.point`` [m], a point on its axis. A site has ``{name}.position`` [m] at q = 0 and moves
|
|
26
|
+
with the joints up to ``after`` (1-based). All are ``design`` Params.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
joints: Sequence[str],
|
|
32
|
+
axes: Sequence[Any],
|
|
33
|
+
points: Sequence[Any],
|
|
34
|
+
sites: Mapping[str, tuple[int, Any]],
|
|
35
|
+
) -> None:
|
|
36
|
+
if any(j not in JOINT_TYPES for j in joints):
|
|
37
|
+
raise ValueError(f"joint types must be in {JOINT_TYPES}, got {list(joints)}")
|
|
38
|
+
if not len(joints) == len(axes) == len(points):
|
|
39
|
+
raise ValueError("give one axis and one point per joint")
|
|
40
|
+
self.joints = list(joints)
|
|
41
|
+
self.space = Euclidean(len(joints))
|
|
42
|
+
self.params = ParamSet()
|
|
43
|
+
free = (-np.inf, np.inf)
|
|
44
|
+
for i, (axis, point) in enumerate(zip(axes, points, strict=True)):
|
|
45
|
+
key = f"j{i + 1}.axis"
|
|
46
|
+
self.params.add(as_param(axis, key, scope="design", bounds=free), key)
|
|
47
|
+
key = f"j{i + 1}.point"
|
|
48
|
+
self.params.add(as_param(point, key, unit=M, scope="design", bounds=free), key)
|
|
49
|
+
self.site_joint: dict[str, int] = {}
|
|
50
|
+
for name, (after, position) in sites.items():
|
|
51
|
+
key = f"{name}.position"
|
|
52
|
+
self.params.add(as_param(position, key, unit=M, scope="design", bounds=free), key)
|
|
53
|
+
self.site_joint[name] = int(after)
|
|
54
|
+
self.sites = tuple(sites)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def q_unit(self) -> str:
|
|
58
|
+
"""Unit of the joint coordinates (rad if any joint is revolute)."""
|
|
59
|
+
return "rad" if "revolute" in self.joints else M
|
|
60
|
+
|
|
61
|
+
def frame(self, q: Any, at: str, p: dict[str, Any]) -> tuple[Any, Any]:
|
|
62
|
+
"""(R, position) of a site."""
|
|
63
|
+
if at not in self.site_joint:
|
|
64
|
+
raise KeyError(f"unknown site {at!r}; sites: {self.sites}")
|
|
65
|
+
R, pos = ca.DM.eye(3), ca.DM.zeros(3, 1)
|
|
66
|
+
for i in range(self.site_joint[at]):
|
|
67
|
+
axis = ca.reshape(p[f"j{i + 1}.axis"], 3, 1)
|
|
68
|
+
axis = axis / ca.norm_2(axis)
|
|
69
|
+
if self.joints[i] == "revolute":
|
|
70
|
+
Ri = exp_so3(axis, q[i])
|
|
71
|
+
ti = ca.mtimes(ca.DM.eye(3) - Ri, ca.reshape(p[f"j{i + 1}.point"], 3, 1))
|
|
72
|
+
else:
|
|
73
|
+
Ri, ti = ca.DM.eye(3), axis * q[i]
|
|
74
|
+
R, pos = ca.mtimes(R, Ri), pos + ca.mtimes(R, ti)
|
|
75
|
+
return R, pos + ca.mtimes(R, ca.reshape(p[f"{at}.position"], 3, 1))
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict[str, Any]:
|
|
78
|
+
"""Constructor arguments at the current Param values."""
|
|
79
|
+
n = range(1, len(self.joints) + 1)
|
|
80
|
+
return {
|
|
81
|
+
"type": "poe",
|
|
82
|
+
"joints": list(self.joints),
|
|
83
|
+
"axes": [self.params[f"j{i}.axis"].value.tolist() for i in n],
|
|
84
|
+
"points": [self.params[f"j{i}.point"].value.tolist() for i in n],
|
|
85
|
+
"sites": {
|
|
86
|
+
name: [after, self.params[f"{name}.position"].value.tolist()]
|
|
87
|
+
for name, after in self.site_joint.items()
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dict(cls, data: dict[str, Any]) -> SerialChain:
|
|
93
|
+
"""Inverse of ``to_dict``."""
|
|
94
|
+
sites = {name: (after, pos) for name, (after, pos) in data["sites"].items()}
|
|
95
|
+
return cls(data["joints"], data["axes"], data["points"], sites)
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""ADAPT finger: three phalanges driven by two motors, the last two joints moving together."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ..core.params import Param
|
|
10
|
+
from ..mechanisms import Custom, Joint, LimitSpring, Mechanism, PointMass
|
|
11
|
+
from ..models import LinearCoupling, SerialChain
|
|
12
|
+
|
|
13
|
+
LINK_LENGTHS = (0.040, 0.030, 0.0175)
|
|
14
|
+
"""Proximal, middle and distal phalanx [m], from the MCP joint to the fingertip."""
|
|
15
|
+
|
|
16
|
+
MOTOR_RADIUS = 0.005 # [m] motor pulley
|
|
17
|
+
FINGER_PULLEY_RADIUS = 0.00223 # [m] pulley on the MCP joint
|
|
18
|
+
PIP_TRANSMISSION = 0.00813 # [m] cable transmission constant of the PIP/DIP drive
|
|
19
|
+
|
|
20
|
+
COUPLING = np.array([
|
|
21
|
+
[FINGER_PULLEY_RADIUS / MOTOR_RADIUS, 0.0],
|
|
22
|
+
[0.0, MOTOR_RADIUS / PIP_TRANSMISSION],
|
|
23
|
+
[0.0, MOTOR_RADIUS / PIP_TRANSMISSION],
|
|
24
|
+
]) # fmt: skip
|
|
25
|
+
"""Joint angles (MCP, PIP, DIP) = COUPLING · motor angles; the DIP joint mimics the PIP joint."""
|
|
26
|
+
|
|
27
|
+
LINK_MASSES = (0.0057, 0.0040, 0.025)
|
|
28
|
+
"""Masses of the three phalanges [kg]."""
|
|
29
|
+
|
|
30
|
+
LINK_COGS = (
|
|
31
|
+
np.array([-0.000639, 0.020, 0.001276]),
|
|
32
|
+
np.array([0.000925, 0.015, 0.001138]),
|
|
33
|
+
np.array([-0.000716, 0.0105, 0.000955]),
|
|
34
|
+
)
|
|
35
|
+
"""Centres of gravity in each phalanx's frame [m]."""
|
|
36
|
+
|
|
37
|
+
GRAVITY = (0.0, 0.0, 9.81)
|
|
38
|
+
"""Gravity in the finger's base frame [m/s²], as mounted (the frame's z axis points down)."""
|
|
39
|
+
|
|
40
|
+
JOINT_LIMITS = {"MCP": (0.0, np.pi / 2), "PIP": (0.0, 1.22173), "DIP": (0.0, 1.22173)}
|
|
41
|
+
"""Joint ranges [rad]."""
|
|
42
|
+
|
|
43
|
+
LIMIT_STIFFNESS = 1.0 # [N·m/rad], stiffness of the joint-limit springs
|
|
44
|
+
|
|
45
|
+
MOTOR_EFFICIENCY = (0.8373, 0.3594)
|
|
46
|
+
"""Delivered over commanded torque of the two motors (MCP, PIP)."""
|
|
47
|
+
|
|
48
|
+
TORQUE_LIMIT = 0.8 # [N·m] per motor, for an optional clip at the hardware boundary
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def finger(name: str = "finger", gravity: Any = None) -> Mechanism:
|
|
52
|
+
"""The finger as a robot mechanism; q holds the two motor angles [rad].
|
|
53
|
+
|
|
54
|
+
Sites: ``pip``, ``dip``, ``tip`` and the phalanges' centres of gravity ``*_cog``.
|
|
55
|
+
"""
|
|
56
|
+
a, b, c = LINK_LENGTHS
|
|
57
|
+
x = [1.0, 0.0, 0.0]
|
|
58
|
+
joints = ([0.0, 0.0, 0.0], [0.0, a, 0.0], [0.0, a + b, 0.0])
|
|
59
|
+
sites = {
|
|
60
|
+
"pip": (1, joints[1]),
|
|
61
|
+
"dip": (2, joints[2]),
|
|
62
|
+
"tip": (3, [0.0, a + b + c, 0.0]),
|
|
63
|
+
}
|
|
64
|
+
for i, link in enumerate(("mcp", "pip", "dip")):
|
|
65
|
+
sites[f"{link}_cog"] = (i + 1, np.asarray(joints[i]) + LINK_COGS[i])
|
|
66
|
+
model = LinearCoupling(SerialChain(["revolute"] * 3, [x, x, x], list(joints), sites), COUPLING)
|
|
67
|
+
robot = Mechanism(name, model=model)
|
|
68
|
+
robot.add_param(
|
|
69
|
+
Param(
|
|
70
|
+
"gravity",
|
|
71
|
+
GRAVITY if gravity is None else gravity,
|
|
72
|
+
unit="m/s^2",
|
|
73
|
+
scope="design",
|
|
74
|
+
bounds=(-np.inf, np.inf),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
for i, link in enumerate(("mcp", "pip", "dip")):
|
|
78
|
+
robot.add(f"m_{link}", PointMass(robot.point(f"{link}_cog"), LINK_MASSES[i]))
|
|
79
|
+
return robot
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def joint_angles(robot: Mechanism) -> Custom:
|
|
83
|
+
"""Coordinate of the three joint angles (MCP, PIP, DIP) [rad] of a finger."""
|
|
84
|
+
coupling = robot.model.coupling
|
|
85
|
+
return Custom(
|
|
86
|
+
lambda motors, coupling: coupling @ motors,
|
|
87
|
+
[Joint(slice(0, 2), unit="rad")],
|
|
88
|
+
dim=3,
|
|
89
|
+
unit="rad",
|
|
90
|
+
params={"coupling": coupling},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def joint_limit_spring(robot: Mechanism, stiffness: Any = LIMIT_STIFFNESS) -> LimitSpring:
|
|
95
|
+
"""A spring that pushes each joint back inside its range (zero force inside)."""
|
|
96
|
+
lower = [lim[0] for lim in JOINT_LIMITS.values()]
|
|
97
|
+
upper = [lim[1] for lim in JOINT_LIMITS.values()]
|
|
98
|
+
return LimitSpring(joint_angles(robot), stiffness, lower, upper)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Helyx soft arm: three tendon-driven PCC segments, in the lab's three geometries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ..core.params import Param
|
|
10
|
+
from ..mechanisms import Gravity, LinearDamper, LinearSpring, Mechanism, PointMass
|
|
11
|
+
from ..models import PCC, TendonTransmission
|
|
12
|
+
|
|
13
|
+
GEOMETRIES: dict[str, dict[str, Any]] = {
|
|
14
|
+
"145-145-145": {"L0": (0.145, 0.145, 0.145), "gravity": (0.0, -9.81, 0.0)},
|
|
15
|
+
"145-290-290": {"L0": (0.145, 0.290, 0.290), "gravity": (0.0, 0.0, 9.81)},
|
|
16
|
+
"290-145-145": {"L0": (0.290, 0.145, 0.145), "gravity": (0.0, 0.0, -9.81)},
|
|
17
|
+
}
|
|
18
|
+
"""Segment rest lengths base to tip [m], and gravity in the base frame as mounted [m/s²]:
|
|
19
|
+
side-mounted, hanging, and pointing up."""
|
|
20
|
+
|
|
21
|
+
ENCODER_SIGN: dict[str, float] = {"145-145-145": -1.0, "145-290-290": -1.0, "290-145-145": 1.0}
|
|
22
|
+
"""Sign of each arm's motor encoders against the library convention (θ > 0 pulls a tendon)."""
|
|
23
|
+
|
|
24
|
+
SECTION_RADIUS = 0.030 # [m]
|
|
25
|
+
SPOOL_RADIUS = 0.003 # [m]
|
|
26
|
+
TENDON_ANGLES = np.radians([[0.0, 120.0, -120.0], [60.0, 180.0, -60.0], [150.0, 270.0, 30.0]])
|
|
27
|
+
"""Tendon angles around the section at each segment's base [rad]."""
|
|
28
|
+
SEGMENT_MASS = 0.040 # [kg] per REFERENCE_LENGTH of segment, lumped at the segment's midpoint
|
|
29
|
+
REFERENCE_LENGTH = 0.145 # [m]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def arm(geometry: str = "145-290-290", name: str = "arm", gravity: Any = None) -> Mechanism:
|
|
33
|
+
"""The arm as a robot mechanism: PCC model, tendons, lumped masses and a gravity Param.
|
|
34
|
+
|
|
35
|
+
``gravity`` [m/s², base frame] overrides the geometry's mounting.
|
|
36
|
+
"""
|
|
37
|
+
spec = GEOMETRIES[geometry]
|
|
38
|
+
model = PCC(spec["L0"], SECTION_RADIUS)
|
|
39
|
+
robot = Mechanism(
|
|
40
|
+
name, model=model, actuation=TendonTransmission(list(TENDON_ANGLES), SPOOL_RADIUS)
|
|
41
|
+
)
|
|
42
|
+
g = spec["gravity"] if gravity is None else gravity
|
|
43
|
+
robot.add_param(Param("gravity", g, unit="m/s^2", scope="design", bounds=(-np.inf, np.inf)))
|
|
44
|
+
b = model.breakpoints()
|
|
45
|
+
for i, length in enumerate(spec["L0"]):
|
|
46
|
+
mass = SEGMENT_MASS * (length / REFERENCE_LENGTH)
|
|
47
|
+
robot.add(f"m{i + 1}", PointMass(robot.point(s=(b[i] + b[i + 1]) / 2), mass))
|
|
48
|
+
return robot
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
SIM_STIFFNESS = np.array([
|
|
52
|
+
233.9329569634883, 199.3719645292524, 410.00362883032545,
|
|
53
|
+
391.6936839908778, 475.436633573179, 831.8383997691384,
|
|
54
|
+
445.34472425862594, 517.8961860162584, 938.3740743393556,
|
|
55
|
+
]) / 0.12 # fmt: skip
|
|
56
|
+
"""Diagonal stiffness in Δ of the simulated 145-290-290 arm [N/m], referred to commanded torque
|
|
57
|
+
(identified values referred to delivered torque, divided by the efficiency 0.12)."""
|
|
58
|
+
|
|
59
|
+
SIM_DAMPING = np.array([
|
|
60
|
+
125.13438533964865, 104.5563494024772, 205.43182586195965,
|
|
61
|
+
139.28436710395044, 148.1129490938869, 284.33900221754124,
|
|
62
|
+
135.3243281016147, 141.08179561149416, 280.464403371687,
|
|
63
|
+
]) / 0.12 # fmt: skip
|
|
64
|
+
"""Diagonal damping in Δ of the simulated 145-290-290 arm [N·s/m], referred like the stiffness."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def add_dynamics(robot: Mechanism, stiffness: Any = None, damping: Any = None) -> Mechanism:
|
|
68
|
+
"""Give the arm its physical stiffness and damping in Δ and gravity, for simulation.
|
|
69
|
+
|
|
70
|
+
``stiffness`` [N/m] and ``damping`` [N·s/m] are per-axis (9 values); the defaults are the
|
|
71
|
+
simulated arm's.
|
|
72
|
+
"""
|
|
73
|
+
delta = robot.joint(slice(0, 9))
|
|
74
|
+
K = SIM_STIFFNESS if stiffness is None else stiffness
|
|
75
|
+
D = SIM_DAMPING if damping is None else damping
|
|
76
|
+
robot.add("stiffness", LinearSpring(delta, Param("stiffness", K, unit="N/m", scope="design")))
|
|
77
|
+
robot.add("damping", LinearDamper(delta, Param("damping", D, unit="N*s/m", scope="design")))
|
|
78
|
+
robot.add("gravity", Gravity(robot))
|
|
79
|
+
return robot
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Simulation and the run loop: plants, a model-based simulator, guard and recorder."""
|
|
2
|
+
|
|
3
|
+
from .model_plant import ModelPlant
|
|
4
|
+
from .plant import Plant, SimPlant
|
|
5
|
+
from .run import Guard, RunLog, SimClock, run
|
|
6
|
+
|
|
7
|
+
__all__ = ["Guard", "ModelPlant", "Plant", "RunLog", "SimClock", "SimPlant", "run"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""ModelPlant: simulates a robot from its own components, and reads like the hardware."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numpy.typing import ArrayLike
|
|
11
|
+
|
|
12
|
+
from ..core.signals import Signals
|
|
13
|
+
from ..dynamics import compile_dynamics
|
|
14
|
+
from ..mechanisms.mechanism import Mechanism
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ModelPlant:
|
|
18
|
+
"""Simulated robot: linearly implicit Euler steps of at most ``max_step`` [s].
|
|
19
|
+
|
|
20
|
+
``read`` returns motor angles and rates (as the hardware does) plus q and v. The command
|
|
21
|
+
``motor_torque`` is held between calls to ``advance``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
robot: Mechanism,
|
|
27
|
+
q0: ArrayLike | None = None,
|
|
28
|
+
v0: ArrayLike | None = None,
|
|
29
|
+
*,
|
|
30
|
+
max_step: float = 1e-3,
|
|
31
|
+
runtime: Iterable[str] = (),
|
|
32
|
+
) -> None:
|
|
33
|
+
self.dynamics = compile_dynamics(robot, runtime)
|
|
34
|
+
self.space = robot.model.space
|
|
35
|
+
self.max_step = max_step
|
|
36
|
+
self.p = self.dynamics.live_values()
|
|
37
|
+
self._q0 = self.space.neutral() if q0 is None else np.asarray(q0, dtype=float)
|
|
38
|
+
self._v0 = np.zeros(self.space.nv) if v0 is None else np.asarray(v0, dtype=float)
|
|
39
|
+
self.reset()
|
|
40
|
+
|
|
41
|
+
def reset(self, x0: Any = None, seed: int | None = None) -> None:
|
|
42
|
+
"""Back to (q0, v0), or to ``x0 = (q, v)``; time 0, zero command."""
|
|
43
|
+
q, v = (self._q0, self._v0) if x0 is None else x0
|
|
44
|
+
self.q: np.ndarray = np.array(q, dtype=float)
|
|
45
|
+
self.v: np.ndarray = np.array(v, dtype=float)
|
|
46
|
+
self.u: np.ndarray = np.zeros(self.dynamics.n_u)
|
|
47
|
+
self.t = 0.0
|
|
48
|
+
|
|
49
|
+
def read(self) -> Signals:
|
|
50
|
+
"""Motor angles and rates, plus the configuration and velocity."""
|
|
51
|
+
theta, theta_dot = self.dynamics.motors(self.q, self.v, self.p)
|
|
52
|
+
return Signals(
|
|
53
|
+
self.t,
|
|
54
|
+
motor_position=np.array(theta).ravel(),
|
|
55
|
+
motor_velocity=np.array(theta_dot).ravel(),
|
|
56
|
+
q=self.q,
|
|
57
|
+
v=self.v,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def write(self, cmd: Signals) -> None:
|
|
61
|
+
"""Hold ``motor_torque`` [N·m] until the next write."""
|
|
62
|
+
self.u = np.array(cmd["motor_torque"], dtype=float)
|
|
63
|
+
|
|
64
|
+
def advance(self, dt: float) -> None:
|
|
65
|
+
"""Integrate over ``dt`` [s]."""
|
|
66
|
+
n = max(1, math.ceil(dt / self.max_step - 1e-9))
|
|
67
|
+
h = dt / n
|
|
68
|
+
for _ in range(n):
|
|
69
|
+
q, v = self.dynamics.step(self.q, self.v, self.u, self.p, self.t, h)
|
|
70
|
+
self.q, self.v = np.array(q).ravel(), np.array(v).ravel()
|
|
71
|
+
self.t += h
|
|
72
|
+
|
|
73
|
+
def energy(self) -> float:
|
|
74
|
+
"""Kinetic plus stored energy of the robot [J]."""
|
|
75
|
+
T, V = self.dynamics.energy(self.q, self.v, self.p, self.t)
|
|
76
|
+
return float(T) + float(V)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
"""Nothing to release."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Plants: anything that can be read and commanded, simulated or real."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
from ..core.signals import Signals
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Plant(Protocol):
|
|
11
|
+
"""A robot as the controller sees it: ``read`` measurements, ``write`` commands.
|
|
12
|
+
|
|
13
|
+
Hardware and simulators implement the same three methods, so one run loop serves both.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def read(self) -> Signals:
|
|
17
|
+
"""Latest measurements (``motor_position`` [rad], ``motor_velocity`` [rad/s], ...)."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def write(self, cmd: Signals) -> None:
|
|
21
|
+
"""Apply a command (``motor_torque`` [N·m])."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def close(self) -> None:
|
|
25
|
+
"""Release resources; a real plant stops its motors."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SimPlant(Plant, Protocol):
|
|
30
|
+
"""A simulated plant: it also resets and advances its own time ``t`` [s]."""
|
|
31
|
+
|
|
32
|
+
t: float
|
|
33
|
+
|
|
34
|
+
def reset(self, x0: Any = None, seed: int | None = None) -> None:
|
|
35
|
+
"""Back to an initial state."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def advance(self, dt: float) -> None:
|
|
39
|
+
"""Integrate over ``dt`` [s] holding the last command."""
|
|
40
|
+
...
|