The shape of one joint

low level control on a Unitree R1 EDU: making the elbow joint go up and down, and all the math that shows up on the way

  • #robotics
  • #notes
  • #topology
  • #control

TLDR

Using low level comms with explicit trajectories, we make the elbow joint on a Unitree R1 go up and down. Along the way we take detours through configuration spaces and topology, differential geometry, rigid body motion, PD control, and the DDS plumbing with some Python at the end of each part. There are also some cool interactive diagrams sprinkled throughout.

But before any of that, obligatory video of the robot spinning elbowing the wall.

The wall did not stand a chance.

Thanks to my wife Sabrina I got my hands on the new R1 EDU from Unitree. It came with dextrous hands that I haven’t installed yet, and a compute backpack that I screwed up installing because the ethernet port was covered with tape lol.

This post is mostly about how I use low level control to make the robot do one simple motion. The most simplified version of drumming would be just moving the elbow joint, but in reality this is mostly for me to get acquainted with the robot and how it works, and make some content out of it.

Of course if you read the thing I just wrote and said “um no”, then you’re right. But I had to make sure the elbow motion kind of matches something real. Drumming really would involve both the wrist and the elbow, controlling the rebound, and at higher speeds, or on double and triple strokes, it’s going to be the fingers. I have about a year of pretty basic drumming lessons behind me, which is just enough to know that. In any case all of that is going to take forever, so I wanted to share at least some basic shit.

What did you think I use the motion for…

Along the way we’ll do a very basic robotics intro, except with a real robot. We only look at one joint, the elbow joint I was actually controlling, how it connects to the links, and some basic lingo, so that you don’t have to go look at a reference. You can obviously look all of this up in a robotics book, but you shouldn’t have to for this.


One joint

The elbow moves up and down. That’s called a revolute joint, meaning it rotates about a single fixed axis.

I did not define a joint rigorously, but you can think of it as a connection between two rigid bodies with some positive number of degrees of freedom. I did not define those either. At this point I know more diffgeom than robotics unfortunately lol, so we probably should define things.

DefinitionRigid body, joint, degrees of freedom

A rigid body is a body where the distance between all of its points is preserved regardless of the transformation. A rigid body motion is a direct isometry, so it preserves both distances and orientation. (Don’t ask about orientation please, otherwise I’ll say it’s a choice of generator of the top nonzero cohomology group.)

A joint is a connection between two rigid bodies with some positive number of degrees of freedom, and the number of degrees of freedom is the dimension of the configuration manifold. The rigid bodies themselves are the links, so on the arm the upper arm and the forearm are links and the elbow between them is the joint.

If it was unconstrained, moving my elbow up and down would just move it in a circle. So topologically the space of positions that joint can be in is S1S^1. Note that the R1 has its joint limits published in the developer docs.

DefinitionConfiguration space

The configuration space C\mathcal{C} of a mechanism is the set of all positions it can be in, one point of C\mathcal{C} per pose. For a single unconstrained revolute joint CS1\mathcal{C} \cong S^1: one angle, and θ\theta and θ+2π\theta + 2\pi are the same configuration. That last part is why it’s a circle and not a line.

Of course it can’t be just a set. It needs a topology, otherwise S1×S1S^1 \times S^1 and S2S^2 would be the same.

The joint is obviously connected to the ground, but in this case we treat the rest of the robot as the ground and just look at the one joint hanging off it.

Mechanism (drag the forearm)Configuration space (a point on S1)
Joint angle q0.000 rad
(cos q, sin q)(1.000, 0.000)
Full turns0

Drag either panel. Unconstrained the angle keeps climbing, but the point comes back around, since q and q + 2π are the same configuration, which is what makes the space a circle. Add limits and the reachable set collapses to an arc, so you still have one coordinate but a completely different topology.

Once we constrain it, we see that it no longer wraps around. As you can see from the link above, the joints on this robot are constrained, and the elbow has a certain min and max in radians. So look at how the topology changed just by adding limits, since an interval is contractible and a circle isn’t.

Two joints

Now take two unconstrained revolute joints. Each one is its own circle and they don’t interact, so the configuration space is the product

T2  :=  S1×S1,T^2 \;:=\; S^1 \times S^1 ,

which is a torus. I don’t define a torus, but I assume you have a mental picture of a sheet of paper glued along opposing edges (also see the interactive below).

Now compare that with the sphere S2S^2. Both are 2-dimensional manifolds, so locally they look the same, since every point of either one has a neighborhood homeomorphic to an open subset of R2\mathbb{R}^2. Globally they’re not the same at all, and that difference is topological.

The way I like to see it is with the direction a link points, which lives on S2S^2, so think of a rod pinned at one end and free to point anywhere. If you put it towards the north pole and use the same angle of rotation, it degenerates into not moving at all. Precisely: if I’m at the north pole and I specify latitude and longitude, then changing the longitude doesn’t actually move me at all. That’s a failure of the coordinates, not of the sphere, and it’s the same failure that gets called gimbal lock.

drag to turn
Move per radian of φ0.906
Move per radian of λ1.000
Parallel length5.69

The bright ring is the parallel you travel along when you turn the longitude dial, and its radius is cos λ. Slide λ up and watch the ring shrink: at the pole it is a single point, every value of φ names the same place, and the two dials stop being independent, which is exactly where the chart stops being a chart. Nothing went wrong with the sphere, it's the coordinates that ran out.
NoteGimbal lock

