MOONMCL motion control

Guide

From an install to a closed loop

Twelve sections, each one runnable on its own and each showing the output it prints. Read them in order for the whole library, or jump to the part the machine needs.

Install

The library is one module with five packages. Add it to a project with moon add, which writes the dependency into moon.mod.

moon add moonbitstack/moonmcl

Import the root package under an alias to reach the whole surface. The examples use mctl; any name works. If you want only one package, import it directly — "moonbitstack/moonmcl/control", for instance — and the other four are not compiled.

import {
  "moonbitstack/moonmcl" @mctl,
}

Every quantity is a Double. Angles are radians, lengths are metres, and time is seconds. The controllers hold their state inside the value you own, so nothing is global and two controllers never interfere.

A first loop

A PID controller takes an error and a timestep and returns the output. Here it drives a first-order plant from rest towards a setpoint of one, with the output clamped to ±10.

let pid = @mctl.Pid::new(config=@mctl.pid_gains(kp=2.0, ki=0.5, kd=0.1))
let mut y = 0.0
for _ in 0..<100 {
  let u = pid.command(reference=1.0, measurement=y, dt=0.02)
  y = y + 0.02 * (u - y)
}
println(y.to_string())
0.7810997531060805

The controller is stateful: call update once per tick and it accumulates the integral and remembers the previous error. A plant that reaches only 0.78 after two seconds has not settled — raise kp, or leave the loop running longer.

Tuning the loop

Three refinements matter in practice, and all three are built in.

Anti-windup. While the output sits at its limit and the error would push it further out, the integrator is held. A long call at a target the plant cannot reach therefore leaves nothing behind to overshoot with once the target comes back into range.

Derivative on the measurement. update differentiates the error, which is what the textbook formula says. A step in the setpoint then produces an infinite derivative and a spike in the output. update_signals(setpoint, measurement, dt) differentiates the measurement instead, so the same step produces nothing:

let config = @mctl.pid_gains(kp=0.0, ki=1.0)
let pid = @mctl.Pid::new(config={ ..config, output: { low: -0.5, high: 0.5 } })
for _ in 0..<100 {
  ignore(pid.command(reference=1.0, measurement=0.0, dt=0.1))
}
// The output saturates at half a unit and the integrator comes to rest there.
println(pid.integral().to_string())
0.6

Feedforward. A term added to every output, for the part of the command that is known in advance. The compensator takes a gain on the reference and a gain on its rate.

let profile = @mctl.trapezoid(distance=2.0, v_max=1.0, a_max=1.0)
let sample = profile.sample(profile.duration() / 2.0)
let feed = @mctl.Feedforward::new(velocity=0.8, acceleration=0.2)
println(feed.command(sample).to_string())
0.8

Trajectories

A jerk-limited profile is the shortest rest-to-rest move that respects a peak velocity, a peak acceleration and a peak jerk. scurve returns the profile; at gives the four kinematic quantities at any instant.

let profile = @mctl.scurve(distance=100.0, v_max=5.0, a_max=2.0, j_max=1.0)
let sample = profile.sample(10.0)
println(
  profile.duration().to_string() +
  " s, then at t = 10 s: " + sample.position.to_string() +
  " m at " + sample.velocity.to_string() + " m/s",
)
24.5 s, then at t = 10 s: 38.75 m at 5 m/s

A hundred metres under those limits takes 24.5 seconds. At ten seconds the move has reached its cruise: five metres per second, no acceleration. Sampling at over the whole duration is how the showcase page draws the curve.

A move can also begin where the previous one left off, with the acceleration already non-zero. Both boundary accelerations are folded into the first and last ramp, so the profile carries the acceleration it was given instead of spending time taking it to zero and building it up again.

let profile = @mctl.scurve(
  distance=3.0,
  v_max=2.0,
  a_max=1.5,
  j_max=6.0,
  start_velocity=0.8,
  start_acceleration=0.4,
  end_velocity=1.2,
  end_acceleration=0.6,
)
println(((profile.duration() * 1000.0).round() / 1000.0).to_string())
1.989

Under those limits the move takes 1.989 seconds, starting at 0.4 and ending at 0.6 m/s² of acceleration. It is the shortest such move: the peak the profile ramps to is the one the distance can pay for.

The profile covers motion along one direction, from any boundary state inside the limits. A move that would have to run backwards in the middle to make its distance — three metres per second down to one over half a metre, say — is refused with Unreachable rather than returned with an overshoot: coming down to one takes four metres of braking, and the half metre is not there. Give it room and the same boundary states produce an ordinary profile.

Linear algebra

Vectors and matrices are flat arrays of Double behind a small wrapper. The one operation worth calling out is solve, which factorises with partial pivoting rather than inverting:

let stiff = @mctl.Mat::of([[2.0, 1.0], [1.0, 3.0]])
let load = @mctl.Mat::of([[3.0], [5.0]])
println(@mctl.solve(stiff, load).to_string())
[ 1.4 ]

A singular system aborts rather than returning a plausible-looking answer. inv, qr, kron and the products are there for the cases that need them; everything else in the library is built on mat_vec, mat_mat and solve.

Optimal feedback

For x[k+1] = A x[k] + B u[k], with a state weight Q and a control weight R, the discrete Riccati equation gives the gain that minimises the infinite-horizon cost. The library iterates it to a tolerance and returns K.

let a = @mctl.Mat::of([[1.0, 0.1], [0.0, 1.0]])
let b = @mctl.Mat::of([[0.005], [0.1]])
let q = @mctl.Mat::identity(2)
let r = @mctl.Mat::of([[1.0]])
println(@mctl.lqr(a=a, b=b, q=q, r=r).gain().to_string())
[ 0.9170745623791932, 1.6355961841847042 ]

The control is u = -K x. Because the gain depends only on the model and the weights, it is computed once and used every tick. When following a non-zero reference instead of regulating to zero, the integral form adds one state and removes the standing error: integral_lqr(a, b, c, q, q_int, r, reference, dt).

Receding horizon

The same plant under a finite-horizon plan. Each call solves a quadratic program over the horizon and returns the first control; the caller applies it, measures again, and calls step afresh.

let a = @mctl.Mat::of([[1.0, 0.1], [0.0, 1.0]])
let b = @mctl.Mat::of([[0.005], [0.1]])
let model = @mctl.StateSpace::discrete(
  a=a, b=b, c=@mctl.Mat::identity(2), d=@mctl.Mat::zeros(2, 1), dt=0.1,
)
let mpc = @mctl.Mpc::new(
  model=model,
  state_weight=@mctl.Mat::identity(2),
  input_weight=@mctl.Mat::of([[1.0]]),
)
println(mpc.control(state=@mctl.Vec::of([1.0, 0.0]), target=@mctl.Vec::zeros(2)).to_string())
[ -0.7712914073271935 ]

The horizon is the number of steps planned ahead; input_limit clamps every element of the plan, which is what makes the result differ from the LQR gain. The step is not cheap — it builds and solves a dense system — so it suits a slower outer loop, with a PID or an LQR closing the fast one.

State estimation

A Kalman filter alternates prediction with the model and correction with a measurement. Twenty corrections against an unchanging measurement of two leave the estimate almost exactly there:

let model = @mctl.StateSpace::discrete(
  a=@mctl.Mat::of([[1.0]]), b=@mctl.Mat::of([[0.0]]),
  c=@mctl.Mat::of([[1.0]]), d=@mctl.Mat::zeros(1, 1), dt=1.0,
)
let kf = @mctl.Kalman::new(
  model=model,
  process=@mctl.Mat::of([[0.01]]),
  measurement=@mctl.Mat::of([[0.1]]),
  state=@mctl.Vec::of([0.0]),
  covariance=@mctl.Mat::of([[1.0]]),
)
for _ in 0..<20 {
  kf.predict(@mctl.Vec::of([0.0]), 1.0)
  kf.correct(@mctl.Vec::of([2.0]))
}
println(kf.state().to_string())
[ 1.9997728899653444 ]

The extended filter takes the two models as closures and differentiates them by central differences, so a nonlinear plant costs no Jacobian algebra. Here a constant-velocity target is tracked from a position measurement:

let plant = @mctl.Dynamics::new(
  f=fn (x, u, dt) {
    ignore(u)
    @mctl.Vec::of([x.get(0) + dt])
  },
  h=fn (x) { @mctl.Vec::of([x.get(0)]) },
)
let ekf = @mctl.ExtendedKalman::new(
  model=plant,
  process=@mctl.Mat::of([[0.01]]),
  measurement=@mctl.Mat::of([[0.1]]),
  state=@mctl.Vec::of([0.0]),
  covariance=@mctl.Mat::of([[1.0]]),
)
for _ in 0..<20 {
  ekf.predict(@mctl.Vec::of([0.0]), 1.0)
  ekf.correct(@mctl.Vec::of([2.0]))
}
println(ekf.state().to_string())
[ 3.080713213333123 ]

Four seconds in, the filter has recovered the position and a velocity of one metre per second from position measurements alone.

A run of estimates can be refined once the measurements are in. rts takes the filtered means, the filtered covariances and the transitions between them, and walks backwards: every estimate is pulled towards what the step after it implies, so an early pose is corrected by the measurements that came later. The last entry comes back untouched, which is where the backward pass starts, and no covariance grows. The backward pass on its own is replayed against a mature filter library on that library's own record, so a disagreement there is the smoother's and not the filter's.

