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,46 @@
|
|
|
1
|
+
"""Signals: named SI arrays stamped with a time, each with a valid flag."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import ArrayLike
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Signals:
|
|
10
|
+
"""Named SI arrays at time ``t`` [s]; an array counts as valid only if flagged and finite."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, t: float = 0.0, **values: ArrayLike) -> None:
|
|
13
|
+
self.t = float(t)
|
|
14
|
+
self._data: dict[str, np.ndarray] = {}
|
|
15
|
+
self._valid: dict[str, bool] = {}
|
|
16
|
+
for name, value in values.items():
|
|
17
|
+
self.set(name, value)
|
|
18
|
+
|
|
19
|
+
def set(self, name: str, value: ArrayLike, valid: bool = True) -> None:
|
|
20
|
+
"""Store ``value`` (as a 1-D float array) under ``name``."""
|
|
21
|
+
self._data[name] = np.atleast_1d(np.asarray(value, dtype=float))
|
|
22
|
+
self._valid[name] = bool(valid)
|
|
23
|
+
|
|
24
|
+
def __getitem__(self, name: str) -> np.ndarray:
|
|
25
|
+
return self._data[name]
|
|
26
|
+
|
|
27
|
+
def __contains__(self, name: object) -> bool:
|
|
28
|
+
return name in self._data
|
|
29
|
+
|
|
30
|
+
def get(self, name: str, default: np.ndarray | None = None) -> np.ndarray | None:
|
|
31
|
+
"""Array under ``name``, or ``default``."""
|
|
32
|
+
return self._data.get(name, default)
|
|
33
|
+
|
|
34
|
+
def is_valid(self, name: str) -> bool:
|
|
35
|
+
"""True if present, flagged valid and free of NaN and Inf."""
|
|
36
|
+
return (
|
|
37
|
+
name in self._data and self._valid[name] and bool(np.all(np.isfinite(self._data[name])))
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def names(self) -> list[str]:
|
|
42
|
+
"""Names of the stored arrays."""
|
|
43
|
+
return list(self._data)
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return f"Signals(t={self.t}, {', '.join(self._data)})"
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Configuration spaces: q lives on the space (nq numbers), v in its tangent space (nv numbers)."""
|
|
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
|
+
|
|
10
|
+
from .symbolic import is_casadi
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Space(Protocol):
|
|
14
|
+
"""Where a configuration lives. ``integrate`` and ``difference`` accept numpy or CasADi."""
|
|
15
|
+
|
|
16
|
+
nq: int
|
|
17
|
+
nv: int
|
|
18
|
+
|
|
19
|
+
def neutral(self) -> np.ndarray:
|
|
20
|
+
"""Reference configuration (zero displacement)."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def integrate(self, q: Any, v: Any) -> Any:
|
|
24
|
+
"""Configuration reached from ``q`` by the tangent step ``v``."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def difference(self, q1: Any, q0: Any) -> Any:
|
|
28
|
+
"""Tangent step ``v`` with ``integrate(q0, v) = q1``."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def velocity_map(self, q: Any) -> Any:
|
|
32
|
+
"""G(q) with q̇ = G(q) v, shape (nq, nv)."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _numeric_call(fn: Any, *args: Any) -> Any:
|
|
37
|
+
"""Run a CasADi-written ``fn`` on numpy inputs and return numpy; pass CasADi through."""
|
|
38
|
+
if any(is_casadi(a) for a in args):
|
|
39
|
+
return fn(*args)
|
|
40
|
+
out = np.array(fn(*[ca.DM(np.asarray(a, dtype=float).reshape(-1)) for a in args]))
|
|
41
|
+
return out.ravel() if out.shape[1] == 1 else out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Euclidean:
|
|
45
|
+
"""Flat space: q and v are both n-vectors."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, n: int) -> None:
|
|
48
|
+
self.nq = self.nv = int(n)
|
|
49
|
+
|
|
50
|
+
def neutral(self) -> np.ndarray:
|
|
51
|
+
"""Origin."""
|
|
52
|
+
return np.zeros(self.nq)
|
|
53
|
+
|
|
54
|
+
def integrate(self, q: Any, v: Any) -> Any:
|
|
55
|
+
"""``q + v``."""
|
|
56
|
+
if is_casadi(q) or is_casadi(v):
|
|
57
|
+
return q + v
|
|
58
|
+
return np.asarray(q, dtype=float) + np.asarray(v, dtype=float)
|
|
59
|
+
|
|
60
|
+
def difference(self, q1: Any, q0: Any) -> Any:
|
|
61
|
+
"""``q1 - q0``."""
|
|
62
|
+
if is_casadi(q1) or is_casadi(q0):
|
|
63
|
+
return q1 - q0
|
|
64
|
+
return np.asarray(q1, dtype=float) - np.asarray(q0, dtype=float)
|
|
65
|
+
|
|
66
|
+
def velocity_map(self, q: Any) -> Any:
|
|
67
|
+
"""Identity."""
|
|
68
|
+
return type(q).eye(self.nq) if is_casadi(q) else np.eye(self.nq)
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
return f"Euclidean({self.nq})"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SO2:
|
|
75
|
+
"""Planar rotations as unit complex numbers: q = (cos θ, sin θ), v = angle step (rad)."""
|
|
76
|
+
|
|
77
|
+
nq = 2
|
|
78
|
+
nv = 1
|
|
79
|
+
|
|
80
|
+
def neutral(self) -> np.ndarray:
|
|
81
|
+
"""Zero angle."""
|
|
82
|
+
return np.array([1.0, 0.0])
|
|
83
|
+
|
|
84
|
+
def integrate(self, q: Any, v: Any) -> Any:
|
|
85
|
+
"""Rotate ``q`` by the angle ``v``."""
|
|
86
|
+
|
|
87
|
+
def fn(q: Any, v: Any) -> Any:
|
|
88
|
+
c, s, w = q[0], q[1], v[0]
|
|
89
|
+
return ca.vertcat(c * ca.cos(w) - s * ca.sin(w), s * ca.cos(w) + c * ca.sin(w))
|
|
90
|
+
|
|
91
|
+
return _numeric_call(fn, q, v)
|
|
92
|
+
|
|
93
|
+
def difference(self, q1: Any, q0: Any) -> Any:
|
|
94
|
+
"""Angle from ``q0`` to ``q1``, in (-π, π]."""
|
|
95
|
+
|
|
96
|
+
def fn(q1: Any, q0: Any) -> Any:
|
|
97
|
+
return ca.atan2(q1[1] * q0[0] - q1[0] * q0[1], q1[0] * q0[0] + q1[1] * q0[1])
|
|
98
|
+
|
|
99
|
+
return _numeric_call(fn, q1, q0)
|
|
100
|
+
|
|
101
|
+
def velocity_map(self, q: Any) -> Any:
|
|
102
|
+
"""(-sin θ, cos θ) as a column."""
|
|
103
|
+
if is_casadi(q):
|
|
104
|
+
return ca.vertcat(-q[1], q[0])
|
|
105
|
+
c, s = np.asarray(q, dtype=float).ravel()
|
|
106
|
+
return np.array([[-s], [c]])
|
|
107
|
+
|
|
108
|
+
def __repr__(self) -> str:
|
|
109
|
+
return "SO2()"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Product:
|
|
113
|
+
"""Cartesian product of spaces; q and v are the blocks stacked in order."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, *spaces: Space) -> None:
|
|
116
|
+
self.spaces = spaces
|
|
117
|
+
self.nq = sum(s.nq for s in spaces)
|
|
118
|
+
self.nv = sum(s.nv for s in spaces)
|
|
119
|
+
|
|
120
|
+
def _blocks(self, x: Any, attr: str) -> list[Any]:
|
|
121
|
+
out, offset = [], 0
|
|
122
|
+
for space in self.spaces:
|
|
123
|
+
n = getattr(space, attr)
|
|
124
|
+
out.append(x[offset : offset + n])
|
|
125
|
+
offset += n
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _cat(parts: list[Any]) -> Any:
|
|
130
|
+
if any(is_casadi(p) for p in parts):
|
|
131
|
+
return ca.vertcat(*parts)
|
|
132
|
+
return np.concatenate([np.ravel(p) for p in parts])
|
|
133
|
+
|
|
134
|
+
def neutral(self) -> np.ndarray:
|
|
135
|
+
"""Each block's neutral configuration."""
|
|
136
|
+
return np.concatenate([s.neutral() for s in self.spaces])
|
|
137
|
+
|
|
138
|
+
def integrate(self, q: Any, v: Any) -> Any:
|
|
139
|
+
"""Integrate block by block."""
|
|
140
|
+
qs, vs = self._blocks(q, "nq"), self._blocks(v, "nv")
|
|
141
|
+
return self._cat([s.integrate(a, b) for s, a, b in zip(self.spaces, qs, vs, strict=True)])
|
|
142
|
+
|
|
143
|
+
def difference(self, q1: Any, q0: Any) -> Any:
|
|
144
|
+
"""Difference block by block."""
|
|
145
|
+
a, b = self._blocks(q1, "nq"), self._blocks(q0, "nq")
|
|
146
|
+
return self._cat([s.difference(x, y) for s, x, y in zip(self.spaces, a, b, strict=True)])
|
|
147
|
+
|
|
148
|
+
def velocity_map(self, q: Any) -> Any:
|
|
149
|
+
"""Block-diagonal G(q)."""
|
|
150
|
+
qs = self._blocks(q, "nq")
|
|
151
|
+
blocks = [s.velocity_map(x) for s, x in zip(self.spaces, qs, strict=True)]
|
|
152
|
+
if any(is_casadi(b) for b in blocks):
|
|
153
|
+
return ca.diagcat(*blocks)
|
|
154
|
+
out, i, j = np.zeros((self.nq, self.nv)), 0, 0
|
|
155
|
+
for block in blocks:
|
|
156
|
+
r, c = np.shape(block)
|
|
157
|
+
out[i : i + r, j : j + c] = block
|
|
158
|
+
i, j = i + r, j + c
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
def __repr__(self) -> str:
|
|
162
|
+
return f"Product({', '.join(map(repr, self.spaces))})"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Symbolic helpers shared by the models: type checks, smooth norms, quadrature, rotations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import casadi as ca
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_casadi(x: Any) -> bool:
|
|
13
|
+
"""True for CasADi symbols (SX, MX) and numeric matrices (DM)."""
|
|
14
|
+
return isinstance(x, (ca.SX, ca.MX, ca.DM))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_symbolic(x: Any) -> bool:
|
|
18
|
+
"""True for CasADi symbols (SX, MX)."""
|
|
19
|
+
return isinstance(x, (ca.SX, ca.MX))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def smooth_norm(x: Any, eps: float = 1e-18) -> Any:
|
|
23
|
+
"""√(xᵀx + ε): the Euclidean norm, differentiable at zero."""
|
|
24
|
+
return ca.sqrt(ca.sumsqr(x) + eps)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def logcosh(x: Any) -> Any:
|
|
28
|
+
"""log(cosh x), written to avoid overflow for large arguments."""
|
|
29
|
+
a = ca.fabs(x)
|
|
30
|
+
return a + ca.log1p(ca.exp(-2.0 * a)) - np.log(2.0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def gauss_legendre(n: int) -> tuple[np.ndarray, np.ndarray]:
|
|
34
|
+
"""Gauss–Legendre nodes and weights on [0, 1], exact for polynomials of degree 2n − 1."""
|
|
35
|
+
t, w = np.polynomial.legendre.leggauss(n)
|
|
36
|
+
return 0.5 * (t + 1.0), 0.5 * w
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def quad(f: Callable[[Any], Any], a: Any, b: Any, n: int = 16) -> Any:
|
|
40
|
+
"""∫ f over [a, b] by n-point Gauss–Legendre quadrature; a, b and f may be symbolic."""
|
|
41
|
+
t, w = gauss_legendre(n)
|
|
42
|
+
h = b - a
|
|
43
|
+
return h * sum(float(wi) * f(a + h * float(ti)) for ti, wi in zip(t, w, strict=True))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def skew(w: Any) -> Any:
|
|
47
|
+
"""Skew-symmetric matrix [w]× with [w]× x = w × x."""
|
|
48
|
+
return ca.vertcat(
|
|
49
|
+
ca.horzcat(0, -w[2], w[1]),
|
|
50
|
+
ca.horzcat(w[2], 0, -w[0]),
|
|
51
|
+
ca.horzcat(-w[1], w[0], 0),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def exp_so3(axis: Any, angle: Any) -> Any:
|
|
56
|
+
"""Rotation by ``angle`` (rad) about the unit vector ``axis`` (Rodrigues)."""
|
|
57
|
+
k = skew(axis)
|
|
58
|
+
return ca.DM.eye(3) + ca.sin(angle) * k + (1 - ca.cos(angle)) * ca.mtimes(k, k)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def rotation_from_vector(w: Any) -> Any:
|
|
62
|
+
"""Rotation matrix of a rotation vector w (axis · angle [rad]); smooth at w = 0."""
|
|
63
|
+
theta = ca.sqrt(ca.sumsqr(w) + 1e-24)
|
|
64
|
+
k = skew(w)
|
|
65
|
+
return (
|
|
66
|
+
ca.DM.eye(3) + ca.sin(theta) / theta * k + (1 - ca.cos(theta)) / theta**2 * ca.mtimes(k, k)
|
|
67
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Unit strings for parameters (labels only; every value inside the library is SI)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
M = "m"
|
|
6
|
+
RAD = "rad"
|
|
7
|
+
S = "s"
|
|
8
|
+
KG = "kg"
|
|
9
|
+
N = "N"
|
|
10
|
+
NM = "N*m"
|
|
11
|
+
M_S2 = "m/s^2"
|
|
12
|
+
KG_M2 = "kg*m^2"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def force_unit(coord_unit: str) -> str:
|
|
16
|
+
"""Unit of the generalized force conjugate to a coordinate of unit ``coord_unit``."""
|
|
17
|
+
return {M: N, RAD: NM}.get(coord_unit, f"J/{coord_unit}" if coord_unit else "J")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def stiffness_unit(coord_unit: str) -> str:
|
|
21
|
+
"""Force per coordinate unit."""
|
|
22
|
+
return {M: "N/m", RAD: "N*m/rad"}.get(coord_unit, f"J/{coord_unit}^2" if coord_unit else "J")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def damping_unit(coord_unit: str) -> str:
|
|
26
|
+
"""Force per coordinate velocity."""
|
|
27
|
+
return {M: "N*s/m", RAD: "N*m*s/rad"}.get(
|
|
28
|
+
coord_unit, f"J*s/{coord_unit}^2" if coord_unit else "J*s"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def inertance_unit(coord_unit: str) -> str:
|
|
33
|
+
"""Force per coordinate acceleration."""
|
|
34
|
+
return {M: KG, RAD: KG_M2}.get(coord_unit, f"J*s^2/{coord_unit}^2" if coord_unit else "J*s^2")
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Robot dynamics assembled from the robot's own components, in the canonical residual form.
|
|
2
|
+
|
|
3
|
+
M(q) a + h(q, v) − B(q) u − Σ (J G)ᵀ f = 0: inertances give M, the Lagrangian of
|
|
4
|
+
T = ½ vᵀ M v gives h, springs, gravity and dampers give f, the actuation gives B.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import casadi as ca
|
|
14
|
+
|
|
15
|
+
from .core.params import Binding, ParamSet
|
|
16
|
+
from .mechanisms.coordinates.base import Context
|
|
17
|
+
from .mechanisms.mechanism import Mechanism
|
|
18
|
+
from .models.actuation import Direct
|
|
19
|
+
|
|
20
|
+
OPTS = {"cse": True}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Dynamics:
|
|
25
|
+
"""CasADi functions of a robot's dynamics; p holds its live Params.
|
|
26
|
+
|
|
27
|
+
``forward`` (q, v, u, p, t) → a; ``residual`` (q, v, a, u, p, t) → r; ``mass`` (q, p) → M;
|
|
28
|
+
``energy`` (q, v, p, t) → (T, V); ``power`` (q, v, u, p, t) → (input, dissipation, source);
|
|
29
|
+
``motors`` (q, v, p) → (θ, θ̇); ``step`` (q, v, u, p, t, h) → (q⁺, v⁺), a linearly implicit
|
|
30
|
+
Euler step, stable for stiff springs and dampers.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
robot: Mechanism
|
|
34
|
+
params: ParamSet
|
|
35
|
+
live: list[str]
|
|
36
|
+
forward: ca.Function
|
|
37
|
+
residual: ca.Function
|
|
38
|
+
mass: ca.Function
|
|
39
|
+
energy: ca.Function
|
|
40
|
+
power: ca.Function
|
|
41
|
+
motors: ca.Function
|
|
42
|
+
step: ca.Function
|
|
43
|
+
n_u: int
|
|
44
|
+
|
|
45
|
+
def live_values(self) -> Any:
|
|
46
|
+
"""Current values of the live Params, packed like p."""
|
|
47
|
+
return self.params.vector(self.live)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def compile_dynamics(robot: Mechanism, runtime: Iterable[str] = ()) -> Dynamics:
|
|
51
|
+
"""Compile the dynamics of ``robot`` from its components.
|
|
52
|
+
|
|
53
|
+
Valid for spaces whose velocity coordinates commute (Euclidean, SO2 and their products).
|
|
54
|
+
``stage`` Params and those matching ``runtime`` stay live; the rest are folded in.
|
|
55
|
+
"""
|
|
56
|
+
if robot.model is None:
|
|
57
|
+
raise ValueError(f"robot {robot.name!r} needs a kinematic model")
|
|
58
|
+
params = ParamSet()
|
|
59
|
+
params.merge(robot.params, robot.name)
|
|
60
|
+
binding = Binding(params, params.select(patterns=runtime, scopes=["stage"]))
|
|
61
|
+
space = robot.model.space
|
|
62
|
+
actuation = robot.actuation if robot.actuation is not None else Direct()
|
|
63
|
+
pa = binding.view(actuation.params)
|
|
64
|
+
n_u = actuation.motor_sizes(space)[1]
|
|
65
|
+
q, v, a = ca.SX.sym("q", space.nq), ca.SX.sym("v", space.nv), ca.SX.sym("a", space.nv)
|
|
66
|
+
u, t, h = ca.SX.sym("u", n_u), ca.SX.sym("t"), ca.SX.sym("h")
|
|
67
|
+
ctx = Context(q, binding, t=t)
|
|
68
|
+
G = space.velocity_map(q)
|
|
69
|
+
|
|
70
|
+
M = ca.SX.zeros(space.nv, space.nv)
|
|
71
|
+
f_gen = ca.SX.zeros(space.nv, 1)
|
|
72
|
+
V, P_diss, P_src = ca.SX(0), ca.SX(0), ca.SX(0)
|
|
73
|
+
for name, comp in robot.components.items():
|
|
74
|
+
y = ctx.value(comp.coord)
|
|
75
|
+
J = ca.mtimes(ca.jacobian(y, q), G)
|
|
76
|
+
if comp.kind == "inertance":
|
|
77
|
+
M += ca.mtimes([J.T, comp.inertance(ctx, y), J])
|
|
78
|
+
continue
|
|
79
|
+
yd = ca.mtimes(J, v) + ca.jacobian(y, t)
|
|
80
|
+
f = comp.force(ctx, y, yd)
|
|
81
|
+
f_gen += ca.mtimes(J.T, f)
|
|
82
|
+
if comp.kind == "storage":
|
|
83
|
+
V += comp.energy(ctx, y)
|
|
84
|
+
elif comp.kind == "dissipation":
|
|
85
|
+
P_diss += ca.dot(f, yd)
|
|
86
|
+
elif comp.kind == "source":
|
|
87
|
+
P_src += ca.dot(f, yd)
|
|
88
|
+
else:
|
|
89
|
+
raise ValueError(f"{name}: unknown component kind {comp.kind!r}")
|
|
90
|
+
|
|
91
|
+
T = 0.5 * ca.dot(v, ca.mtimes(M, v))
|
|
92
|
+
h_vec = ca.mtimes(ca.jacobian(ca.mtimes(M, v), q), ca.mtimes(G, v)) - ca.mtimes(
|
|
93
|
+
G.T, ca.gradient(T, q)
|
|
94
|
+
)
|
|
95
|
+
tau_u = actuation.generalized_force(u, q, pa)
|
|
96
|
+
rhs = f_gen + tau_u - h_vec
|
|
97
|
+
acc = ca.solve(M, rhs)
|
|
98
|
+
|
|
99
|
+
# Linearly implicit Euler: (M − h ∂f/∂v − h² ∂f/∂q G) Δv = h (rhs + h ∂f/∂q G v). Only the
|
|
100
|
+
# component and input forces f are linearized (stiffness and damping); the velocity-squared
|
|
101
|
+
# terms h stay explicit, which keeps the step first order and cheap.
|
|
102
|
+
f_lin = f_gen + tau_u
|
|
103
|
+
dq = ca.mtimes(ca.jacobian(f_lin, q), G)
|
|
104
|
+
dv = ca.jacobian(f_lin, v)
|
|
105
|
+
delta_v = ca.solve(M - h * dv - h**2 * dq, h * (rhs + h * ca.mtimes(dq, v)))
|
|
106
|
+
v_next = v + delta_v
|
|
107
|
+
q_next = space.integrate(q, h * v_next)
|
|
108
|
+
|
|
109
|
+
p = binding.p
|
|
110
|
+
theta = actuation.motor_angles(q, pa)
|
|
111
|
+
theta_dot = actuation.motor_rates(q, v, pa)
|
|
112
|
+
return Dynamics(
|
|
113
|
+
robot=robot,
|
|
114
|
+
params=params,
|
|
115
|
+
live=binding.live,
|
|
116
|
+
forward=ca.Function(
|
|
117
|
+
"forward", [q, v, u, p, t], [acc], ["q", "v", "u", "p", "t"], ["a"], OPTS
|
|
118
|
+
),
|
|
119
|
+
residual=ca.Function(
|
|
120
|
+
"residual",
|
|
121
|
+
[q, v, a, u, p, t],
|
|
122
|
+
[ca.mtimes(M, a) - rhs],
|
|
123
|
+
["q", "v", "a", "u", "p", "t"],
|
|
124
|
+
["r"],
|
|
125
|
+
OPTS,
|
|
126
|
+
),
|
|
127
|
+
mass=ca.Function("mass", [q, p], [M], ["q", "p"], ["M"], OPTS),
|
|
128
|
+
energy=ca.Function("energy", [q, v, p, t], [T, V], ["q", "v", "p", "t"], ["T", "V"], OPTS),
|
|
129
|
+
power=ca.Function(
|
|
130
|
+
"power",
|
|
131
|
+
[q, v, u, p, t],
|
|
132
|
+
[ca.dot(tau_u, v), P_diss, P_src],
|
|
133
|
+
["q", "v", "u", "p", "t"],
|
|
134
|
+
["input", "dissipation", "source"],
|
|
135
|
+
OPTS,
|
|
136
|
+
),
|
|
137
|
+
motors=ca.Function(
|
|
138
|
+
"motors", [q, v, p], [theta, theta_dot], ["q", "v", "p"], ["theta", "theta_dot"], OPTS
|
|
139
|
+
),
|
|
140
|
+
step=ca.Function(
|
|
141
|
+
"step",
|
|
142
|
+
[q, v, u, p, t, h],
|
|
143
|
+
[q_next, v_next],
|
|
144
|
+
["q", "v", "u", "p", "t", "h"],
|
|
145
|
+
["q_next", "v_next"],
|
|
146
|
+
OPTS,
|
|
147
|
+
),
|
|
148
|
+
n_u=n_u,
|
|
149
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Mechanisms: coordinates and the components that act on them."""
|
|
2
|
+
|
|
3
|
+
from .components import (
|
|
4
|
+
KINDS,
|
|
5
|
+
Component,
|
|
6
|
+
ForceSource,
|
|
7
|
+
GaussianSpring,
|
|
8
|
+
Gravity,
|
|
9
|
+
GravityCompensation,
|
|
10
|
+
Inertance,
|
|
11
|
+
LimitSpring,
|
|
12
|
+
LinearDamper,
|
|
13
|
+
LinearSpring,
|
|
14
|
+
PointMass,
|
|
15
|
+
PolynomialSpring,
|
|
16
|
+
SigmoidSpring,
|
|
17
|
+
TanhDamper,
|
|
18
|
+
TanhSpring,
|
|
19
|
+
)
|
|
20
|
+
from .coordinates import (
|
|
21
|
+
Context,
|
|
22
|
+
Coordinate,
|
|
23
|
+
Custom,
|
|
24
|
+
Difference,
|
|
25
|
+
FramePoint,
|
|
26
|
+
Joint,
|
|
27
|
+
Norm,
|
|
28
|
+
Projection,
|
|
29
|
+
Ref,
|
|
30
|
+
Slice,
|
|
31
|
+
Stack,
|
|
32
|
+
State,
|
|
33
|
+
walk,
|
|
34
|
+
)
|
|
35
|
+
from .mechanism import Mechanism
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"KINDS",
|
|
39
|
+
"Component",
|
|
40
|
+
"Context",
|
|
41
|
+
"Coordinate",
|
|
42
|
+
"Custom",
|
|
43
|
+
"Difference",
|
|
44
|
+
"ForceSource",
|
|
45
|
+
"FramePoint",
|
|
46
|
+
"GaussianSpring",
|
|
47
|
+
"Gravity",
|
|
48
|
+
"GravityCompensation",
|
|
49
|
+
"Inertance",
|
|
50
|
+
"Joint",
|
|
51
|
+
"LimitSpring",
|
|
52
|
+
"LinearDamper",
|
|
53
|
+
"LinearSpring",
|
|
54
|
+
"Mechanism",
|
|
55
|
+
"Norm",
|
|
56
|
+
"PointMass",
|
|
57
|
+
"PolynomialSpring",
|
|
58
|
+
"Projection",
|
|
59
|
+
"Ref",
|
|
60
|
+
"SigmoidSpring",
|
|
61
|
+
"Slice",
|
|
62
|
+
"Stack",
|
|
63
|
+
"State",
|
|
64
|
+
"TanhDamper",
|
|
65
|
+
"TanhSpring",
|
|
66
|
+
"walk",
|
|
67
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Components: storage (springs), dissipation (dampers), inertance (masses) and sources."""
|
|
2
|
+
|
|
3
|
+
from .base import KINDS, Component
|
|
4
|
+
from .dissipation import LinearDamper, TanhDamper
|
|
5
|
+
from .inertance import Inertance, PointMass
|
|
6
|
+
from .sources import ForceSource, GravityCompensation
|
|
7
|
+
from .storage import (
|
|
8
|
+
GaussianSpring,
|
|
9
|
+
Gravity,
|
|
10
|
+
LimitSpring,
|
|
11
|
+
LinearSpring,
|
|
12
|
+
PolynomialSpring,
|
|
13
|
+
SigmoidSpring,
|
|
14
|
+
TanhSpring,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"KINDS",
|
|
19
|
+
"Component",
|
|
20
|
+
"ForceSource",
|
|
21
|
+
"GaussianSpring",
|
|
22
|
+
"Gravity",
|
|
23
|
+
"GravityCompensation",
|
|
24
|
+
"Inertance",
|
|
25
|
+
"LimitSpring",
|
|
26
|
+
"LinearDamper",
|
|
27
|
+
"LinearSpring",
|
|
28
|
+
"PointMass",
|
|
29
|
+
"PolynomialSpring",
|
|
30
|
+
"SigmoidSpring",
|
|
31
|
+
"TanhDamper",
|
|
32
|
+
"TanhSpring",
|
|
33
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Component base class: something acting on one coordinate, of one of four kinds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
import casadi as ca
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ...core.params import Param, Scope, as_param
|
|
11
|
+
from ..coordinates.base import Context, Coordinate
|
|
12
|
+
|
|
13
|
+
KINDS = ("storage", "dissipation", "inertance", "source")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def scaled(gain: Any, x: Any) -> Any:
|
|
17
|
+
"""Gain times x: a scalar or per-axis gain multiplies entrywise, a matrix multiplies."""
|
|
18
|
+
return gain * x if gain.shape[1] == 1 else ca.mtimes(gain, x)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Component:
|
|
22
|
+
"""Acts on one coordinate. ``kind`` is storage, dissipation, inertance or source.
|
|
23
|
+
|
|
24
|
+
Storage has an energy V(y) and the force f = −∂V/∂y; dissipation has f(y, ẏ) with f·ẏ ≤ 0;
|
|
25
|
+
inertance has an inertia M(y); a source has a metered force f.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
kind: ClassVar[str] = ""
|
|
29
|
+
|
|
30
|
+
def __init__(self, coord: Coordinate) -> None:
|
|
31
|
+
self.coord = coord
|
|
32
|
+
self._params: dict[str, Param] = {}
|
|
33
|
+
|
|
34
|
+
def _param(
|
|
35
|
+
self,
|
|
36
|
+
name: str,
|
|
37
|
+
value: Any,
|
|
38
|
+
*,
|
|
39
|
+
unit: str,
|
|
40
|
+
scope: Scope,
|
|
41
|
+
bounds: tuple[Any, Any] = (0.0, np.inf),
|
|
42
|
+
) -> Param:
|
|
43
|
+
param = as_param(value, name, unit=unit, bounds=bounds, scope=scope)
|
|
44
|
+
self._params[name] = param
|
|
45
|
+
return param
|
|
46
|
+
|
|
47
|
+
def params(self) -> dict[str, Param]:
|
|
48
|
+
"""Params owned by this component, by local name."""
|
|
49
|
+
return dict(self._params)
|
|
50
|
+
|
|
51
|
+
def energy(self, ctx: Context, y: Any) -> Any:
|
|
52
|
+
"""Stored energy V(y) [J] (storage components)."""
|
|
53
|
+
raise NotImplementedError(f"{type(self).__name__} stores no energy")
|
|
54
|
+
|
|
55
|
+
def force(self, ctx: Context, y: Any, yd: Any) -> Any:
|
|
56
|
+
"""Force on the coordinate, (dim, 1). Storage defaults to −∂V/∂y by differentiation."""
|
|
57
|
+
if self.kind != "storage":
|
|
58
|
+
raise NotImplementedError(f"{type(self).__name__} defines no force")
|
|
59
|
+
if isinstance(y, ca.MX):
|
|
60
|
+
ym = ca.MX.sym("y", self.coord.dim)
|
|
61
|
+
return ca.substitute(-ca.gradient(self.energy(ctx, ym), ym), ym, y)
|
|
62
|
+
ys = ca.SX.sym("y", self.coord.dim)
|
|
63
|
+
return ca.substitute(-ca.gradient(self.energy(ctx, ys), ys), ys, ca.SX(y))
|
|
64
|
+
|
|
65
|
+
def inertance(self, ctx: Context, y: Any) -> Any:
|
|
66
|
+
"""Inertia M(y), (dim, dim) (inertance components)."""
|
|
67
|
+
raise NotImplementedError(f"{type(self).__name__} has no inertance")
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
values = ", ".join(f"{k}={p.value.tolist()}" for k, p in self._params.items())
|
|
71
|
+
return f"{type(self).__name__}({self.coord!r}, {values})"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Dissipation components: dampers with f(y, ẏ) and f·ẏ ≤ 0."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import casadi as ca
|
|
8
|
+
|
|
9
|
+
from ...core.registry import register
|
|
10
|
+
from ...core.units import damping_unit
|
|
11
|
+
from ..coordinates.base import Context, Coordinate
|
|
12
|
+
from .base import Component, scaled
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register("component", "linear_damper")
|
|
16
|
+
class LinearDamper(Component):
|
|
17
|
+
"""Linear damper, f = −D ẏ.
|
|
18
|
+
|
|
19
|
+
``damping`` D is a scalar, one value per axis, or a matrix [N·s/m, or N·m·s/rad on angles].
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
kind = "dissipation"
|
|
23
|
+
|
|
24
|
+
def __init__(self, coord: Coordinate, damping: Any) -> None:
|
|
25
|
+
super().__init__(coord)
|
|
26
|
+
self.damping = self._param("damping", damping, unit=damping_unit(coord.unit), scope="stage")
|
|
27
|
+
|
|
28
|
+
def force(self, ctx: Context, y: Any, yd: Any) -> Any:
|
|
29
|
+
"""−D ẏ."""
|
|
30
|
+
return -scaled(ctx.param(self.damping), yd)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@register("component", "tanh_damper")
|
|
34
|
+
class TanhDamper(Component):
|
|
35
|
+
"""Saturating damper, per axis: f = −F tanh(d ẏ / F), within ±F on each axis.
|
|
36
|
+
|
|
37
|
+
``damping`` d is the slope at ẏ = 0, a scalar or one value per axis; ``max_force`` F [N].
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
kind = "dissipation"
|
|
41
|
+
|
|
42
|
+
def __init__(self, coord: Coordinate, damping: Any, max_force: Any) -> None:
|
|
43
|
+
super().__init__(coord)
|
|
44
|
+
self.damping = self._param("damping", damping, unit=damping_unit(coord.unit), scope="stage")
|
|
45
|
+
self.max_force = self._param("max_force", max_force, unit="N", scope="stage")
|
|
46
|
+
|
|
47
|
+
def force(self, ctx: Context, y: Any, yd: Any) -> Any:
|
|
48
|
+
"""−F tanh(d ẏ / F)."""
|
|
49
|
+
d, F = ctx.param(self.damping), ctx.param(self.max_force)
|
|
50
|
+
return -F * ca.tanh(d * yd / F)
|