A ball joint’s configuration space is SO(3)SO(3), which is a compact 3-manifold, so it admits no single global chart. Any parametrization by three angles (Euler angles) therefore has to degenerate somewhere: at the singular configuration two of the three axes align and you lose a degree of freedom in the parametrization, even though the joint itself has lost nothing. See gimbal lock.

The way people actually dodge this is to stop using three angles in the first place. A unit quaternion carries a rotation as four numbers with one constraint holding them on a sphere, and since it was never a three-coordinate chart, there is no chart left to degenerate. This doesn’t change the topology of SO(3)SO(3) one bit though, it just stops pretending SO(3)SO(3) is a box of three dials.

RemarkHalf-angle quaternions, and SU(2) double covering SO(3)

A rotation by α\alpha about a unit axis nn is carried by

q  =  cosα2  +  sinα2(nxi+nyj+nzk),q \;=\; \cos\tfrac{\alpha}{2} \;+\; \sin\tfrac{\alpha}{2}\,\bigl(n_x i + n_y j + n_z k\bigr),

and it’s the half that does everything. Replacing α\alpha by α+2π\alpha + 2\pi is the same rotation but sends qq to q-q, so every rotation is named by exactly two unit quaternions.

The unit quaternions form the 3-sphere S3S^3, which as a group is SU(2)SU(2), and sending each one to the rotation it performs is a 2-to-1 group homomorphism

SU(2)SO(3),ker={±1}SO(3)SU(2)/{±1}RP3.\begin{aligned} SU(2) &\longrightarrow SO(3), \qquad \ker = \{\pm 1\} \\[2pt] SO(3) &\cong SU(2)/\{\pm 1\} \cong \mathbb{RP}^3 . \end{aligned}

S3S^3 is simply connected, so this is the universal cover and

π1(SO(3))Z/2Z,\pi_1\bigl(SO(3)\bigr) \cong \mathbb{Z}/2\mathbb{Z},

which is the belt trick: give something a 360°360° turn and it stays tangled, give it 720°720° and it comes free. Two turns is the trivial loop and one turn isn’t. You may have run into this under other names, since everyone seems to have found it independently: the plate trick, Dirac’s string trick, and the Balinese candle dance, where somebody walks a full turn under their own arm holding a candle and comes back untwisted only after the second one. A dance move and a statement about π1\pi_1 turn out to be the same thing, which I think is great.

What you buy is no coordinate singularity anywhere. What you pay is that qq and q-q are the same rotation, so anything that interpolates or averages has to pick a branch and stay on it.

The thing that actually tells them apart is the fundamental group. The sphere is simply connected and the torus isn’t:

π1(S2)1,π1(S1×S1)Z×Z.\pi_1(S^2) \cong 1, \qquad \pi_1(S^1 \times S^1) \cong \mathbb{Z} \times \mathbb{Z}.

So π1\pi_1 of the sphere is the trivial group, meaning every loop contracts to a point, and π1\pi_1 of the torus is the free abelian group on two generators, one loop around each circle. Strictly speaking π1\pi_1 needs a basepoint, but all of these spaces are path connected, so the group you get doesn’t depend on which one you pick.

This is the part I actually care about here. Motion is a topological question, and topology is about shape, not about dimension. The torus and the sphere have exactly the same dimension, and that fact bought us nothing: counting degrees of freedom tells you how many numbers you need to write a pose down, and tells you nothing at all about what happens when you start turning them.

While we’re at it we can also do the wedge of two circles, meaning the two circles glued at a single point,

π1(S1S1)F2,\pi_1(S^1 \vee S^1) \cong F_2,

the free group on two generators, which is nonabelian, so now the order of the loops matters. On the torus abab and baba are the same class, and on the wedge they aren’t.

NoteThe wedge isn't even a manifold

Every point of S1S1S^1 \vee S^1 except the join has a neighborhood that looks like an interval. The join doesn’t: any small enough neighborhood of it is four arcs meeting at a point, and no open subset of R1\mathbb{R}^1 looks like that.

The quickest way to see it is to delete the point. Take an interior point out of an interval and two pieces are left. Take the join out of its neighborhood and four are left, and a homeomorphism can’t change how many pieces you get. So the wedge fails the definition right at the one point we care about.

RemarkGetting from the torus to the sphere

Actually, getting from S1×S1S^1 \times S^1 to S2S^2 requires a smash product, which for pointed spaces is the product with the wedge collapsed to a point, taken with the quotient topology:

S1S1  :=  (S1×S1)/(S1S1)    S2.S^1 \wedge S^1 \;:=\; (S^1 \times S^1) \,/\, (S^1 \vee S^1) \;\cong\; S^2 .

Wikipedia has a cool looking visualization of how the bouquet collapsing into a point on a torus gives us S2S^2.

drag to turn
Loop
π₁(S²)1
Loop
Classtrivial

Build a word out of a and b, then drag the contract slider and see whether the loop is willing to shrink. On the sphere every loop does, which is what a trivial π₁ looks like. On the torus only the words that wind a net zero times each way make it, so ab and ba land in the same class and aba⁻¹b⁻¹ pulls all the way to a point. Try the same commutator on the wedge and it gets stuck, because there the group is free and the order you wrote the letters in is part of the answer.

What it actually means to move the arm

Now let’s talk about what it actually means to move the robot hand, and particularly what it means on the R1. Asking a joint to be somewhere means computing a torque.

Quickly, on torque, since everything below is denominated in it. Getting a mass moving in a straight line takes a force, and rotating something is no different, it just happens angularly: swinging a link takes an effort too, and torque is the rotational version of that force. It’s what you get by pushing at some distance from the axis, so a small push far out and a hard push close in come to the same thing. Newton comes along for the ride as well, and F=maF = ma turns into

