In this tutorial, you'll write a BB.Estimator from scratch — a small sensor-nested filter that consumes raw accelerometer data and republishes it with a fused tilt estimate. By the end you'll understand the behaviour, the DSL entity, and how messages flow through an estimator.
Prerequisites
Complete Sensors and PubSub. You should know how a sensor publishes BB.Message.Sensor.Imu (or similar) into pubsub and how to subscribe.
The estimator's job
An estimator consumes one or more input message streams and publishes derived state. The same contract covers two distinct cases:
- Within-sensor fusion — combining the modalities of a single sensor. AHRS combining gyro + accelerometer from one IMU into orientation is the canonical case.
- Cross-sensor fusion — combining different physical sensors into an estimate of some target frame's state. An EKF blending IMU + wheel odometry into a base pose is the canonical case.
This tutorial focuses on the within-sensor form because it's the simplest place to start. The cross-sensor form is covered in the understanding-estimators topic.
The example we'll build
A TiltSmoother that nests inside an IMU sensor, consumes its Imu messages, and republishes them with the :orientation field set to a low-pass tilt estimate computed from the accelerometer. (A real AHRS would also use the gyro — bb_estimator_ahrs does — but a single-axis low-pass keeps the example small.)
Step 1: Declare the estimator in your DSL
estimator is a DSL entity that nests inside either a sensor (sensor-nested form — what we want) or a link (cross-sensor form — for later).
defmodule MyRobot.Robot do
use BB
topology do
link :base_link do
sensor :imu, MyImuSensor, bus: "i2c-1", address: 0x68 do
estimator :tilt, {TiltSmoother, alpha: 0.95}
end
end
end
endThe estimator declaration takes the same shape as sensor and actuator — a name (:tilt) and a child spec ({TiltSmoother, alpha: 0.95}).
Because :tilt is nested inside sensor :imu, the framework wires the parent sensor's published messages as its implicit input. No input blocks are needed.
Step 2: Write the callback module
Like sensors and controllers, your module is not a GenServer — the framework provides a wrapper (BB.Estimator.Server) that delegates to your callbacks. You implement BB.Estimator.
defmodule TiltSmoother do
use BB.Estimator,
options_schema: [
alpha: [type: :float, default: 0.95, doc: "Low-pass weight"]
]
alias BB.Math.{Quaternion, Vec3}
alias BB.Message
alias BB.Message.Sensor.Imu
@impl BB.Estimator
def init(opts) do
{:ok, %{alpha: Keyword.fetch!(opts, :alpha), q: Quaternion.identity()}}
end
@impl BB.Estimator
def handle_input(%Message{payload: %Imu{} = imu} = msg, state) do
{ax, ay, az} = {Vec3.x(imu.linear_acceleration),
Vec3.y(imu.linear_acceleration),
Vec3.z(imu.linear_acceleration)}
# Tilt from gravity (roll about X, pitch about Y), ignoring yaw.
roll = :math.atan2(ay, az)
pitch = :math.atan2(-ax, :math.sqrt(ay * ay + az * az))
q_accel = Quaternion.from_euler(roll, pitch, 0.0)
q_blended = Quaternion.slerp(state.q, q_accel, 1.0 - state.alpha)
{:ok, out} =
Imu.new(msg.frame_id,
orientation: q_blended,
angular_velocity: imu.angular_velocity,
linear_acceleration: imu.linear_acceleration
)
{:reply, [out: out], %{state | q: q_blended}}
end
def handle_input(_other, state), do: {:noreply, state}
endTwo things are worth pausing on.
The reply shape
Sensors and actuators publish by calling BB.PubSub.publish/3 directly. Estimators don't — they return outputs from their callback:
{:reply, [out: message], new_state}The framework routes each {output_name, message} tuple to that output's pubsub path. :out is the conventional single-output name and the framework synthesises a path for it automatically — your estimator's own path, in this case [:sensor, :base_link, :imu, :tilt]. Multi-output estimators declare each output explicitly with an output :name block; this tutorial doesn't need them.
You can return {:noreply, state} to consume an input without emitting anything — useful for accumulators that need several updates before producing one output.
The input shape
For sensor-nested estimators (or link-nested estimators with one declared input), handle_input/2 receives a single %BB.Message{} envelope. Multi-input estimators receive a map %{input_name => message} keyed by their declared input names. The same callback handles both — pattern-match on whichever shape your estimator expects.
For Roboticists: This is similar in spirit to a ROS node that subscribes to
/imu/data_rawand republishes on/imu/data. The difference is that BB's framework owns subscription, fan-in, dt tracking, and output routing — your module is pure logic.
For Elixirists: Think of
BB.Estimatoras a behaviour that gives you a GenServer with structured input/output semantics —handle_input/2is your message handler, the{:reply, outputs, state}return shape is the publish side. You can also handle arbitrary messages viahandle_info/2,handle_call/3,handle_cast/2, and any of them can return the{:reply, outputs, state}shape too — handy for estimators that emit on a timer.
Step 3: Subscribe to the estimator's output
The estimator publishes to its natural path. For sensor-nested estimators that's the parent sensor's path with the estimator name appended:
{:ok, _} = BB.subscribe(MyRobot, [:sensor, :base_link, :imu, :tilt])
# ... in handle_info ...
def handle_info({:bb, _path, %BB.Message{payload: %BB.Message.Sensor.Imu{} = imu}}, state) do
{roll, pitch, _yaw} = BB.Math.Quaternion.to_euler(imu.orientation)
IO.puts("roll=#{Float.round(roll, 3)} pitch=#{Float.round(pitch, 3)}")
{:noreply, state}
endSubscribers to [:sensor, :base_link, :imu] still receive the raw sensor output — the estimator publishes alongside, not in place of, the parent sensor.
For Roboticists: The two are siblings in pubsub-space. If a downstream consumer wants raw IMU data and another wants the fused output, both subscribe to the paths they care about.
Step 4: Test the estimator
Algorithms are easier to test as pure functions. Expose a stateless step/N helper so tests can drive it without spinning up a GenServer:
defmodule TiltSmoother do
# ... as before ...
@doc "Run one step against an `{ax, ay, az}` accel tuple."
def step(state, {ax, ay, az}) do
# same body, just refactored out of handle_input
end
endThen in your tests:
defmodule TiltSmootherTest do
use ExUnit.Case, async: true
test "stationary gravity along +Z stays near identity" do
state = %{alpha: 0.9, q: BB.Math.Quaternion.identity()}
final =
Enum.reduce(1..100, state, fn _, s ->
TiltSmoother.step(s, {0.0, 0.0, 9.81})
end)
assert_in_delta BB.Math.Quaternion.angular_distance(
final.q,
BB.Math.Quaternion.identity()
),
0.0,
1.0e-3
end
endFor integration tests, spin up the whole robot:
test "estimator publishes when sensor input arrives" do
start_supervised!(MyRobot.Robot)
{:ok, _} = BB.subscribe(MyRobot.Robot, [:sensor, :base_link, :imu, :tilt])
# Publish a fake IMU message at the parent sensor's path:
{:ok, msg} = BB.Message.Sensor.Imu.new(:imu,
orientation: BB.Math.Quaternion.identity(),
angular_velocity: BB.Math.Vec3.zero(),
linear_acceleration: BB.Math.Vec3.new(0.0, 0.0, 9.81)
)
BB.publish(MyRobot.Robot, [:sensor, :base_link, :imu], msg)
assert_receive {:bb, [:sensor, :base_link, :imu, :tilt],
%BB.Message{payload: %BB.Message.Sensor.Imu{}}}, 500
endCommon gotchas
Path conventions
Sensor-nested estimators publish on
[:sensor | sensor_path] ++ [estimator_name]. For the example above that's[:sensor, :base_link, :imu, :tilt].Link-nested estimators publish on
[:estimator | link_path] ++ [estimator_name]— the:estimatorprefix distinguishes them from sensor outputs in subscriptions.
When the verifier rejects an estimator with "input :foo references unknown path …" it usually means a typo in the path or that the referenced sensor lives at a different level of the topology than you think.
dt tracking
Each incoming message carries monotonic_time in nanoseconds. An estimator that needs dt should store the previous message's monotonic_time and compute the delta on each handle_input/2. The framework doesn't compute dt for you — that decision is per-algorithm (some don't need it, some prefer a fixed-rate approximation).
Unit conventions
BB uses SI everywhere: rad/s for angular velocity, m/s² for linear acceleration, radians for orientation, metres for translation. Algorithms that traditionally work in other units (e.g. accel_threshold expressed as "fraction of 1 g") should convert at the boundary. Don't push unit awareness into sensor drivers — they should publish SI.
Next steps
- The understanding-estimators topic explains the design choices behind the behaviour and DSL — single vs multi-input, frame semantics, why the reply shape, where estimators sit in the supervision tree.
- The configure estimator health how-to covers
latency_budget,lost_after, and theon_degraded/on_lost/on_recoveredcommand-as-policy mechanism. - The
bb_estimator_ahrssibling package is a real-world example: three IMU fusion algorithms (Madgwick, Mahony, Complementary), each implemented as aBB.Estimator. Skim the source for patterns to copy.