Current section
Files
Jump to
Current section
Files
lib/bb/pid/controller.ex
# SPDX-FileCopyrightText: 2026 James Harton
#
# SPDX-License-Identifier: Apache-2.0
defmodule BB.PID.Controller do
@moduledoc """
General-purpose PID controller for BB robots.
Subscribes to configurable topics for setpoint and measurement values,
runs a periodic PID loop, and publishes output to a configurable topic.
## Architecture
```
Setpoint Topic ─────────────────────────┐
(configurable message/field) │
▼
┌─────────────────┐
Measurement Topic ────────────►│ BB.PID.Controller│
(configurable message/field) │ │
│ PIDControl │
│ .step() │
└────────┬─────────┘
│
▼
Output Topic
(configurable message/field)
```
One controller instance = one PID loop. Instantiate multiple controllers
for multiple control loops.
## Example Usage
defmodule MyRobot do
use BB
controller :shoulder_pid, {BB.PID.Controller,
kp: 2.0, ki: 0.1, kd: 0.05,
output_min: -1.0, output_max: 1.0,
setpoint_topic: [:actuator, :base_link, :shoulder, :pid],
setpoint_message: BB.Message.Actuator.Command.Position,
setpoint_path: [:position],
measurement_topic: [:sensor, :base_link, :shoulder, :encoder],
measurement_message: BB.Message.Sensor.JointState,
measurement_path: [:positions, 0],
output_topic: [:actuator, :base_link, :shoulder, :servo],
output_message: BB.Message.Actuator.Command.Velocity,
output_field: :velocity,
output_frame_id: :shoulder,
rate: 100
}
# ... rest of robot definition
end
## Path Extraction
The `*_path` options are lists of atoms (field names) and integers (list indices):
setpoint_path: [:position] # extracts payload.position
measurement_path: [:positions, 0] # extracts payload.positions |> Enum.at(0)
path: [:data, :readings, 0, :value] # extracts payload.data.readings[0].value
"""
use BB.Controller,
options_schema: [
kp: [type: :float, required: true, doc: "Proportional gain"],
ki: [type: :float, default: 0.0, doc: "Integral gain"],
kd: [type: :float, default: 0.0, doc: "Derivative gain"],
tau: [type: :float, default: 1.0, doc: "Derivative low-pass filter (0-1, 1=no filter)"],
output_min: [type: :float, default: -1.0, doc: "Output clamp minimum"],
output_max: [type: :float, default: 1.0, doc: "Output clamp maximum"],
setpoint_topic: [
type: {:list, :atom},
required: true,
doc: "Topic path to subscribe to for setpoint"
],
setpoint_message: [
type: {:behaviour, BB.Message},
required: true,
doc: "Message module to filter for (e.g., BB.Message.Actuator.Command.Position)"
],
setpoint_path: [
type: {:list, {:or, [:atom, :non_neg_integer]}},
required: true,
doc: "Path to value in message payload (e.g., [:position] or [:positions, 0])"
],
measurement_topic: [
type: {:list, :atom},
required: true,
doc: "Topic path to subscribe to for measurement"
],
measurement_message: [
type: {:behaviour, BB.Message},
required: true,
doc: "Message module to filter for (e.g., BB.Message.Sensor.JointState)"
],
measurement_path: [
type: {:list, {:or, [:atom, :non_neg_integer]}},
required: true,
doc: "Path to value in message payload (e.g., [:positions, 0])"
],
output_topic: [
type: {:list, :atom},
required: true,
doc: "Topic path to publish output to"
],
output_message: [
type: {:behaviour, BB.Message},
required: true,
doc: "Message module to construct (e.g., BB.Message.Actuator.Command.Velocity)"
],
output_field: [
type: :atom,
required: true,
doc: "Field name in output message for PID output value (e.g., :velocity)"
],
output_frame_id: [type: :atom, required: true, doc: "frame_id for constructed messages"],
rate: [type: :pos_integer, default: 100, doc: "Control loop frequency (Hz)"]
]
@impl BB.Controller
def init(opts) do
with :ok <- validate_unique_sources(opts),
:ok <- validate_paths(opts),
:ok <- validate_output_field(opts) do
bb = Keyword.fetch!(opts, :bb)
pid =
PIDControl.new(
kp: Keyword.fetch!(opts, :kp),
ki: Keyword.get(opts, :ki, 0.0),
kd: Keyword.get(opts, :kd, 0.0),
tau: Keyword.get(opts, :tau, 1.0),
output_min: Keyword.get(opts, :output_min, -1.0),
output_max: Keyword.get(opts, :output_max, 1.0)
)
state = %{
bb: bb,
pid: pid,
setpoint: nil,
measurement: nil,
setpoint_topic: opts[:setpoint_topic],
setpoint_message: opts[:setpoint_message],
setpoint_path: opts[:setpoint_path],
measurement_topic: opts[:measurement_topic],
measurement_message: opts[:measurement_message],
measurement_path: opts[:measurement_path],
output_topic: opts[:output_topic],
output_message: opts[:output_message],
output_field: opts[:output_field],
output_frame_id: opts[:output_frame_id],
rate: opts[:rate],
tick_ref: nil
}
BB.PubSub.subscribe(bb.robot, state.setpoint_topic)
BB.PubSub.subscribe(bb.robot, state.measurement_topic)
tick_ref = schedule_tick(state.rate)
{:ok, %{state | tick_ref: tick_ref}}
end
end
@impl BB.Controller
def handle_info({:bb, topic, %BB.Message{payload: %type{} = payload}}, state) do
cond do
topic == state.setpoint_topic and type == state.setpoint_message ->
value = extract_value(payload, state.setpoint_path)
{:noreply, %{state | setpoint: value}}
topic == state.measurement_topic and type == state.measurement_message ->
value = extract_value(payload, state.measurement_path)
{:noreply, %{state | measurement: value}}
true ->
{:noreply, state}
end
end
def handle_info(:tick, state) do
state =
if state.setpoint != nil and state.measurement != nil do
pid = PIDControl.step(state.pid, state.setpoint, state.measurement)
message = build_output_message(pid.output, state)
BB.PubSub.publish(state.bb.robot, state.output_topic, message)
%{state | pid: pid}
else
state
end
tick_ref = schedule_tick(state.rate)
{:noreply, %{state | tick_ref: tick_ref}}
end
def handle_info(_msg, state) do
{:noreply, state}
end
@impl BB.Controller
def handle_options(new_opts, state) do
pid =
PIDControl.new(
kp: Keyword.fetch!(new_opts, :kp),
ki: Keyword.get(new_opts, :ki, 0.0),
kd: Keyword.get(new_opts, :kd, 0.0),
tau: Keyword.get(new_opts, :tau, 1.0),
output_min: Keyword.get(new_opts, :output_min, -1.0),
output_max: Keyword.get(new_opts, :output_max, 1.0)
)
{:ok, %{state | pid: pid}}
end
@impl BB.Controller
def terminate(_reason, state) do
if state.tick_ref, do: Process.cancel_timer(state.tick_ref)
:ok
end
defp validate_unique_sources(opts) do
setpoint_source = {opts[:setpoint_topic], opts[:setpoint_message]}
measurement_source = {opts[:measurement_topic], opts[:measurement_message]}
if setpoint_source == measurement_source do
{:stop,
{:invalid_config,
"setpoint and measurement must have different topic/message combinations"}}
else
:ok
end
end
defp validate_paths(opts) do
cond do
opts[:setpoint_path] == [] ->
{:stop, {:invalid_config, "setpoint_path cannot be empty"}}
opts[:measurement_path] == [] ->
{:stop, {:invalid_config, "measurement_path cannot be empty"}}
true ->
:ok
end
end
defp validate_output_field(opts) do
output_message = opts[:output_message]
output_field = opts[:output_field]
schema = output_message.schema()
case Keyword.fetch(schema.schema, output_field) do
{:ok, field_opts} ->
field_type = Keyword.get(field_opts, :type)
if numeric_type?(field_type) do
:ok
else
{:stop,
{:invalid_config,
"output_field #{inspect(output_field)} must be a numeric type, got #{inspect(field_type)}"}}
end
:error ->
{:stop,
{:invalid_config,
"output_field #{inspect(output_field)} not found in #{inspect(output_message)}"}}
end
end
defp numeric_type?(:float), do: true
defp numeric_type?(:integer), do: true
defp numeric_type?(:pos_integer), do: true
defp numeric_type?(:neg_integer), do: true
defp numeric_type?(:non_neg_integer), do: true
defp numeric_type?({:or, types}), do: Enum.any?(types, &numeric_type?/1)
defp numeric_type?(_), do: false
defp extract_value(payload, path) do
get_in(payload, build_accessors(path))
end
defp build_accessors(path) do
Enum.map(path, fn
key when is_atom(key) -> Access.key!(key)
index when is_integer(index) -> Access.at!(index)
end)
end
defp build_output_message(output_value, state) do
BB.Message.new!(state.output_message, state.output_frame_id, [
{state.output_field, output_value}
])
end
defp schedule_tick(rate) do
interval_ms = div(1000, rate)
Process.send_after(self(), :tick, interval_ms)
end
end