τ=Jq¨,\tau = J\,\ddot q,

where the moment of inertia JJ plays the part of the mass and q¨\ddot q is angular acceleration. It’s the equation every interactive on this page is integrating, at J=0.05 kgm2J = 0.05\ \mathrm{kg\,m^2} for something elbow sized.

So the formula for the torque we actually send is your standard PD controller with an added feed forward torque:

τ=Kp(qdesq)  +  Kd(q˙desq˙)  +  τff\tau = K_p\,\bigl(q^{\text{des}} - q\bigr) \;+\; K_d\,\bigl(\dot q^{\text{des}} - \dot q\bigr) \;+\; \tau_{\text{ff}}

Here qq and q˙\dot q are the measured position and velocity of the joint, qdesq^{\text{des}} and q˙des\dot q^{\text{des}} are the ones we’re asking for, and τ\tau is a torque in Nm\mathrm{N\,m}. That makes KpK_p a stiffness in Nm/rad\mathrm{N\,m/rad} and KdK_d a damping in Nms/rad\mathrm{N\,m\,s/rad}, which is worth keeping in mind, because the two gains are not on the same scale and you can’t compare their numbers directly.

So it’s the displacement in position times KpK_p, the displacement in velocity times KdK_d, and then whatever torque you want to add directly. What it’s really telling us is how to periodically adjust the force based on the error. You look at where you are versus where you were asked to be, you look at how fast you’re closing that gap, and you push accordingly. The KpK_p term is the spring that pulls you toward the target, and the KdK_d term is the damper that keeps you from flying past it.

Funny enough you already know a bunch of these. Cruise control is a controller, where the error is the difference between your set speed and your actual speed, and the output is throttle. Autoscaling is a controller too, where the error is the difference between target utilization and actual utilization, and the output is how many instances you add. Both of those are usually PID rather than PD, so they also integrate the error over time to kill the steady state offset.

Damping ratio ζ0.51
Overshoot15%
Peak torque16 N·m

Inertia J = 0.05 kg·m² with light viscous friction, and torque clipped at ±30 N·m, which is the shaded band where the motor has nothing left to give. ζ = 1 is the fastest approach that never overshoots, below it the joint rings, above it the joint crawls. The commanded velocity is zero throughout, so all the damping here is Kd fighting the joint's own motion.

Here are the gains I’m actually running. These are the defaults straight out of the R1 tutorials, and I have not sat down and properly tuned them, so treat them as a starting point rather than anything considered.

# stiffness, the proportional part of the PD controller, in N·m/rad
# think of the spring equation, but angular: the further the joint is from
# where we asked it to be, the harder this pulls it back
Kp = [
    200, 200, 200, 200, 200, 200,     # legs
    200, 200, 200, 200, 200, 200,     # legs
    300, 300,                         # waist
    100, 100, 100, 100, 50,           # arms
    100, 100, 100, 100, 50,           # arms
    50, 10                            # head
]

# damping, the derivative part, in N·m·s/rad
# this one reads velocity rather than position, so it pushes against however
# fast the joint is already moving, which is what keeps the spring above from
# overshooting and ringing
Kd = [
    3, 3, 3, 3, 3, 3,                 # legs
    3, 3, 3, 3, 3, 3,                 # legs
    5, 5,                             # waist
    2, 2, 2, 2, 2,                    # arms
    2, 2, 2, 2, 2,                    # arms
    2, 0.1                            # head
]

One entry per joint, 26 of them. Legs and waist are the stiffest, which makes sense given they’re the ones holding the robot up against gravity, arms are softer, and the head is softer still, down to Kp=10K_p = 10 on the last joint, where you’d rather it gave way than fought you.

Both lists are indexed by joint, and that index is worth having in front of you, along with the map into the wire format and the limits every one of them is clamped to.

ReferenceJoint indices, the IDL map, and the limits
# continuous indices
class R1JointIndex:
    LeftHipPitch = 0
    LeftHipRoll = 1
    LeftHipYaw = 2
    LeftKnee = 3
    LeftAnklePitch = 4
    LeftAnkleB = 4
    LeftAnkleRoll = 5
    LeftAnkleA = 5
    RightHipPitch = 6
    RightHipRoll = 7
    RightHipYaw = 8
    RightKnee = 9
    RightAnklePitch = 10
    RightAnkleB = 10
    RightAnkleRoll = 11
    RightAnkleA = 11
    WaistRoll = 12
    WaistYaw = 13
    LeftShoulderPitch = 14
    LeftShoulderRoll = 15
    LeftShoulderYaw = 16
    LeftElbow = 17
    LeftWristRoll = 18
    RightShoulderPitch = 19
    RightShoulderRoll = 20
    RightShoulderYaw = 21
    RightElbow = 22
    RightWristRoll = 23
    HeadPitch = 24
    HeadYaw = 25
    
# map from the contigous index to the actual IDL index
# of the data structure that will be sent across
# joint index
joint_idx_in_idl = [
    0, 1, 2, 3, 4, 5,
    6, 7, 8, 9, 10, 11,
    12, 13,
    15, 16, 17, 18, 19,
    22, 23, 24, 25, 26,
    29, 30
]
    
