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,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)