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:
| process | Nx-backed | scalar | |
|---|---|---|---|
imu (BMI323) | 174,540 | 144,657 | 1.2× |
orientation (Mahony) | 742,633 | 159,225 | 4.7× |
lean_angle | 1,747,225 | 79,284 | 22.0× |
| total | 2,667,827 | 386,840 | 6.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_controller0.3.2 givesBB.PID.Kernela scalar path (#90). A{}-shapeddefnstep costs ~180 µs and allocates hundreds of kilobytes; the same arithmetic on floats costs 0.22 µs.BB.PID.Controlleris 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_ahrs0.2.2 deletes its privateBB.Estimator.Ahrs.Quaternionand usesBB.Math.Quaterniondirectly (#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:
| robot | before | after | within 1 cm |
|---|---|---|---|
| TwoLinkArm | 0.058 | 9.5e-5 | 15/200 → 200/200 |
| ThreeLinkArm | 0.078 | 9.7e-5 | 1/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
retuning — ki 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
parametersDSL acceptedmin:/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 theSpark.Optionstype, so they apply atset/3,set_many/2and startupparams:; and bounds that can't be enforced — on a:string, orminabovemax, 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_kinoandbb_mcphad each grown their own workaround. There were six write paths inruntime.exand only one of them stored what validation produced, so adefault: ~u(0 radian)booted holding radians and a persisted value replayed as radians on every restart. All six now convert (#244,bb0.31.0). - The UI couldn't reach the server at all. None of the parameter inputs in
bb_liveviewworked from a browser (#136): the slider's hook read adata-namenothing carried and pushed to a LiveView with nohandle_event/3; the number, atom and text inputs carriedphx-changeoutside any<form>and threw in the client before pushing anything; andphx-value-pathwould never have arrived anyway, since LiveView readsphx-value-*off the form for form events. All of them are forms now, the custom hook is deleted, andbb_kinostopped 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.
Links
bb0.31.0 · 0.30.0 · 0.29.0 · 0.28.0 · 0.27.0 · 0.26.0- Proposal 0022 — Multi-DoF Joints · Proposal 0023 —
bb_geo - Goatmire 2026, 28 Sept – 3 Oct, Varberg — the balance bot workshop is free to register
bb_ik_dls·bb_ik_fabrik·bb_pid_controller·bb_estimator_ahrsbb_so101·bb_servo_feetech·feetechbb_tui·ex_ratatuibbon GitHub- Beam Bots on the Elixir Forum · Discord