# (min, max) joint limits in rad
R1_JOINT_LIMITS = [
    (-2.9322, 2.5482),
    (-1.0472, 1.7453),
    (-2.7402, 2.7402),
    (-0.1745, 2.4260),
    (-0.8727, 0.5760),
    (-0.2618, 0.2618),
    (-2.9322, 2.5482),
    (-1.7453, 1.0472),
    (-2.7402, 2.7402),
    (-0.1745, 2.4260),
    (-0.8727, 0.5760),
    (-0.2618, 0.2618),
    (-0.5236, 0.5236),
    (-2.6180, 2.6180),
    (-3.1416, 2.0944),
    (-0.2269, 2.4784),
    (-1.9199, 1.9199),
    (-0.9757, 2.1850),
    (-1.9199, 1.9199),
    (-3.1416, 2.0944),
    (-2.4784, 0.2269),
    (-1.9199, 1.9199),
    (-0.9757, 2.1850),
    (-1.9199, 1.9199),
    (-0.6283, 0.6283),
    (-2.0071, 2.0071),
]

Two things in there are worth pulling out. Each arm block runs shoulder pitch, shoulder roll, shoulder yaw, elbow, wrist roll, so the joint this whole post is about is LeftElbow = 17, and it runs at Kp=100 Nm/radK_p = 100\ \mathrm{N\,m/rad} and Kd=2 Nms/radK_d = 2\ \mathrm{N\,m\,s/rad}. Its limits are [0.9757, 2.1850][-0.9757,\ 2.1850] rad, so about 56°-56° to 125°125°: a range of 181°181°, a little over half a turn and nowhere near the full circle the unconstrained joint would have had. That interval is the contractible thing from the first section, written down.

The joint_idx_in_idl list is the other one to notice. The tidy contiguous numbering above is not what goes on the wire, so every command has to be written through that map, which is why joint_idx_in_idl[i] shows up on every line of the code below.

Communicating with the robot

Now this is going to be purely engineering, as in how do we actually communicate with the robot itself. It’s DDS, and specifically Cyclone DDS. I haven’t really dug into the spec too much for these purposes, but if you want to learn more you can check it out there.

Simplifying it a lot, DDS is kind of like pub sub. It’s obviously more than just pub sub, but what I like to think about it is that you read the states from the robot and you send a command to the robot. The commands we’re sending are basically the states of all the motors, and the state of every motor is the state that we’re receiving.

Here’s what that actually looks like in Python. There’s a fair amount of cruft in here, but behind all of it there are only two things going on: we subscribe to the robot’s state updates, and we periodically send commands back. Everything else is setup. (These are methods on my controller class, and there’s a C++ SDK too that works the same way.)

def Init(self):
    # first we typically shut down the motion switcher client
    # coming from the remote controller
    self.msc = MotionSwitcherClient()
    self.msc.SetTimeout(5.0)
    self.msc.Init()

    # keep releasing until no mode is holding the robot
    status, result = self.msc.CheckMode()
    while result is None or result["name"]:
        self.msc.ReleaseMode()
        status, result = self.msc.CheckMode()
        time.sleep(1)
    self.msc.ReleaseMode()

    # sending commands to the robot
    # create publisher #
    self.lowcmd_publisher_ = ChannelPublisher("rt/lowcmd", LowCmd_)
    self.lowcmd_publisher_.Init()

    # receiving robot state
    # create subscriber #
    self.lowstate_subscriber = ChannelSubscriber("rt/lowstate", LowState_)
    self.lowstate_subscriber.Init(self.LowStateHandler, 10)

def Start(self):
    # LowCmdWrite gets called every control_dt_ seconds, forever
    self.lowCmdWriteThreadPtr = RecurrentThread(
        interval=self.control_dt_, target=self.LowCmdWrite, name="control"
    )

    # don't start writing until the first state message has told us
    # which mode machine we're talking to
    while not self.update_mode_machine_:
        time.sleep(1)

    self.lowCmdWriteThreadPtr.Start()

# the read side: fires on every state message from the robot
def LowStateHandler(self, msg: LowState_):
    self.low_state = msg

    # seed the shared state before releasing Start(), otherwise the control
    # thread can begin a tick while desired is still empty
    self.shared_state.set_low_state(msg)

    if not self.update_mode_machine_:
        self.mode_machine_ = self.low_state.mode_machine
        self.update_mode_machine_ = True

So Init releases whatever mode the remote controller had the robot in, then opens exactly two channels, rt/lowcmd to write and rt/lowstate to read. LowStateHandler is the read side and it fires whenever the robot has something to say. The write side is LowCmdWrite, which fires on a timer instead, every control_dt_ seconds, and that’s where the trajectory actually turns into a message. We’ll get to it once we have a trajectory worth writing.

The bottom line is that we are continuously getting the state at a certain frequency, like 500 times a second, and sending commands back at the same rate. That read and write loop is the whole interface. This part is actually devoid of any machine learning, it’s just the movement and the trajectories.

It is, though, the basis for reinforcement learning. When I say that, what I mean is: imagine that instead of us trying to hardcode a trajectory, the robot learns a policy that at each point in time reads the state of the robot and tells the actuators the desired position, velocity, acceleration and so on. Obviously the state could be a much bigger thing, like the room or camera input, but in the simplest sense it’s the same read and write loop we already have.


Trajectories

Ideally we want to create a trajectory for our elbow, and I obviously chose an oscillating one. It’s got to be smooth, and most importantly it’s got to be continuous.

What is a trajectory, though? It’s basically a curve on a manifold for position, and optionally velocity and the rest.

Here’s what happens at a discontinuity. Say we reset the robot into the zero state, so q=0q = 0, and then we instantly tell the robot that we want it at 10-10 radians. Because we’re asking this every Δt=1/500\Delta t = 1/500 seconds, the average velocity you’d need over that one tick is

ΔqΔt  =  10 rad0.002 s  =  5000 rad/s,\frac{\Delta q}{\Delta t} \;=\; \frac{-10\ \mathrm{rad}}{0.002\ \mathrm{s}} \;=\; -5000\ \mathrm{rad/s},

and the PD law happily asks for a torque proportional to a 10 radian error. Never mind that 10 radians is well past the elbow’s limit anyway, the point is what the arithmetic does. Something is definitely going to break here. If I asked you to move that far in two milliseconds it would be quite challenging for you too.

On a real robot this manifests as a wound up jerk. The controller sees a huge error, commands whatever torque it’s allowed to, the joint snaps toward the target, and then the damping term has to catch it on the other side.

Which is exactly the video at the top of this post. That’s not a robot that lost its balance, it’s a robot asked to be somewhere it couldn’t get to in the time it was given, doing its best, and taking the wall and then the floor with it. Same arithmetic, just with more of the arm involved.

Speed it would need5000 rad/s
Torque demanded600 N·m
Over the limit20×

A 10 radian jump asked for in one 2 ms control tick. The dashed line is the command, the solid line is where the joint actually gets to, and the lower plot is torque magnitude on a log axis, since the demand starts more than an order of magnitude above the limit and a linear axis would flatten everything else out. Commanded velocity is zero throughout, exactly like the real code, so that spike is Kp alone reacting to a ten radian error. Drag the blend time and watch the same motion, spread over a few tenths of a second, stop asking for the impossible.

So this means we need to give the robot a position that’s continuous and also smooth. And what do we mean by smooth here? Each derivative we ask to exist and be continuous buys us one more thing that can’t jump:

qC0:q never jumpsqC1:q˙ exists and is continuousqC2:q¨ exists and is continuous\begin{aligned} q \in C^0 &: \quad q \text{ never jumps} \\[2pt] q \in C^1 &: \quad \dot q \text{ exists and is continuous} \\[2pt] q \in C^2 &: \quad \ddot q \text{ exists and is continuous} \end{aligned}

Worth being careful here, because continuity on its own buys you nothing about the velocity. A continuous function doesn’t have to be differentiable anywhere at all. What it does buy is that the commanded position never jumps, which is the thing that blew up the torque above. If you want a velocity that doesn’t jump that’s C1C^1, and an acceleration that doesn’t jump is C2C^2. Jerk is the third derivative q...\dddot q, so actually bounding it wants C3C^3, or at least a Lipschitz q¨\ddot q. In practice C2C^2 is already plenty.

Also the word smooth is doing two jobs here. For a trajectory I just mean enough derivatives for whatever we’re doing. For the manifold stuff below it means CC^\infty.

So now you see what I’m alluding to. We have talked about how the configuration space and the positions are manifolds, because of the constraints imposed on the coordinate system, and how they can move locally, how many dimensions they can move in locally, as well as some global constraints like the gimbal lock we saw. Now we also want to introduce smoothness, and the reason for that is that we want to be able to take velocities, accelerations and so on, in other words infinitely many derivatives. In the real world it’s probably going to be finitely many, because at some point your higher derivatives matter less and less, but that’s kind of the gist.


Manifolds, smooth structure, Lie groups

DefinitionTopological manifold

A second countable, Hausdorff topological space where each point has a neighborhood homeomorphic to an open subset of Rn\mathbb{R}^n. That nn is called the dimension of the manifold, and that it’s actually well defined, so that the same space can’t be both nn-dimensional and mm-dimensional, is invariance of domain.

While the topological manifold definition seems out of the blue at first, the reason for having second countability is certain local to global constraints and partitions of unity, to be able to glue things appropriately across the manifold. For the actual treatment see Lee in the references, any differential geometry book covers it.

DefinitionSmooth structure

A maximal atlas of charts whose transition maps are CC^\infty. Maximal matters, otherwise two atlases that are perfectly compatible would count as different structures. See Lee in the references, since it’s not really about the definition here.

Technically charts are just one way of defining smoothness, and there are others. For example, we can do away with what happens at a particular point and work with germs of functions and their direct limits, so presheaves and zooming into the point infinitely. The other way I know about is differential spaces, leading into stratifolds. And then there are diffeological spaces. Same thing, none of it covered here, see the references.

Sharp corner (heading jumps)Rounded (heading turns)
Curvature at the turn∞ vs 4.0
Turn takes0 vs 0.71 s
SmoothnessC⁰ vs C¹

Both curves are traced at the same constant speed, and the lower plots are the heading angle along the way. On the left it steps by 90° at a single point, so the acceleration there is a spike with no finite value to report. On the right the same 90° gets spread over an arc, and the shorter you make that arc the taller the curvature gets: the total turn is always π/2, so squeezing r just concentrates it. Sending r to zero is where the delta comes back.

Now look at how we start with a manifold and progressively add more and more abstraction, based on some useful things. When we talk about rotating rigid bodies and translating them, we start thinking about composition and invertibility, and obviously those things remind us of a group structure, one that is jointly continuous and has to respect the operations on the manifold and the smooth structure. What that means in practice is that if I do a rotation and then another rotation, the composition is also an element of that space, and it’s also a rotation.

DefinitionLie group

A group that is also a smooth manifold, with multiplication and inversion being smooth maps.

Notice how we naturally started with a manifold, then added smoothness, then added a group structure. Each step keeps the objects and asks more of the maps between them: continuous, then CC^\infty, then respecting multiplication too. I want to draw the category diagram for that at some point, it isn’t here yet.

We don’t need any of this for one elbow, but since we’ve built the ladder we may as well look at the thing sitting on top of it.

