Skip to content

improvement!: match bb's synchronous set_position changes - #86

Merged
jimsynz merged 3 commits into
mainfrom
sync-set-position
Aug 22, 2026
Merged

improvement!: match bb's synchronous set_position changes#86
jimsynz merged 3 commits into
mainfrom
sync-set-position

Conversation

@jimsynz

@jimsynz jimsynz commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Matches bb_ik_dls to the breaking changes released in bb 0.30.0
(beam-bots/bb#235). Three separate things needed doing, in three commits.

The bb floor moves to ~> 0.30, in the first commit rather than separately.
That is load-bearing, not housekeeping: set_position/4 existed in 0.28 and
0.29 as a publish that discards options it does not recognise, so the
delivery: :direct and :timeout this PR documents and forwards would compile
clean against an older bb and silently take the wrong transport, or silently
wait forever. A removed function fails at compile time; an ignored option fails
in the workshop.

1. Types and docs that bb's new return shapes made wrong

BB.IK.DLS.Motion is a thin wrapper: every one of its functions delegates
straight to BB.Motion, so its @specs and @doc examples only ever describe
whatever bb returns. They described something bb no longer returns:

  • move_to_multi/3 and solve_multi/3 documented {:error, failed_link, reason, results}. A solve failure is now {:error, %BB.Error.Kinematics.MultiFailed{failed_link:, error:, partial_results:}},
    and an actuator refusal comes back unwrapped as {:error, error} — not
    wrapped in MultiFailed, because nothing about it is kinematic.
  • move_to/4 and solve/4 were declared {:ok, meta()} | {:error, atom(), meta()}. bb returns {:error, error} with a BB.Error struct. Dialyzer
    can't catch a spec that is merely too narrow, which is why this survived.
  • :delivery documented a :sync mode that no longer exists — :pubsub is
    synchronous now.

The four result types are now aliases for BB.Motion's own
(Motion.motion_result() and friends) rather than restatements, so they can't
drift again on their own. :timeout is documented and passed through, since
under the now-blocking :pubsub path it is the only way to bound the wait.

2. Tracker.stop/1 returned the wrong thing

Not a doc fix — a real regression. The tracker recorded its last_positions
with BB.Robot.State.get_all_configurations/1. That used to be a reasonable
proxy for "what the tracker last commanded", because BB.Motion wrote the
solved positions into the state. It no longer does, so on a robot without
position feedback stop/1 returns the configuration the robot started in,
and on a robot with feedback it returns a mid-flight measurement of every joint
rather than the tracker's own answer.

The tracker now solves and sends as two steps — BB.Motion.solve_only/4 then
BB.Motion.send_positions/3 — and keeps the configuration the solver returned.
BB.Motion.move_to/4 can't be used for this because it doesn't hand back the
positions it solved. Consequences worth knowing:

  • The per-tick telemetry span changes from [:bb, :motion, :move_to] to
    [:bb, :motion, :solve] plus [:bb, :motion, :send_positions].
  • An actuator refusal no longer clobbers last_meta with a synthesised
    %{residual: nil, iterations: 0}. The solve succeeded; the refusal is logged
    and the solve's real meta is kept. (Only reachable under :pubsub
    :direct always answers :ok.)
  • The cached BB.Robot.State handle is gone from the tracker's state; it was
    only there to be read.

This tracker had no test coverage at all, so the commit also adds the mock
actuator and tracker test the sibling FABRIK package already carries. The
stop/1 test fails against the old bookkeeping — verified by reverting just
that expression and re-running.

3. Position feedback is now a documented prerequisite for tracking

Every solve seeds from the robot's current configuration, and bb now writes
that from BB.Message.Sensor.JointState messages and from nothing else. A
tracker pointed at joints that nothing reports on re-solves from the same
frozen pose on every tick.

I looked at whether that needs a code change here and concluded it doesn't. It
doesn't diverge or oscillate — a solve is a function of its seed and its
target, so a frozen seed still yields an absolute joint configuration that
reaches the target. What it loses is the warm start: more iterations per tick,
worse convergence near singularities, and — the one that actually bites —
freedom to change solution branch between ticks, because the answer stops
depending on the path taken to get there. A redundant arm can be asked to swing
between two equally valid postures inside one tick period. DLS has one extra
wrinkle: :step_size caps how far the configuration moves per iteration, so
:max_iterations bounds how far the answer can travel from its seed at all,
which a warm start would never come near.

I measured the branch-change effect on FABRIK rather than DLS (a three-revolute
planar arm, 0.25 m + 0.25 m + 0.15 m, following a 0.2 m circle straddling its
own base over 61 ticks: a largest single-tick joint step of 5.25 rad frozen
against 1.73 rad warm-started). It is quoted here rather than in the moduledoc
because it belongs to the arm and the solver it was taken on, and would rot the
moment either solver's defaults changed. The moduledoc describes the mechanism
instead.

The fix belongs to the robot, not to this package: an encoder, a driver
declaring :position_feedback through c:BB.Actuator.capabilities/1, or
BB.Sensor.OpenLoopPositionEstimator. bb's new
BB.Dsl.Verifiers.ValidatePositionFeedback warns at compile time about a
driven joint with none of the three, so this can't go unnoticed the way it
would have before — which is what settled it for me. Re-adding a private
warm-start cache in each solver package would have quietly re-introduced the
dead reckoning bb just deliberately removed, in two places, divergently.