A plant given in physical units is usually continuous. discretize turns it into the record the filter and the predictive controller want, by zero-order hold — the input held over the period, which is what a digital controller actually does — or by the trapezoidal rule or forward Euler when that is what a plant model was written for. The hold is the matrix exponential of the augmented plant, so it stays exact at any period it is asked for.

Planning

A grid is free or blocked per cell, four-connected, with unit edge cost. index(col, row) turns a coordinate into the flat index the search uses.

let grid = @mctl.Grid::new(10, 10)
for column in 4..<6 {
  grid.block(column, 4)
  grid.block(column, 5)
}
let path = @mctl.grid_path(
  grid,
  start=grid.node(0, 0),
  goal=grid.node(9, 9),
  search=@mctl.GridSearch::AStar,
)
println(path.length().to_string())
12

Nineteen cells is the Manhattan distance plus one: the detour around the two-by-two block costs nothing extra. grid_path returns None when the goal is walled off, so unwrap is a deliberate choice to abort instead of steering nowhere.

A car's own geometry

A car that can only go forward has six words between two poses: three pieces, each a left arc, a right arc or a straight. A car that can also reverse has forty-eight, because every word can be mirrored, driven backwards, or both. Both models are state spaces, so the distance between two poses is the length of the shortest path and the interpolation walks it.

let car = @mctl.ReedsShepp::new(turning_radius=1.0)
let start = @mctl.Vec::of([0.0, 0.0, 0.0])
let goal = @mctl.Vec::of([-2.0, 0.8, 0.5])
let path = car.path(start~, goal~)
println(
  ((path.length * 1000.0).round() / 1000.0).to_string() +
  " m, pieces: " + path.segments.length().to_string() +
  ", cusps: " + path.cusps().to_string(),
)
2.429 m, pieces: 3, cusps: 1

The goal is behind the start, so the car backs up once: three pieces and one cusp. The same goal costs a forward-only car 7.56 metres, because every metre of it has to be driven facing the way it is going. Since a car space is a Space, a tree grown in it is made of arcs and reversals rather than straight lines.

Drive kinematics

A differential drive has one track width and two wheels. The inverse map turns a body twist into wheel speeds; the forward map turns wheel speeds back into a twist; Differential::advance integrates the twist over a timestep.

let base = @mctl.Differential::new(wheel_radius=0.1, track=0.5)
let wheels = base.inverse(@mctl.Vec::of([1.0, 0.5]))
let pose = base.advance(
  pose=@mctl.Vec::of([0.0, 0.0, 0.0]),
  twist=@mctl.Vec::of([1.0, 0.0]),
  dt=0.1,
)
println(
  wheels.to_string() + " rad/s, then the pose after 0.1 s: " +
  "[ " + pose.get(0).to_string() + ", " + pose.get(1).to_string() + " ]",
)
[ 8.75, 11.25 ] rad/s, then the pose after 0.1 s: [ 0.1, 0 ]

At one metre per second and half a radian per second on a half-metre track, the wheels turn at 0.875 and 1.125. Driving straight for a tenth of a second moves the robot a tenth of a metre along x.

Path following

Two geometric laws, both returning a steering angle and a speed. Pure pursuit aims at a point on the path ahead of the robot; Stanley corrects both the heading error and the lateral offset.

let path = [@mctl.Vec::of([0.0, 0.0]), @mctl.Vec::of([10.0, 0.0])]
let steer = @mctl.pursuit.steering(pose=@mctl.Vec::of([0.0, 0.0, 0.2]), path=path)
println(steer.to_string())
-0.37821001651844294

A lookahead point 3 m ahead and 0.4 m to the left asks for about five degrees of steering on a one-metre wheelbase. The showcase page runs both laws on the same circle from different starting offsets; pure pursuit settles onto the path, while Stanley — given only heading and offset, with no curvature feedforward — holds a small standing offset on a curve.

One loop, end to end

The pieces compose without glue. A profile supplies a speed, the inverse kinematics split it into wheel speeds, the forward kinematics rebuild the twist, and the pose integrates. A five-metre move on a half-metre track:

let profile = @mctl.scurve(distance=5.0, v_max=1.0, a_max=0.8, j_max=4.0)
let base = @mctl.Differential::new(wheel_radius=0.1, track=0.5)
let mut pose = @mctl.Vec::of([0.0, 0.0, 0.0])
for i in 0..<200 {
  let sample = profile.sample(i.to_double() * 0.05)
  let wheels = base.inverse(@mctl.Vec::of([sample.velocity, 0.0]))
  pose = base.advance(pose=pose, twist=base.forward(wheels), dt=0.05)
}
println(pose.get(0).to_string())
4.999999999999995

The move takes 6.45 seconds and the robot stops within a rounding error of five metres. Add a PID on the measured distance, an LQR on the drive, or a Kalman filter on the odometry, and the same loop carries the rest of the library unchanged.