RemarkA diversion: the special Euclidean group

Euclidean because its elements are the isometries of R3\mathbb{R}^3, so they preserve distances, and special because we keep only the direct ones, the ones that also preserve orientation, which is exactly the detR=+1\det R = +1 condition that cuts SO(3)SO(3) out of O(3)O(3). Each element is a rotation plus a translation, written as one matrix on homogeneous coordinates:

SE(3)    R3SO(3)    {(Rt01)  :  RSO(3), tR3}\begin{aligned} SE(3) \;&\cong\; \mathbb{R}^3 \rtimes SO(3) \\[4pt] \;&\cong\; \left\{ \begin{pmatrix} R & t \\ 0 & 1 \end{pmatrix} \;:\; R \in SO(3),\ t \in \mathbb{R}^3 \right\} \end{aligned}

It has to be a semidirect product and not just a product, because rotating and then translating isn’t the same as translating and then rotating. It’s a Lie group, and as a manifold it’s six dimensional, since dimSO(3)=3\dim SO(3) = 3 and the translations give three more:

dimSE(3)=3+3=6,\dim SE(3) = 3 + 3 = 6,

which coincidentally, or on purpose, coincides with the number of degrees of freedom of a rigid body in 3D space, since you have three translations and three rotations. It all kind of connects to each other, which is kind of cool.


Back to programming the robot

So I want a very simple trajectory that oscillates up and down, and because I hear the word oscillates, that typically wants some kind of periodic function. In this case I just choose a sine:

q(t)=Asin(ωt)q(t) = A \sin(\omega t)

Why do I choose a sine? Because I position the robot at zero to begin with, say over the first three seconds I reset all the positions to zero, and I don’t want to have a discontinuity at zero. Since sin0=0\sin 0 = 0, the trajectory starts exactly where the robot already is. So I just make it a regular good old sine.

RemarkThe position matches, the velocity doesn't

Btw: this fixes the position but not the velocity. The robot is sitting still after the reset, so q˙=0\dot q = 0, but

q˙(0)=Aωcos(0)=Aω0,\dot q(0) = A\,\omega \cos(0) = A\,\omega \neq 0,

which means at t=0t = 0 the commanded velocity jumps from zero to AωA\omega. It’s exactly the discontinuity from the previous section, moved up one derivative. It’s much gentler than a 10 radian jump so you get away with it, but if you want to be clean about it you ramp the amplitude in over its own window TrT_r,

A(t)=A1cos(πt/Tr)2for t<Tr,A(t) = A \cdot \frac{1 - \cos(\pi t / T_r)}{2} \quad \text{for } t < T_r,

which is 00 at t=0t = 0 and AA at t=Trt = T_r with zero slope at both ends. The ramp needs that separate TrT_r, since reusing ω\omega would just make the amplitude itself oscillate and pinch the motion back to nothing every period. Or skip the ramp and build the trajectory out of 1cos(ωt)1 - \cos(\omega t) in the first place, which starts at zero position and zero velocity on its own, at the cost of only swinging one way from the reset pose.

Funny enough this goes back to parametric curves, because at the end of the day we are describing a curve by simply varying tt. And not just a curve in 3D space, but a curve on that configuration space manifold:

γ:[0,T]C,γ(t)=[Asin(ωt)].\gamma : [0, T] \to \mathcal{C}, \qquad \gamma(t) = \bigl[\, A\sin(\omega t) \,\bigr].

We put big brackets around the sine to remind that it is in fact an equivalence class. Asin(ωt)A\sin(\omega t) is a number, and C\mathcal{C} is a circle, so to land in C\mathcal{C} we read that number as an angle, identifying S1=R/2πZS^1 = \mathbb{R} / 2\pi\mathbb{Z}. For our elbow it hardly matters, since the joint limits keep the whole trajectory inside one chart and we can just work with the angle, but the sine by itself isn’t a point on the manifold until you say how to read it.

So a regular sine over tt does actually describe some kind of curve over its configuration space.

Period T = 2π/ω2.09 s
q(t)0.000 rad
q̇(t)1.80 rad/s
A point sweeping along q(t) = A sin(ωt), and the same value as an elbow angle. Starting at q(0) = 0 means it can pick up from a zeroed robot with no jump, and the curve is smooth everywhere, so there is no instant where the arm is asked to teleport.

Position only

You can see that the basic version going up and down does not actually specify any velocity. Leaving q˙des=0\dot q^{\text{des}} = 0 doesn’t mean the velocity term does nothing though, it means the term becomes Kdq˙-K_d\,\dot q, so it’s pure damping that fights whatever motion you have. You’re telling the joint to be at a moving target and to be stationary, at the same time, and what you get is the compromise between the two.

The lag that comes out of it is worst where the joint is moving fastest, which for a sine is the middle of the stroke, and it’s smallest at the peaks, where q˙\dot q is heading to zero anyway and the two requests stop disagreeing. So the motion doesn’t fall apart, it just runs behind and comes out shallower than you asked for. In the velocity interactive further down that’s about a quarter radian of error mid stroke against a few hundredths at the turnaround, and you can toggle it on and off there to watch the gap open and close.

While we describe the desired trajectory as a sine, the controller never receives a continuous function. At 500Hz we just sample the sine and send a new position target every 2ms. The robot receives a discrete sequence of setpoints, and the resulting samples closely approximate the continuous trajectory we had in mind.

RemarkThis is just universal approximation