The prerequisite is documented in BB.IK.DLS.Tracker's moduledoc,
usage-rules.md and AGENTS.md. solve_and_update/6 gained the matching
caveat: it's right for a state of your own from BB.Robot.State.new/1, and
wrong for a running robot's.

The README's motion and tracker examples were also missing the required
:source_link and so could not have run as written; fixed while correcting
their return shapes.

Verification

mix check --no-retry against hex bb 0.30.0 — no BB_VERSION, exactly what
CI does — is green on all nine tools, from a clean baseline on main.

mix.lock pins bb 0.30.0 and nothing else. mix deps.update bb also wanted
to carry localize, nx and req along; bb 0.30.0 asks only for localize ~> 1.0 and nx ~> 0.10, both of which the existing pins already satisfy, and
req is not a bb dependency at all, so those three are restored to their
previous versions and left for a dep sweep.

…ptions

Requires bb 0.30.

`BB.IK.DLS.Motion` delegates every function straight to `BB.Motion`, so
its `@spec`s and `@doc` examples only ever describe what bb returns — and bb
no longer returns what they described.

`move_to_multi/3` and `solve_multi/3` documented `{:error, failed_link,
reason, results}`. A solve failure is now a single
`%BB.Error.Kinematics.MultiFailed{}` carrying the failed link, the underlying
error and the results that preceded it, and an actuator's refusal arrives
unwrapped, because nothing about it is kinematic. `move_to/4` and `solve/4`
were declared `{:error, atom(), meta()}`, which bb has not returned for
several versions; dialyzer says nothing about a spec that is merely too
narrow, which is how it survived.

The four result types are now aliases for `BB.Motion`'s own rather than
restatements of them, so they cannot drift again independently.

`:delivery` documented a `:sync` mode that no longer exists — `:pubsub` is
synchronous now — and gains `:timeout`, which is the only way to bound a
wait that has become blocking. `BB.IK.DLS.Tracker` takes and forwards it
too: it runs a periodic loop, so a `:pubsub` command that outlives its
timeout takes the tracker down with it.

The floor moves to `~> 0.30` in this commit rather than separately, because
nothing here fails loudly against an older bb. `set_position/4` existed in
0.28 and 0.29 as a publish that discards options it does not recognise, so
`delivery: :direct` and `:timeout` would compile clean and silently take the
wrong transport, or silently wait forever. A removed function fails at
compile time; an ignored option fails in the workshop.
`Tracker.stop/1` documents that it returns the positions tracking arrived at,
and it read them with `BB.Robot.State.get_all_configurations/1`. That was a
fair proxy while `BB.Motion` wrote each solve into the robot's state. It no
longer does — state is written from `BB.Message.Sensor.JointState` messages
and from nothing else — so on a robot without position feedback `stop/1`
hands back the configuration the robot *booted* in, and on one with feedback
it hands back a mid-flight measurement of every joint rather than the
tracker's own answer.

The tracker now solves and sends as two steps, `BB.Motion.solve_only/4` then
`BB.Motion.send_positions/3`, and keeps what the solver returned.
`BB.Motion.move_to/4` cannot serve here: it does both in one call but does
not hand back the positions it solved.

Three things follow from the split. The per-tick telemetry span becomes
`[:bb, :motion, :solve]` plus `[:bb, :motion, :send_positions]` rather than
`[:bb, :motion, :move_to]`. An actuator's refusal no longer overwrites
`last_meta` with a synthesised `%{residual: nil, iterations: 0}` — the solve
succeeded, so its real meta is kept and the refusal is logged instead; that
path is only reachable under `:pubsub`, since `:direct` always answers `:ok`.
And the cached `BB.Robot.State` handle leaves the tracker's state, having
only ever been there to be read.

The tracker had no test coverage at all, so this adds the mock actuator and
the tracker test the sibling solver package already carries, including one
that fails against the old bookkeeping.
Every solve seeds from the robot's current configuration, and bb now writes
that from `BB.Message.Sensor.JointState` messages and from nothing else. A
tracker pointed at joints nothing reports on re-solves from the same frozen
pose on every tick.

It does not diverge — a solve is a function of its seed and its target, so a
frozen seed still yields an absolute joint configuration that reaches the
target. What it loses is the warm start: more iterations per tick, worse
convergence near singularities, and, since `:step_size` caps how far the
configuration moves per iteration, `:max_iterations` bounds how far the
answer can travel from its seed at all. The one that bites is that the answer
stops depending on the path taken to get there, which leaves the arm free to
change solution branch between ticks — a redundant arm can be asked to swing
between two equally valid postures inside a single tick period.

The fix belongs to the robot rather than to this package — an encoder, a
driver declaring `:position_feedback`, or
`BB.Sensor.OpenLoopPositionEstimator` — and bb warns at compile time about a
driven joint with none of the three, so it cannot go unnoticed. Keeping a
private warm-start cache here instead would quietly reinstate the dead
reckoning bb just removed, once per solver package.

`solve_and_update/6` gains the matching caveat: writing a solved
configuration into a *running* robot's state claims its joints have arrived
somewhere they have only been asked to go. It remains right for a state of
your own, stepped through a planned motion.

The tracker's test robot now declares an estimator per joint, as a real
open-loop arm would, rather than tripping the new compile-time warning. The
README's motion and tracker examples were also missing the required
`:source_link`, so they could not have run as written.
@jimsynz
jimsynz force-pushed the sync-set-position branch from be46d27 to 7924f22 Compare August 21, 2026 23:09
@jimsynz
jimsynz merged commit 3e186d3 into main Aug 22, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant