James Harton James Harton

September 2026: Achieving Balance

The balance bot balances. Getting there meant taking Nx back out of the maths core — 6.9× fewer reductions and a whole CPU core handed back — plus multi-DoF joints, a framework-owned clock, and commands that finally admit when they've been refused.

Last month the framework learned to take instructions through one door. This month a robot stood up on two wheels and stayed there, which took rather more arithmetic than I'd budgeted for.

Eight releases of bb — 0.26 through 0.31 — and something close to forty across the satellites. The headline is hardware, and the reason the hardware works is a decision I made a few months ago and have now reversed.

It Balances

The two-wheeled balance bot that's the subject of the Goatmire workshop balances and drives. Not gracefully — disturb it hard enough and it will rush off into the distance and fall over with tremendous commitment — but it holds itself up and it goes where it's pointed, which is more than it did in July.

The case design is finalised and printed, the workshop parts are ordered, and Gus's prototype carrier boards have done their job. The focus now shifts off the robot and onto the teaching materials, which is a much better problem to have four weeks out.

Nx Is Not Free On A Cortex-A7

Here's the bit that made it work, and it's an unwind.

Back in bb#147 I decided that BB.Math.Vec3, BB.Math.Quaternion and BB.Math.Transform should hold Nx tensors, on the grounds that machines running BB have ample CPU headroom. That premise is fine on a laptop. On a Nerves Starter Kit board with two Cortex-A7 cores trying to close a 100 Hz loop, it is comprehensively false, and the robot that couldn't hold itself up was the thing that told me so.