Funny enough you can stick a universal approximation here, since we’re uniformly approximating with continuous splines, basically piecewise linear functions. If SΔtS_{\Delta t} is the piecewise linear interpolant of qq through the samples, and qq is C2C^2, then

qSΔt    Δt28q¨,\bigl\| q - S_{\Delta t} \bigr\|_\infty \;\le\; \frac{\Delta t^2}{8} \bigl\| \ddot q \bigr\|_\infty ,

with both sup norms over the interval. For a sine q¨=Aω2sin(ωt)\ddot q = -A\omega^2\sin(\omega t), so q¨=Aω2\|\ddot q\|_\infty = A\omega^2. Say A=0.5A = 0.5 rad at 1 Hz, so ω=2π\omega = 2\pi, and Δt=1/500\Delta t = 1/500 s:

qSΔt    (0.002)28(0.5)(2π)2    1×105 rad,\bigl\| q - S_{\Delta t} \bigr\|_\infty \;\le\; \frac{(0.002)^2}{8}\,(0.5)(2\pi)^2 \;\approx\; 1 \times 10^{-5}\ \mathrm{rad},

which is nothing, so sampling is not what’s limiting this arm.

To be careful, this isn’t the neural network universal approximation theorem, it’s the same underlying fact both of them lean on, that a class of simple functions is dense in C(K)C(K) under the sup norm. I don’t know, I just feel like it’s worth mentioning because it’s cool.

Here’s the whole thing to play with. Drag Δt up until the corners are obvious, then hit the button to go back to what the robot actually runs at.

Max error6.1e−3 rad
Bound Δt²/8 · ‖q̈‖6.2e−3 rad
Fraction of amplitude1.2%

q(t) = A sin(ωt) with A = 0.5 rad at 1 Hz, sampled every Δt. The faint curve is the sine, the dots are the samples, and the solid line through them is what the joint is really given. The strip underneath is the error against ±the bound, and notice the error is worst at the peaks, where the sine bends hardest, and nearly nothing at the zero crossings, where it was almost straight to begin with. Drag Δt down and the whole strip collapses like Δt², so every halving buys a factor of four.

And here’s the write side of the Python, which is where every idea above finally turns into something the motors receive.

# send the message to the robot
def LowCmdWrite(self):
    # tick the time
    self.time_ += self.control_dt_

    if self.time_ < self.duration_:
        # stage 1
        # set robot to zero posture
        # for unitree R1 it's actually holding the elbows at 90 deg
        # like so
        #
        #   shoulder ─── o
        #                │
        #                │        upper arm, straight down
        #                │
        #      elbow ─── o ──────  forearm, out at 90 deg
        #
        # forgive my terrible ascii art skills
        for i in range(R1_NUM_MOTOR):
            ratio = np.clip(self.time_ / self.duration_, 0.0, 1.0)
            self.low_cmd.mode_pr = Mode.PR
            self.low_cmd.mode_machine = self.mode_machine_
            self.low_cmd.motor_cmd[
                joint_idx_in_idl[i]
            ].mode = 1  # 1:Enable, 0:Disable
            self.low_cmd.motor_cmd[joint_idx_in_idl[i]].tau = 0.0
            self.low_cmd.motor_cmd[joint_idx_in_idl[i]].q = (
                1.0 - ratio
            ) * self.low_state.motor_state[joint_idx_in_idl[i]].q
            self.low_cmd.motor_cmd[joint_idx_in_idl[i]].dq = 0.0
            self.low_cmd.motor_cmd[joint_idx_in_idl[i]].kp = Kp[i]
            self.low_cmd.motor_cmd[joint_idx_in_idl[i]].kd = Kd[i]

    else:
        # stage 2
        # move the left elbow up and down
        i = R1JointIndex.LeftElbow
        idl_i = joint_idx_in_idl[i]
        max_angle = 20 * np.pi / 180
        t = self.time_ - self.duration_

        self.low_cmd.mode_pr = Mode.PR
        self.low_cmd.mode_machine = self.mode_machine_

        self.low_cmd.motor_cmd[idl_i].mode = 1
        self.low_cmd.motor_cmd[idl_i].tau = 0.0
        self.low_cmd.motor_cmd[idl_i].q = max_angle * np.sin(t)
        self.low_cmd.motor_cmd[idl_i].dq = 0.0
        self.low_cmd.motor_cmd[idl_i].kp = Kp[i]
        self.low_cmd.motor_cmd[idl_i].kd = Kd[i]

    self.low_cmd.crc = self.crc.Crc(self.low_cmd)
    self.lowcmd_publisher_.Write(self.low_cmd)

A few things to notice, because they’re all the abstract stuff from earlier showing up as actual lines.

self.time_ += self.control_dt_ is the only clock. There’s no wall time anywhere, we just count ticks, which is why tk=kΔtt_k = k\,\Delta t exactly.

Stage 1 is the reset to 0.0 position. On each tick we take the latest measured joint position from LowState and scale it to 0 over duration_, so by the time stage 2 starts we have q=0q = 0 and sin0=0\sin 0 = 0 agreeing with each other.

Stage 2 is the whole post in six lines. One joint, R1JointIndex.LeftElbow, gets q(t)=Asin(t)q(t) = A\sin(t) with A=20°A = 20°, which is 0.3490.349 rad, comfortably inside that [0.9757, 2.1850][-0.9757,\ 2.1850] limit. There’s no ω\omega in the expression, so ω=1\omega = 1 rad/s and the period is 2π6.32\pi \approx 6.3 seconds, which is why the arm in the video is moving as unhurriedly as it is.

