Current section

Files

Jump to
bb_ik_dls lib bb ik dls tracker.ex
Raw

lib/bb/ik/dls/tracker.ex

# SPDX-FileCopyrightText: 2025 James Harton
#
# SPDX-License-Identifier: Apache-2.0
defmodule BB.IK.DLS.Tracker do
@moduledoc """
GenServer implementing continuous position tracking with DLS.
Runs a periodic IK solve loop, continuously solving for updated targets
and sending actuator commands. Useful for following moving targets or
real-time position control from external sources.
## Usage
# Start tracking
{:ok, pid} = BB.IK.DLS.Tracker.start_link(
robot: MyRobot,
target_link: :gripper,
initial_target: {0.3, 0.2, 0.1},
update_rate: 30
)
# Update target from vision callback
BB.IK.DLS.Tracker.update_target(pid, {0.35, 0.25, 0.15})
# Check status
%{residual: 0.001, tracking: true} = BB.IK.DLS.Tracker.status(pid)
# Stop and get final positions
{:ok, positions} = BB.IK.DLS.Tracker.stop(pid)
## Options
- `:robot` - Robot module (required)
- `:target_link` - Link to track (required)
- `:initial_target` - Starting target position (required)
- `:update_rate` - Solve frequency in Hz (default: 20)
- `:delivery` - Actuator command delivery: `:direct` (default), `:pubsub`, `:sync`
- `:max_iterations` - Maximum DLS iterations per update (default: 100)
- `:tolerance` - Convergence tolerance in metres (default: 1.0e-4)
- `:lambda` - Damping factor (default: 0.5)
- `:adaptive_damping` - Adapt lambda based on error (default: true)
- `:step_size` - Max joint update per iteration (default: 0.1)
- `:respect_limits` - Whether to clamp to joint limits (default: true)
- `:name` - Optional GenServer name for registration
## Notes
- Uses `:direct` delivery by default for low latency
- Continues tracking even if individual solves fail (best-effort)
- Call `stop/1` to cleanly terminate tracking
"""
use GenServer
alias BB.IK.DLS
alias BB.Motion
alias BB.Robot.Runtime
alias BB.Robot.State, as: RobotState
defstruct [
:robot_module,
:robot,
:robot_state,
:target_link,
:target,
:delivery,
:solver_opts,
:update_rate,
:timer_ref,
:last_positions,
:last_meta,
:last_update,
tracking: true
]
@default_update_rate 20
@default_delivery :direct
@doc """
Start a tracker process.
See module documentation for options.
"""
def start_link(opts) do
{gen_opts, tracker_opts} = Keyword.split(opts, [:name])
GenServer.start_link(__MODULE__, tracker_opts, gen_opts)
end
@doc """
Update the current target position.
"""
def update_target(tracker, target) do
GenServer.call(tracker, {:update_target, target})
end
@doc """
Get current tracking status.
"""
def status(tracker) do
GenServer.call(tracker, :status)
end
@doc """
Stop tracking and return final positions.
## Options
- `:hold` - Send hold commands to actuators (default: false)
"""
def stop(tracker, opts \\ []) do
GenServer.call(tracker, {:stop, opts})
end
@impl GenServer
def init(opts) do
robot_module = Keyword.fetch!(opts, :robot)
target_link = Keyword.fetch!(opts, :target_link)
initial_target = Keyword.fetch!(opts, :initial_target)
update_rate = Keyword.get(opts, :update_rate, @default_update_rate)
delivery = Keyword.get(opts, :delivery, @default_delivery)
solver_opts =
Keyword.take(opts, [
:max_iterations,
:tolerance,
:orientation_tolerance,
:lambda,
:adaptive_damping,
:step_size,
:respect_limits
])
|> Keyword.reject(fn {_k, v} -> is_nil(v) end)
robot = Runtime.get_robot(robot_module)
robot_state = Runtime.get_robot_state(robot_module)
state = %__MODULE__{
robot_module: robot_module,
robot: robot,
robot_state: robot_state,
target_link: target_link,
target: initial_target,
delivery: delivery,
solver_opts: solver_opts,
update_rate: update_rate,
tracking: true
}
timer_ref = schedule_tick(update_rate)
{:ok, %{state | timer_ref: timer_ref}}
end
@impl GenServer
def handle_call({:update_target, target}, _from, state) do
if state.tracking do
{:reply, {:ok, state}, %{state | target: target}}
else
{:reply, {:error, :not_tracking}, state}
end
end
@impl GenServer
def handle_call(:status, _from, state) do
status = %{
tracking: state.tracking,
target: state.target,
residual: get_in(state.last_meta, [:residual]),
iterations: get_in(state.last_meta, [:iterations]) || 0,
update_rate: state.update_rate,
last_update: state.last_update
}
{:reply, status, state}
end
@impl GenServer
def handle_call({:stop, opts}, _from, state) do
if state.timer_ref, do: Process.cancel_timer(state.timer_ref)
if opts[:hold] do
send_hold_commands(state)
end
{:stop, :normal, {:ok, state.last_positions || %{}}, %{state | tracking: false}}
end
@impl GenServer
def handle_info(:tick, state) do
state = do_solve_and_send(state)
timer_ref =
if state.tracking do
schedule_tick(state.update_rate)
else
nil
end
{:noreply, %{state | timer_ref: timer_ref}}
end
defp do_solve_and_send(state) do
motion_opts =
state.solver_opts
|> Keyword.put(:solver, DLS)
|> Keyword.put(:delivery, state.delivery)
case Motion.move_to(state.robot_module, state.target_link, state.target, motion_opts) do
{:ok, meta} ->
positions = get_current_positions(state)
%{
state
| last_positions: positions,
last_meta: meta,
last_update: DateTime.utc_now()
}
{:error, error} ->
meta = %{
residual: Map.get(error, :residual),
iterations: Map.get(error, :iterations, 0)
}
%{state | last_meta: meta}
end
end
defp get_current_positions(state) do
case Map.get(state.robot.joints, state.target_link) do
nil ->
%{}
_joint ->
state.robot.joints
|> Map.keys()
|> Enum.reduce(%{}, fn joint_name, acc ->
pos = RobotState.get_joint_position(state.robot_state, joint_name)
Map.put(acc, joint_name, pos)
end)
end
end
defp send_hold_commands(state) do
Enum.each(state.robot.actuators, fn {name, _info} ->
BB.Actuator.hold!(state.robot_module, name)
end)
end
defp schedule_tick(update_rate) do
interval = div(1000, update_rate)
Process.send_after(self(), :tick, interval)
end
end