bb 0.31.0 holds the small math types as plain BEAM floats (#249). tensor/1 and from_tensor/1 remain, and now convert rather than unwrap. Measured on the balance bot, three runs a side, reductions per second:

processNx-backedscalar
imu (BMI323)174,540144,6571.2×
orientation (Mahony)742,633159,2254.7×
lean_angle1,747,22579,28422.0×
total2,667,827386,8406.9×

CPU went from 70.3% and 72.3% busy across the two cores to roughly 24% each — about a whole core given back, on a board that has two. On that board, that is the difference between balancing and not.

The 22× on lean_angle is the one that stings. Quaternion.to_euler/2 was building a rotation matrix as nine tensors plus two Nx.stacks and then running about twenty-five eager ops, six of them selects for the gimbal-lock branches, to answer a question that is nine multiplications and an atan2. Nx's per-operation cost is small; it is not small relative to that.

Two follow-ups rode along:

  • bb_pid_controller 0.3.2 gives BB.PID.Kernel a scalar path (#90). A {}-shaped defn step costs ~180 µs and allocates hundreds of kilobytes; the same arithmetic on floats costs 0.22 µs. BB.PID.Controller is always the single-loop case, so every robot using the package was paying the full cost of Nx for none of the benefit. The batched path is untouched and still wins at 32 loops.
  • bb_estimator_ahrs 0.2.2 deletes its private BB.Estimator.Ahrs.Quaternion and uses BB.Math.Quaternion directly (#60). That type existed solely to dodge Nx dispatch overhead at hundreds of hertz, and its moduledoc said so. With the reason gone, the two were the same struct with the same field names.

Batching still matters — a fleet of identical chains, the legs of a gait — and nothing in the vectorised kinematics has moved. What changed is that a three-element vector no longer pays tensor tax for the privilege of being three floats.

Which Brings Us To Goatmire

Four weeks out, so: I'm doing two things there.

  • "Beam Bots: Robotics on the BEAM" — the conference talk.
  • "Achieving Balance in the Workshop" — a free two-day workshop where you build a balance bot on a custom carrier board for the Nerves starter kit, write a Phoenix app to drive it from your phone, and take the whole thing home along with a repo to keep hacking on.

No soldering iron and no screwdriver. The whole robot press-fits together, with the holding force supplied by six cable ties and a bit of double-sided tape for the battery. That's deliberate: threaded fasteners into PLA are a liability at the best of times, and a workshop is not the best of times. A stripped thread halfway through the build is somebody's afternoon gone, and you can't un-strip it. Six cable ties can be cut off and replaced by anybody, hold better than the screws would have, and cost about forty cents.

Goatmire runs 28 September – 3 October in Varberg, with Frank Hunleth, Zach Daniel and Sam Aaron among the speakers. Workshop registration is free and capacity is limited. There's a #achieving-balance channel on the Goatmire Discord if you're coming along or want to ask what you'd be signing up for.

Joints That Move In More Than One Direction

bb 0.27.0 implements :planar and :floating joints (#217, proposal 0022).

Both were already in the joint-type enum, and both silently returned Transform.identity() from forward kinematics. You could declare a floating base, get no warning at compile time or runtime, and receive kinematics that treated your mobile robot as welded to the floor — every downstream pose wrong, nothing saying so. That's the worst class of bug this framework can ship, and it shipped.

The design decision worth reading is in the PR: the kernel takes per-joint motion matrices rather than decomposing a stored rotation into Euler angles, which would be lossy, non-unique and gimbal-locked. Each joint contributes origin · scalar_motion(q) · stored · (I + hat(delta)), where delta is the differentiable parameter the Jacobian needs and is only ever evaluated at zero. A real se(3) exponential was tried first and produced :nan gradients, because (1 - cos θ)/θ² is a literal 0/0 at exactly the point you evaluate it. The payoff is that single-DoF joints need no special case at all — stored = I, delta = 0, and the expression collapses to what it always was.

The user-facing consequence is a rename: positions became configurations throughout, because a joint's value is no longer necessarily a scalar. Everything followed — bb_jido, bb_kino, bb_liveview, bb_mcp, bb_servo_pca9685, bb_servo_pigpio, bb_policy.

The solvers divided along honest lines. bb_ik_dls solves them (#75) — damped least squares is a pseudo-inverse over whatever Jacobian it's handed, so a wider Jacobian needs no algorithmic change. bb_ik_fabrik refuses them (#86), because FABRIK reasons about points on a chain and a floating base has no point analogue. Better a clear refusal than a plausible wrong answer.

While we were in there, FABRIK got rewritten

Someone reported that FABRIK's reached flag was a literal true (#85). Fixing it meant rewriting the solver, because the flag had been hiding how far off the answers were.

The point solve was converging to 1e-4 and then throwing the answer away. points_to_positions/4 measured each segment's world-frame direction change and assigned it wholesale to that joint — but a joint's value is relative to its parent, so the world-frame change is the accumulated work of every ancestor. Fed the exact points of a known pose, it recovered three joints of six on the 6-DoF arm, and a fourth leaked its entire rotation into its child.

bb_ik_fabrik 0.7.0 now reads the backward reach as desired directions and walks base-to-tip choosing, per joint, the rotation about its real world axis that gets closest, clamped to its limits (#88). Every pose it considers is one the robot can actually hold. Over 200 provably-achievable targets:

robotbeforeafterwithin 1 cm
TwoLinkArm0.0589.5e-515/200 → 200/200
ThreeLinkArm0.0789.7e-51/200 → 200/200

The Framework Owns The Clock Now

Last month I noted that there was no framework-provided periodic-loop primitive and every component scheduled its own tick. There is now: bb 0.26.0 adds BB.Loop (#212).

Every loop-runner in the ecosystem was hand-rolling this, and each got it wrong differently. The common scheme — a fixed send_after delay, re-armed after the work — lets both handler duration and scheduler latency push the schedule later. Measured over 300 ticks at a nominal 10 ms, that's 13% drift: a "100 Hz" loop actually running at about 88 Hz. bb_pid_controller then called its control law with the library default t: 1.0, so the integral and derivative terms were computed as though each step were a full second apart. The loop wasn't running at the configured rate, and the maths assumed a rate it wasn't achieving anyway.

BB.Loop accumulates absolute deadlines in nanoseconds, so neither a slow handler nor millisecond rounding moves the schedule. The non-obvious part is that missed periods are skipped, not queued: re-arming to a deadline that has already passed makes send_after fire immediately, so absolute deadlines alone still produce a burst — measured at 19 consecutive sub-millisecond ticks after a 200 ms stall, identical to what :timer.send_interval/2 gives you via mailbox backlog. For anything carrying an integral term, a burst of zero-dt steps is worse than missing the ticks outright. The skip count doubles as the loop's overrun metric, published on [:bb, :loop, :tick].

Everything periodic has moved across: both IK trackers, bb_policy's three loops, the BMI323 poller, the INA219 publisher, and the feetech and robotis servo buses.

bb_pid_controller took the opportunity to own its control law as well, fixing derivative kick (differentiate the measurement, not the error) and swapping integrator clamping for back-calculation anti-windup. Existing loops will need retuningki and kd are now per second of real elapsed time rather than per nominal step.

Did It Actually Work? Now You Can Tell

bb 0.30.0 makes BB.Actuator.set_position/4 synchronous (#235).

Last month's work gave the framework a gate that refuses commands — disarmed robot, undeclared payload. It just couldn't tell you. refuse/5 emitted telemetry and then replied only on the :call transport, so the pubsub and cast forms dropped the error and the caller carried on believing the joint was moving. A safety check nobody can observe is a safety check that fails quietly, which is the wrong direction for one to fail.

set_position/4 now publishes the command and delivers it by a call, returning :ok | {:error, reason}. The publication stays, because orchestration and logging should still see that a command was issued; the return value says whether it was accepted. set_position!/4 is now set_position_async/4 — the bang was never Elixir's bang, it meant "cast", and a bang function that can't see the error it would raise on is a contradiction. set_position_sync/5 is gone, folded into a :timeout option.

bb's own tests caught the change: several commanded a robot they'd never armed, and passed only because the refusal was invisible.

0.30 also added a compile-time verifier for a related silence. BB.Robot.State is written from JointState messages and nothing else, so a non-fixed joint that's driven but never measured stays at its initial configuration forever — quietly breaking FK, IK seeding and every visualiser. That now warns at compile time, and drivers declare capabilities/1 so the verifier knows who reports position.

The sibling commands — set_velocity, set_effort, follow_trajectory, stop, hold — still have the original flaw and are a one-shape-fits-all sweep for another day. The moduledoc says so plainly rather than pretending otherwise.

Parameters That Mean What They Say

Three separate ways a parameter could lie to you, all now closed:

  • Bounds weren't bounds. The parameters DSL accepted min:/max: and stored them, but the transformer never copied them into the generated schema. A declaration that read as a constraint wasn't one. Now they fold into the Spark.Options type, so they apply at set/3, set_many/2 and startup params:; and bounds that can't be enforced — on a :string, or min above max, or a default outside its own range — fail the robot's compilation (#234).
  • Units weren't converted. A {:unit, :degree} parameter accepted ~u(0.26 radian) and reported it back in radians. bb_liveview, bb_kino and bb_mcp had each grown their own workaround. There were six write paths in runtime.ex and only one of them stored what validation produced, so a default: ~u(0 radian) booted holding radians and a persisted value replayed as radians on every restart. All six now convert (#244, bb 0.31.0).
  • The UI couldn't reach the server at all. None of the parameter inputs in bb_liveview worked from a browser (#136): the slider's hook read a data-name nothing carried and pushed to a LiveView with no handle_event/3; the number, atom and text inputs carried phx-change outside any <form> and threw in the client before pushing anything; and phx-value-path would never have arrived anyway, since LiveView reads phx-value-* off the form for form events. All of them are forms now, the custom hook is deleted, and bb_kino stopped taking the whole cell down when you typed an atom the runtime had never heard of.

A Bad Month For Things That Silently Did Nothing

A theme, apparently.

Trajectories were broken on any joint with a transmission (#226). Transmission.apply_to_waypoint/2 pattern-matched a waypoint as a map; the schema has always declared waypoints as keyword lists. So every trajectory sent to a joint with a transmission raised a FunctionClauseError and took the actuator down with it. On an SO-101 that's five joints of six — the gripper is the only one without a transmission, which is exactly why this survived unnoticed. Waypoint velocity and acceleration are also optional now, because positions and times is the ordinary way to describe a trajectory and inventing a 0.0 a driver can't distinguish from a deliberate standstill is worse than omitting it.

A servo that reported the right speed and moved at a different one (feetech #67). An STS servo starts moving the moment goal_position lands, so a goal_speed sent in a following packet can arrive after the move has begun and get ignored. Commanded 850 steps at 196 steps/s — about 4,300 ms expected:

separate packets      805ms    <- speed ignored
one instruction      4298ms
one instruction      4267ms
one instruction      4284ms

Roughly one move in four on the bench, and much more often through a control loop that emits both writes microseconds apart. goal_speed reads back correctly every time, which is what makes it so hard to spot. sync_write_raw/3 now takes a list of registers and writes the contiguous span in one instruction, refusing anything with a gap.

The SO-101 installer generated limits the servos can't reach (bb_so101 #60). It emitted acceleration(~u(2160 degree_per_square_second)) on every joint; an STS3215 clamps that register at 50 raw — 439.5 deg/s² — and says nothing. Five sixths of the figure was fiction. Once bb_servo_feetech started reading the register back and refusing to start on a mismatch, a freshly installed arm wouldn't boot at all, which is precisely what happened to a real SO-101 during this work. Velocity came down from 333 to 300 deg/s too: 333 was the datasheet's no-load figure, 300 is what the servo actually reached on the bench. Neither change makes the arm slower — the servo was already doing this — but BeginMotion now predicts arrivals the joint can meet, which is the entire point of declaring a limit.

The gripper's zero was 53° from where it belonged (bb_so101 #59). Calibration set zero to the arithmetic midpoint of the recorded sweep. That's right for the five symmetric joints and wrong for the gripper, whose URDF range is −10° to 100°. It now anchors to the closed stop, because gripping depends on the jaws actually closing and the slack belongs at the open end where nothing relies on it. Calibration also learned --joint, so fixing one joint no longer discards the other five.

bb 0.27 deadlocked the Elixir 1.19 compiler (#223) — a genuine compile-time cycle between BB.Message.Option and the Twist/Wrench structs it pattern-matches. Elixir 1.20 tolerates a struct pattern in a cycle and 1.19 doesn't, so 1.20 was masking a real bug. bb declares ~> 1.19, CI ran 1.20.2, and nobody found out until somebody tried to adopt 0.28. That gap is closed too: the shared workflows now build every Elixir minor between a repo's declared floor and its pinned version, with combinations resolved from builds.hex.pm so every pair is one that exists.

The robotis driver claimed XL320 support it never had (#104). The XL320 is an earlier generation with a different control table — control_mode not operating_mode, moving_speed not profile_velocity, no goal-current register at all — and the lookup for a missing parameter was a MatchError raised inside the Robotis GenServer, taking the bus down. Nobody in the ecosystem has one, so the claim is withdrawn rather than implemented. If you want XL320 support, say so and it'll get built properly.

Community Corner

Mauricio's bb_tui has kept pace with a month of breaking changes and then some — two releases, 0.4.0 and 0.5.0. It follows the configurations rename, carries multi-DoF joint configurations through state, panels and events, demos a driven planar base on its dev robot, delegates its scene's forward kinematics to bb core rather than duplicating it, and surfaces actuator refusals from the jog keys in the event log. That last one landed within days of the refusal work shipping, which is a nice thing to watch happen. ex_ratatui also put out 0.12.0 and 0.13.0.

Elsewhere In Robots

Three things from the wider world that aren't ours but are worth your attention:

  • Microduck — Pollen Robotics' 25 cm biped, US$399, 15 motors, camera, LiDAR, two IMUs, a 50 Hz onboard policy loop, and the whole SDK and RL training stack on GitHub under Apache-2.0. It runs an RK3566, which makes it eminently Nerves-able and therefore extremely tempting. Rather less tempting once converted to New Zealand dollars, but I'm working on my justification.
  • The Model Hardware Standard — Anthropic's new interface for letting agents discover and operate physical hardware, with the device's physical characteristics and safety limits carried in the driver rather than in a PDF somebody laminated in 2011. It's in research preview with Doosan, Universal Robots, Hugging Face and Raspberry Pi among those testing it, and it'll be open-sourced. Being a young framework is an advantage here: we can add this sort of integration without contorting an abstraction we've already committed to. I've applied for preview access.
  • Robostral Navigate — an 8B model that navigates from natural language and a single RGB camera, trained entirely in simulation and reportedly transferring to wheeled, legged and flying robots. I've asked for preview access to that one too.

I'm not expecting to hear back from either. As a friend of mine likes to say: you miss 100% of the shots you don't take.

On the Bench

An outdoor robot is on the drawing board, which means GNSS. There's a spike of bb_ublox — a BB.Sensor for serially connected u-blox receivers — and I have M8- and M10-based modules on the desk waiting for a free evening to test against.

The maths that needs is the subject of proposal 0023: bb_geo — WGS 84, geodetic ↔ ECEF ↔ local ENU/NED frames, distance and bearing, and the NavSatFix payloads receiver drivers publish. Deliberately no behaviour: a BB.Sensor publishes and subscribers pattern-match, so pubsub already supplies the polymorphism, and vendor configuration is already BB.Bridge's job. The shared thing is a message plus a maths library, not a callback contract.

Geodesy is a domain where plausible-looking code is quietly wrong in expensive ways — spherical versus ellipsoidal distance is 5 m per km, ellipsoid height versus mean sea level is tens of metres, ENU versus NED is two sign errors and a swapped axis pair that looks fine near the origin, and f32 for ECEF gives you half-metre resolution before you do any arithmetic at all. The API will have no default convention, and everything will be :f64, asserted by test.

The open question I'd most like settled is whether the messages live in bb_geo or in core. Core precedent is strong — Imu, LaserScan and BatteryState are all in core and bb_sensor_bmi323 doesn't depend on a bb_imu to get Imu — but every GNSS driver takes the dep anyway. I'd call it 60/40. Opinions welcome.