And then the honest part: dq = 0.0 and tau = 0.0, on every motor, in both stages. So this is exactly the position-only case, and the Kdq˙-K_d\,\dot q pure damping described above isn’t a hypothetical, it’s what this code does. Kp[i] and Kd[i] are the per-joint tables from earlier, which is why a shoulder carrying the whole arm gets 100 and the wrist hanging off the end of it gets 50.

Here it is running on the arm.

Adding velocity

So now we also want to add velocity, basically so that at the very top we smoothly bring the velocity to zero. Obviously the easiest way to do that is to just do a cosine, the derivative of the sine:

q˙(t)=Aωcos(ωt).\dot q(t) = A\,\omega \cos(\omega t).

At those peaks, where sin(ωt)=±1\sin(\omega t) = \pm 1, we get cos(ωt)=0\cos(\omega t) = 0, which is exactly where we want the derivative to be zero. And now the damping term has something to subtract against, so instead of Kdq˙-K_d\,\dot q fighting the motion all the way through, it only pushes back on the difference between the speed we asked for and the speed we got.

In the code that is a one line change, since we were already leaving the velocity at zero:

self.low_cmd.motor_cmd[idl_i].q  = max_angle * np.sin(t)
self.low_cmd.motor_cmd[idl_i].dq = max_angle * np.cos(t)   # was 0.0

Sine becomes cosine and that’s it. Worth knowing that this only works out so cleanly because ω=1\omega = 1 here: in general the derivative of Asin(ωt)A\sin(\omega t) is Aωcos(ωt)A\omega\cos(\omega t), so you’d carry the ω\omega through, and forgetting it is a good way to ask for a velocity that disagrees with your own position command.

Period T = 2π/ω2.09 s
q(t)0.000 rad
q̇(t)1.80 rad/s
Same sine, now run through the PD law at Kp = 4, Kd = 0.6. With position only the commanded velocity is zero everywhere, so Kd fights every move and the arm trails the dashed target. Commanding q̇ = Aω cos(ωt) alongside it puts the arm back on the curve, and the velocity crossing zero at the peaks is what makes the turnaround ease off instead of snapping.

So that’s basically it. All I have to do is program this and not make any algebraic mistakes. What I like about specifying it this way is that I can plot these functions beforehand and make sure there are no discontinuities or anything. Obviously once you run it on a robot, if you mess it up it’ll jerk and stuff.


Visual poser

After doing that, my next quick thing was a visual poser. The Unitree app has something like it buried in there, but it’s nice to have my own thing I can launch from a terminal instead of installing the app and dragging it around, and I can extend it however I want.

The UI itself was pretty quick and dirty, just DearPyGui with a slider per joint, and then I had Claude Code redo the theme into something presentable.

The way I implemented it is pretty rudimentary. Whenever a person changes a slider value we obviously debounce it and request a trajectory change. Once we specify that trajectory change, you kind of lock in that joint to a trajectory and you don’t mess with it, which is the self.shared_state.trajectories dict in the code below: a slider writes an entry, the control thread samples it every tick, and it drops out when it reports done. After that the joint just sits there with whatever torque the KpK_p term needs to hold the position.

The write side grows a little to support it, since now any joint might have a trajectory attached and most of them don’t:

# the write side: fires every control_dt_ seconds, forever
def LowCmdWrite(self):
    # tick the time
    self.time_ += self.control_dt_

    # nothing to say until the first state message has arrived
    if not self.shared_state.seeded:
        return

    self.low_cmd.mode_pr = Mode.PR
    self.low_cmd.mode_machine = self.mode_machine_

    if self.time_ >= self.init_duration_:
        self.update_trajectories()

    if self.time_ < self.init_duration_:
        # the reset: scale every joint's measured position down to zero over
        # init_duration_ seconds, so we start from a known pose instead of
        # from wherever the arm happened to be left
        for idx in range(R1_NUM_MOTOR):
            idl_idx = joint_idx_in_idl[idx]
            t = np.clip(self.time_ / self.init_duration_, 0.0, 1.0)
            cmd = self.low_cmd.motor_cmd[idl_idx]
            cmd.mode = 1
            cmd.kp = Kp[idx]
            cmd.kd = Kd[idx]
            cmd.tau = 0.0
            cmd.dq = 0.0
            cmd.q = (1.0 - t) * self.low_state.motor_state[idl_idx].q
    else:
        # the actual run: every joint that has a trajectory gets sampled at
        # this instant, and joints without one are simply not commanded
        for idx in range(R1_NUM_MOTOR):
            if idx not in self.shared_state.trajectories:
                continue
            idl_idx = joint_idx_in_idl[idx]
            traj_fn = self.shared_state.trajectories[idx]
            cmd = self.low_cmd.motor_cmd[idl_idx]
            cmd.mode = 1
            cmd.kp = Kp[idx]
            cmd.kd = Kd[idx]
            cmd.tau = 0.0
            cmd.dq = 0.0
            cmd.q, done = traj_fn()
            # a finished trajectory drops out and the joint just holds
            if done:
                del self.shared_state.trajectories[idx]

    self.record_diagnostics()

    self.low_cmd.crc = self.crc.Crc(self.low_cmd)
    self.lowcmd_publisher_.Write(self.low_cmd)

Which is kind of nice, so you get to play around with different hand positions and see where the limitations are. Obviously there are specs as well, so try not to move it around too much. But it was kind of cool.

Next steps

Definitely buying a mocap. I’m just kidding. Am I?

I think the next step is going to be doing some interesting reinforcement learning stuff, so you can actually train it and make it do things.

References

Leaving some references here just in case you want to learn more.