Use a model outside CasADi¶
Every model of the library is a CasADi function: the kinematics of a robot, its dynamics, a controller’s law. CasADi may not be where the model has to run: a computer that only has numpy, a network that is trained through a model, a colleague’s code. In this tutorial we write models as plain Python, for numpy or for PyTorch, from the same expression graph, so that the numbers are the same ones.
A model as numpy code¶
Kinematics.functions takes a backend. With "numpy" it returns a Python function in place
of the CasADi one: the same arguments, the same outputs, here the position, rotation, Jacobians
and Hessian of the tip of the soft arm:
import numpy as np
import virtualmodelcontrol as vmc
from virtualmodelcontrol.robots import helyx
arm = helyx.add_dynamics(helyx.arm("145-290-290"))
kin = vmc.Kinematics(arm)
tip = kin.functions(1.0, backend="numpy") # the end of the arm, s = 1
reference = kin.functions(1.0) # CasADi's
q = np.random.default_rng(0).normal(size=9) * 0.004
position, rotation, J, Jw, H = tip(q)
print(position, J.shape, H.shape)
[ 0.041788 -0.02415408 0.72299117] (3, 9) (27, 9)
The code agrees with CasADi’s own result to better than 1e-14: it does the
same operations in the same order. The function is generated: tip.source is its text, a
straight line of 7031 statements, one for each operation of the expression graph,
that imports numpy and nothing else.
body = tip.source.split("def kinematics")[1].splitlines()
print("def kinematics" + "\n".join(body[:14]))
def kinematics(x0):
x0 = _flat(x0, 9, 1)
shape = np.broadcast_shapes(x0.shape[:-1])
one, zero = 1.0, 0.0
w0 = 0.03
w1 = 0.145
w2 = x0[..., 2]
w1 = w1 + w2
w1 = w0 * w1
w2 = x0[..., 0]
w3 = w2 * w2
w4 = x0[..., 1]
w5 = w4 * w4
w3 = w3 + w5
The Hessian H comes with the three coordinates stacked, shape (27, 9): H.reshape(3, 9, 9) is
what kin.hessian returns, as in the kinematics tutorial.
Arguments follow CasADi’s shapes: a number, a vector of shape (n,) or a matrix of shape
(n, m). Any leading dimensions are a batch: a thousand configurations at once is one call,
and tip(Q) with Q of shape (1000, 9) returns arrays with 1000 as their first dimension.
Take the code away¶
The text is all a machine needs. We write it to a file and run it in a Python that cannot import CasADi, which we make sure of by blocking the import:
import pathlib
import subprocess
import sys
import tempfile
folder = pathlib.Path(tempfile.mkdtemp())
(folder / "tip.py").write_text(tip.source)
np.save(folder / "q.npy", q)
program = f"""
import sys
sys.modules["casadi"] = None # any import of casadi fails
import numpy as np
sys.path.insert(0, {str(folder)!r})
import tip
print(repr(tip.kinematics(np.load({str(folder / "q.npy")!r}))[0].tolist()))
"""
run = subprocess.run([sys.executable, "-I", "-c", program],
capture_output=True, text=True)
print(np.array(eval(run.stdout)), run.stderr[-200:])
[ 0.041788 -0.02415408 0.72299117]
A controller as numpy code¶
A compiled controller exports the same way, with compiled.export("numpy"). On a robot the
loop that talks to the motors needs fast: the motor angles and rates, the virtual state, the
live Params and the time, in one vector, to the motor torques and the rate of the virtual
state. Here is the three-link arm of the underactuated tutorial, which has
two motors for its three joints, held at a goal by a spring and a damper on its tip. We take one
step of its law in numpy, against that tutorial’s controller for such an arm, ua.controller
with "frozen", the one that needs only the motors’ readings:
from virtualmodelcontrol.control import underactuated as ua
from virtualmodelcontrol.robots import planar
link = planar.add_dynamics(planar.arm("three-link"))
ctrl = vmc.Mechanism("ctrl")
end = link.point("tip")
ctrl.add("reach", vmc.LinearSpring(end - [0.45, 0.15, 0.0], 150.0))
ctrl.add("damp", vmc.LinearDamper(end, 25.0))
ctrl.add("gravity", vmc.GravityCompensation(link))
compiled = vmc.compile(vmc.VirtualMechanismSystem(link, ctrl))
law = compiled.export("numpy").fast # no CasADi from here on
theta, rate, t = np.array([0.3, 0.5]), np.array([0.1, -0.2]), 0.0
# the state, the live Params and the time (no virtual state here)
x = np.concatenate([theta, rate, compiled.live_values(), [t]])
u = law(x)[: compiled.n_u] # [N·m]: the first n_u outputs, one per motor
frozen = ua.controller(compiled, "frozen")
reading = vmc.Signals(t, motor_position=theta, motor_velocity=rate)
frozen.reset(t, reading)
print(u, frozen.step(t, reading)["law_torque"])
[-3.43450378 3.4634888 ] [-3.43450378 3.4634888 ]
The two agree. The numpy function is what such a loop needs, law(x) once a period, with nothing
of the library but its text.
PyTorch¶
With backend="torch" the same text is written for torch tensors. The function is made of
torch operations, so autograd differentiates it, and a batch of states is one call. This needs
PyTorch (pip install virtualmodelcontrol[torch]); the code below runs as it is where PyTorch is
installed, with the soft arm’s dynamics:
import torch
dynamics = vmc.compile_dynamics(arm)
model = dynamics.export("torch") # forward, residual, mass, ...
p = torch.as_tensor(dynamics.live_values())
# a batch of 64 states
q_batch = torch.zeros(64, 9, dtype=torch.float64, requires_grad=True)
v_batch = torch.zeros_like(q_batch)
u_batch = torch.zeros_like(q_batch)
acceleration = model.forward(q_batch, v_batch, u_batch, p, 0.0) # (64, 9)
acceleration.sum().backward() # d(acceleration)/dq, from the same graph
The tests compare these values and derivatives with CasADi’s (the Jacobian and the Hessian of
the kinematics included) to \(10^{-12}\) of the largest entry. The soft arm’s dynamics agree to
no more than a few parts in \(10^{11}\), depending on the build of PyTorch: PyTorch’s sin and cos differ from the C library’s in the
last place, and the arm’s mass matrix has a condition number of a few million, which
multiplies the difference.
Good to know¶
What can be written. Anything made of CasADi’s scalar operations: the models and controllers of the library are. A function that calls code outside CasADi (a callback, an external library) cannot, and an operation without a translation says its name.
Speed. The code is a long straight line of Python. For one input CasADi’s compiled evaluation is much faster (26 times for the arm’s kinematics, measured here), and numpy is faster per input from a batch of 100. Take the code away for portability, not for speed.
Where the Params went. The kinematics are written at the Params’ values when you ask (
Kinematicsfolds them in); the dynamics and the controllers take the live Params as an argumentp, as their CasADi functions do. Ask again after changing a design Param.dtype. numpy computes in float64. The torch code follows the dtype and the device of its arguments, and makes whole numbers floats.
Branches.
if_elsebecomeswhere: both branches are computed. A branch that is not a number where it is not taken gives PyTorch anangradient. Keep the unused branch finite, as for any torch function.