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.
Files changed (49) hide show
  1. virtualmodelcontrol/__init__.py +75 -0
  2. virtualmodelcontrol/_version.py +24 -0
  3. virtualmodelcontrol/compiler.py +158 -0
  4. virtualmodelcontrol/control/__init__.py +5 -0
  5. virtualmodelcontrol/control/controller.py +76 -0
  6. virtualmodelcontrol/core/__init__.py +24 -0
  7. virtualmodelcontrol/core/params.py +244 -0
  8. virtualmodelcontrol/core/registry.py +54 -0
  9. virtualmodelcontrol/core/signals.py +46 -0
  10. virtualmodelcontrol/core/space.py +162 -0
  11. virtualmodelcontrol/core/symbolic.py +67 -0
  12. virtualmodelcontrol/core/units.py +34 -0
  13. virtualmodelcontrol/dynamics.py +149 -0
  14. virtualmodelcontrol/mechanisms/__init__.py +67 -0
  15. virtualmodelcontrol/mechanisms/components/__init__.py +33 -0
  16. virtualmodelcontrol/mechanisms/components/base.py +71 -0
  17. virtualmodelcontrol/mechanisms/components/dissipation.py +50 -0
  18. virtualmodelcontrol/mechanisms/components/inertance.py +56 -0
  19. virtualmodelcontrol/mechanisms/components/sources.py +63 -0
  20. virtualmodelcontrol/mechanisms/components/storage.py +272 -0
  21. virtualmodelcontrol/mechanisms/coordinates/__init__.py +24 -0
  22. virtualmodelcontrol/mechanisms/coordinates/base.py +117 -0
  23. virtualmodelcontrol/mechanisms/coordinates/frames.py +62 -0
  24. virtualmodelcontrol/mechanisms/coordinates/joints.py +51 -0
  25. virtualmodelcontrol/mechanisms/coordinates/ops.py +146 -0
  26. virtualmodelcontrol/mechanisms/coordinates/references.py +43 -0
  27. virtualmodelcontrol/mechanisms/mechanism.py +88 -0
  28. virtualmodelcontrol/models/__init__.py +22 -0
  29. virtualmodelcontrol/models/actuation.py +196 -0
  30. virtualmodelcontrol/models/assembly.py +154 -0
  31. virtualmodelcontrol/models/continuum/__init__.py +5 -0
  32. virtualmodelcontrol/models/continuum/pcc.py +120 -0
  33. virtualmodelcontrol/models/kinematic.py +41 -0
  34. virtualmodelcontrol/models/rigid/__init__.py +6 -0
  35. virtualmodelcontrol/models/rigid/couplings.py +64 -0
  36. virtualmodelcontrol/models/rigid/poe.py +95 -0
  37. virtualmodelcontrol/py.typed +0 -0
  38. virtualmodelcontrol/robots/__init__.py +5 -0
  39. virtualmodelcontrol/robots/adapt.py +98 -0
  40. virtualmodelcontrol/robots/helyx.py +79 -0
  41. virtualmodelcontrol/sim/__init__.py +7 -0
  42. virtualmodelcontrol/sim/model_plant.py +79 -0
  43. virtualmodelcontrol/sim/plant.py +40 -0
  44. virtualmodelcontrol/sim/run.py +73 -0
  45. virtualmodelcontrol/system.py +55 -0
  46. virtualmodelcontrol-0.1.0.dist-info/METADATA +82 -0
  47. virtualmodelcontrol-0.1.0.dist-info/RECORD +49 -0
  48. virtualmodelcontrol-0.1.0.dist-info/WHEEL +4 -0
  49. 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,6 @@
1
+ """Rigid models."""
2
+
3
+ from .couplings import LinearCoupling
4
+ from .poe import SerialChain
5
+
6
+ __all__ = ["LinearCoupling", "SerialChain"]
@@ -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,5 @@
1
+ """Robot data: model builders and default parameters for the lab's robots."""
2
+
3
+ from . import adapt, helyx
4
+
5
+ __all__ = ["adapt", "helyx"]
@@ -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
+ ...