Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .credo.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: 2026 James Harton <james.harton@alembic.com.au>
#
# SPDX-License-Identifier: Apache-2.0

%{
configs: [
%{
name: "default",
files: %{
included: ["lib/", "src/", "test/", "web/", "apps/*/lib/", "apps/*/src/"],
# Generated by `mix localize.unit.gen_conversions`; its style is the
# generator's, not ours, and any edit here is lost on regeneration.
excluded: [~r"/_build/", ~r"/deps/", "lib/bb/unit/conversions.ex"]
},
strict: true
}
]
}
12 changes: 12 additions & 0 deletions .dialyzer_ignore.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# SPDX-FileCopyrightText: 2026 James Harton <james.harton@alembic.com.au>
#
# SPDX-License-Identifier: Apache-2.0

[
# `BB.Unit.Conversions` is generated by `mix localize.unit.gen_conversions`
# and cannot be edited here. Its `parse_identifier/1` keeps a catch-all
# clause for a parser failure that dialyzer proves unreachable — unknown
# identifiers are rejected later, during decomposition, not by the parser.
# Reported upstream; drop this entry once the generator stops emitting it.
{"lib/bb/unit/conversions.ex", :pattern_match_cov}
]
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,10 @@ When adding a new payload, pick the convention that matches its shape rather tha

## Key Patterns

- Units: Use `Localize.Unit` throughout DSL, converted to floats (SI) in Robot struct
- Units: Use `BB.Unit` throughout DSL, converted to floats (SI) in Robot struct.
Conversion tables live in `lib/bb/unit/conversions.ex`, generated from CLDR by
`mix localize.unit.gen_conversions` — Localize is a `:dev`-only dependency, so
nothing ships to a target but the generated file
- Transforms: 4x4 matrices in `BB.Math.Transform`, angles in radians
- Process registration: Uses Registry with `:via` tuples, names must be globally unique per robot
- DSL entities are structs in `lib/bb/dsl/` matching entity names
Expand Down
54 changes: 27 additions & 27 deletions documentation/dsls/DSL-BB.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,12 @@ Specifies where the link's center of mass is located, relative to the link's ref

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`roll`](#topology-link-inertial-origin-roll){: #topology-link-inertial-origin-roll } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the `x` axis |
| [`pitch`](#topology-link-inertial-origin-pitch){: #topology-link-inertial-origin-pitch } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the `y` axis |
| [`yaw`](#topology-link-inertial-origin-yaw){: #topology-link-inertial-origin-yaw } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the `z` axis |
| [`x`](#topology-link-inertial-origin-x){: #topology-link-inertial-origin-x } | `any` | `Localize.Unit.new!(0, "meter")` | translation along the `x` axis |
| [`y`](#topology-link-inertial-origin-y){: #topology-link-inertial-origin-y } | `any` | `Localize.Unit.new!(0, "meter")` | translation along the `y` axis |
| [`z`](#topology-link-inertial-origin-z){: #topology-link-inertial-origin-z } | `any` | `Localize.Unit.new!(0, "meter")` | translation along the `z` axis |
| [`roll`](#topology-link-inertial-origin-roll){: #topology-link-inertial-origin-roll } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the `x` axis |
| [`pitch`](#topology-link-inertial-origin-pitch){: #topology-link-inertial-origin-pitch } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the `y` axis |
| [`yaw`](#topology-link-inertial-origin-yaw){: #topology-link-inertial-origin-yaw } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the `z` axis |
| [`x`](#topology-link-inertial-origin-x){: #topology-link-inertial-origin-x } | `any` | `BB.Unit.new!(0, "meter")` | translation along the `x` axis |
| [`y`](#topology-link-inertial-origin-y){: #topology-link-inertial-origin-y } | `any` | `BB.Unit.new!(0, "meter")` | translation along the `y` axis |
| [`z`](#topology-link-inertial-origin-z){: #topology-link-inertial-origin-z } | `any` | `BB.Unit.new!(0, "meter")` | translation along the `z` axis |



Expand Down Expand Up @@ -444,12 +444,12 @@ The refrence frame of the visual element with respect to the reference frame of

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`roll`](#topology-link-visual-origin-roll){: #topology-link-visual-origin-roll } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the `x` axis |
| [`pitch`](#topology-link-visual-origin-pitch){: #topology-link-visual-origin-pitch } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the `y` axis |
| [`yaw`](#topology-link-visual-origin-yaw){: #topology-link-visual-origin-yaw } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the `z` axis |
| [`x`](#topology-link-visual-origin-x){: #topology-link-visual-origin-x } | `any` | `Localize.Unit.new!(0, "meter")` | translation along the `x` axis |
| [`y`](#topology-link-visual-origin-y){: #topology-link-visual-origin-y } | `any` | `Localize.Unit.new!(0, "meter")` | translation along the `y` axis |
| [`z`](#topology-link-visual-origin-z){: #topology-link-visual-origin-z } | `any` | `Localize.Unit.new!(0, "meter")` | translation along the `z` axis |
| [`roll`](#topology-link-visual-origin-roll){: #topology-link-visual-origin-roll } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the `x` axis |
| [`pitch`](#topology-link-visual-origin-pitch){: #topology-link-visual-origin-pitch } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the `y` axis |
| [`yaw`](#topology-link-visual-origin-yaw){: #topology-link-visual-origin-yaw } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the `z` axis |
| [`x`](#topology-link-visual-origin-x){: #topology-link-visual-origin-x } | `any` | `BB.Unit.new!(0, "meter")` | translation along the `x` axis |
| [`y`](#topology-link-visual-origin-y){: #topology-link-visual-origin-y } | `any` | `BB.Unit.new!(0, "meter")` | translation along the `y` axis |
| [`z`](#topology-link-visual-origin-z){: #topology-link-visual-origin-z } | `any` | `BB.Unit.new!(0, "meter")` | translation along the `z` axis |



Expand Down Expand Up @@ -505,12 +505,12 @@ The refrence frame of the collision element, relative to the reference frame of

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`roll`](#topology-link-collision-origin-roll){: #topology-link-collision-origin-roll } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the `x` axis |
| [`pitch`](#topology-link-collision-origin-pitch){: #topology-link-collision-origin-pitch } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the `y` axis |
| [`yaw`](#topology-link-collision-origin-yaw){: #topology-link-collision-origin-yaw } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the `z` axis |
| [`x`](#topology-link-collision-origin-x){: #topology-link-collision-origin-x } | `any` | `Localize.Unit.new!(0, "meter")` | translation along the `x` axis |
| [`y`](#topology-link-collision-origin-y){: #topology-link-collision-origin-y } | `any` | `Localize.Unit.new!(0, "meter")` | translation along the `y` axis |
| [`z`](#topology-link-collision-origin-z){: #topology-link-collision-origin-z } | `any` | `Localize.Unit.new!(0, "meter")` | translation along the `z` axis |
| [`roll`](#topology-link-collision-origin-roll){: #topology-link-collision-origin-roll } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the `x` axis |
| [`pitch`](#topology-link-collision-origin-pitch){: #topology-link-collision-origin-pitch } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the `y` axis |
| [`yaw`](#topology-link-collision-origin-yaw){: #topology-link-collision-origin-yaw } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the `z` axis |
| [`x`](#topology-link-collision-origin-x){: #topology-link-collision-origin-x } | `any` | `BB.Unit.new!(0, "meter")` | translation along the `x` axis |
| [`y`](#topology-link-collision-origin-y){: #topology-link-collision-origin-y } | `any` | `BB.Unit.new!(0, "meter")` | translation along the `y` axis |
| [`z`](#topology-link-collision-origin-z){: #topology-link-collision-origin-z } | `any` | `BB.Unit.new!(0, "meter")` | translation along the `z` axis |



Expand Down Expand Up @@ -1004,12 +1004,12 @@ This is the transform from the parent link to the child link. The joint is locat

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`roll`](#topology-joint-origin-roll){: #topology-joint-origin-roll } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the `x` axis |
| [`pitch`](#topology-joint-origin-pitch){: #topology-joint-origin-pitch } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the `y` axis |
| [`yaw`](#topology-joint-origin-yaw){: #topology-joint-origin-yaw } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the `z` axis |
| [`x`](#topology-joint-origin-x){: #topology-joint-origin-x } | `any` | `Localize.Unit.new!(0, "meter")` | translation along the `x` axis |
| [`y`](#topology-joint-origin-y){: #topology-joint-origin-y } | `any` | `Localize.Unit.new!(0, "meter")` | translation along the `y` axis |
| [`z`](#topology-joint-origin-z){: #topology-joint-origin-z } | `any` | `Localize.Unit.new!(0, "meter")` | translation along the `z` axis |
| [`roll`](#topology-joint-origin-roll){: #topology-joint-origin-roll } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the `x` axis |
| [`pitch`](#topology-joint-origin-pitch){: #topology-joint-origin-pitch } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the `y` axis |
| [`yaw`](#topology-joint-origin-yaw){: #topology-joint-origin-yaw } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the `z` axis |
| [`x`](#topology-joint-origin-x){: #topology-joint-origin-x } | `any` | `BB.Unit.new!(0, "meter")` | translation along the `x` axis |
| [`y`](#topology-joint-origin-y){: #topology-joint-origin-y } | `any` | `BB.Unit.new!(0, "meter")` | translation along the `y` axis |
| [`z`](#topology-joint-origin-z){: #topology-joint-origin-z } | `any` | `BB.Unit.new!(0, "meter")` | translation along the `z` axis |



Expand Down Expand Up @@ -1040,9 +1040,9 @@ What it means depends on the joint type:

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`roll`](#topology-joint-axis-roll){: #topology-joint-axis-roll } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the X axis |
| [`pitch`](#topology-joint-axis-pitch){: #topology-joint-axis-pitch } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the Y axis |
| [`yaw`](#topology-joint-axis-yaw){: #topology-joint-axis-yaw } | `any` | `Localize.Unit.new!(0, "degree")` | rotation around the Z axis |
| [`roll`](#topology-joint-axis-roll){: #topology-joint-axis-roll } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the X axis |
| [`pitch`](#topology-joint-axis-pitch){: #topology-joint-axis-pitch } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the Y axis |
| [`yaw`](#topology-joint-axis-yaw){: #topology-joint-axis-yaw } | `any` | `BB.Unit.new!(0, "degree")` | rotation around the Z axis |



Expand Down
6 changes: 3 additions & 3 deletions lib/bb/dsl/axis.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ defmodule BB.Dsl.Axis do
@type t :: %__MODULE__{
__identifier__: any,
__spark_metadata__: Entity.spark_meta(),
roll: Localize.Unit.t(),
pitch: Localize.Unit.t(),
yaw: Localize.Unit.t()
roll: BB.Unit.t(),
pitch: BB.Unit.t(),
yaw: BB.Unit.t()
}
end
2 changes: 1 addition & 1 deletion lib/bb/dsl/box.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ defmodule BB.Dsl.Box do
y: nil,
z: nil

alias Localize.Unit
alias BB.Unit
alias Spark.Dsl.Entity

@type t :: %__MODULE__{
Expand Down
2 changes: 1 addition & 1 deletion lib/bb/dsl/capsule.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ defmodule BB.Dsl.Capsule do
radius: nil,
height: nil

alias Localize.Unit
alias BB.Unit
alias Spark.Dsl.Entity

@type t :: %__MODULE__{
Expand Down
2 changes: 1 addition & 1 deletion lib/bb/dsl/cylinder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ defmodule BB.Dsl.Cylinder do
radius: nil,
height: nil

alias Localize.Unit
alias BB.Unit
alias Spark.Dsl.Entity

@type t :: %__MODULE__{
Expand Down
4 changes: 2 additions & 2 deletions lib/bb/dsl/dynamics.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ defmodule BB.Dsl.Dynamics do
@type t :: %__MODULE__{
__identifier__: any,
__spark_metadata__: Entity.spark_meta(),
damping: nil | Localize.Unit.t(),
friction: nil | Localize.Unit.t()
damping: nil | BB.Unit.t(),
friction: nil | BB.Unit.t()
}
end
6 changes: 3 additions & 3 deletions lib/bb/dsl/estimator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ defmodule BB.Dsl.Estimator do
child_spec: child_spec,
inputs: [Input.t()],
outputs: [Output.t()],
sync_tolerance: nil | Localize.Unit.t(),
latency_budget: nil | Localize.Unit.t(),
lost_after: nil | Localize.Unit.t(),
sync_tolerance: nil | BB.Unit.t(),
latency_budget: nil | BB.Unit.t(),
lost_after: nil | BB.Unit.t(),
recover_after: pos_integer(),
on_degraded: nil | atom(),
on_lost: nil | atom(),
Expand Down
12 changes: 6 additions & 6 deletions lib/bb/dsl/inertia.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ defmodule BB.Dsl.Inertia do
@type t :: %__MODULE__{
__identifier__: any,
__spark_metadata__: Entity.spark_meta(),
ixx: Localize.Unit.t(),
iyy: Localize.Unit.t(),
izz: Localize.Unit.t(),
ixy: Localize.Unit.t(),
ixz: Localize.Unit.t(),
iyz: Localize.Unit.t()
ixx: BB.Unit.t(),
iyy: BB.Unit.t(),
izz: BB.Unit.t(),
ixy: BB.Unit.t(),
ixz: BB.Unit.t(),
iyz: BB.Unit.t()
}
end
2 changes: 1 addition & 1 deletion lib/bb/dsl/inertial.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ defmodule BB.Dsl.Inertial do
__identifier__: any,
__spark_metadata__: Entity.spark_meta(),
origin: nil | Origin.t(),
mass: Localize.Unit.t(),
mass: BB.Unit.t(),
inertia: Inertia.t()
}
end
10 changes: 5 additions & 5 deletions lib/bb/dsl/limit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ defmodule BB.Dsl.Limit do
@type t :: %__MODULE__{
__identifier__: any,
__spark_metadata__: Entity.spark_meta(),
lower: nil | Localize.Unit.t(),
upper: nil | Localize.Unit.t(),
effort: Localize.Unit.t(),
velocity: Localize.Unit.t(),
acceleration: nil | Localize.Unit.t()
lower: nil | BB.Unit.t(),
upper: nil | BB.Unit.t(),
effort: BB.Unit.t(),
velocity: BB.Unit.t(),
acceleration: nil | BB.Unit.t()
}
end
12 changes: 6 additions & 6 deletions lib/bb/dsl/origin.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ defmodule BB.Dsl.Origin do
@type t :: %__MODULE__{
__identifier__: any,
__spark_metadata__: Entity.spark_meta(),
roll: Localize.Unit.t(),
pitch: Localize.Unit.t(),
yaw: Localize.Unit.t(),
x: Localize.Unit.t(),
y: Localize.Unit.t(),
z: Localize.Unit.t()
roll: BB.Unit.t(),
pitch: BB.Unit.t(),
yaw: BB.Unit.t(),
x: BB.Unit.t(),
y: BB.Unit.t(),
z: BB.Unit.t()
}
end
2 changes: 1 addition & 1 deletion lib/bb/dsl/sphere.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ defmodule BB.Dsl.Sphere do
__spark_metadata__: nil,
radius: nil

alias Localize.Unit
alias BB.Unit
alias Spark.Dsl.Entity

@type t :: %__MODULE__{
Expand Down
2 changes: 1 addition & 1 deletion lib/bb/dsl/transmission.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ defmodule BB.Dsl.Transmission do
__identifier__: any,
__spark_metadata__: Entity.spark_meta(),
reduction: number,
offset: nil | Localize.Unit.t(),
offset: nil | BB.Unit.t(),
reversed?: boolean
}
end
7 changes: 3 additions & 4 deletions lib/bb/dsl/uniqueness_transformer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,9 @@ defmodule BB.Dsl.UniquenessTransformer do
end
end

# `Localize.Unit` and `Decimal` structs carry a `:name` field that is not a
# DSL entity identifier — skip them so they aren't treated as named entities.
defp retrieve_names(%Localize.Unit{}, _path, names), do: names
defp retrieve_names(%Decimal{}, _path, names), do: names
# `BB.Unit` structs carry a `:name` field that is not a DSL entity
# identifier — skip them so they aren't treated as named entities.
defp retrieve_names(%BB.Unit{}, _path, names), do: names

# Estimator input/output entities use `:name` as a callback-side key
# (matched in the user's `handle_input/2` payload map and `{:reply, [...]}`
Expand Down
2 changes: 1 addition & 1 deletion lib/bb/dsl/validate_limit_units_transformer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ defmodule BB.Dsl.ValidateLimitUnitsTransformer do
do: {:cont, :ok}

defp check_field(
%Localize.Unit{} = value,
%BB.Unit{} = value,
section,
field,
expected_unit,
Expand Down
33 changes: 33 additions & 0 deletions lib/bb/error/invalid/unit.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SPDX-FileCopyrightText: 2026 James Harton <james.harton@alembic.com.au>
#
# SPDX-License-Identifier: Apache-2.0

defmodule BB.Error.Invalid.Unit do
@moduledoc """
Invalid unit identifier or incompatible unit conversion.

Raised when an identifier does not name a unit `BB.Unit.Conversions` can
convert, or when two units are compared or converted across dimensional
categories.
"""
use BB.Error,
class: :invalid,
fields: [:unit, :expected]

@type t :: %__MODULE__{
unit: String.t(),
expected: String.t() | nil
}

defimpl BB.Error.Severity do
def severity(_), do: :error
end

def message(%{unit: unit, expected: nil}) do
"`#{unit}` is not a known unit"
end

def message(%{unit: unit, expected: expected}) do
"The unit `#{unit}` is not compatible with `#{expected}`"
end
end
2 changes: 1 addition & 1 deletion lib/bb/estimator/wiring.ex
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ defmodule BB.Estimator.Wiring do

defp duration_to_ns(unit) do
unit
|> Localize.Unit.convert!("second")
|> BB.Unit.convert!("second")
|> Units.extract_float()
|> Kernel.*(1_000_000_000)
|> round()
Expand Down
6 changes: 3 additions & 3 deletions lib/bb/loop.ex
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ defmodule BB.Loop do
## Options

- `:clock` (required) - `{:rate, hertz}` or `:external`. A rate may be given
as a `Localize.Unit` in any frequency unit (`~u(100 hertz)`) or as a plain
as a `BB.Unit` in any frequency unit (`~u(100 hertz)`) or as a plain
positive number of hertz.

Building a loop does not start it; call `arm/1` once the component is ready to
Expand Down Expand Up @@ -327,9 +327,9 @@ defmodule BB.Loop do
defp period_ns(:external), do: nil
defp period_ns({:rate, rate}), do: round(@ns_per_second / hertz(rate))

defp hertz(%Localize.Unit{} = rate) do
defp hertz(%BB.Unit{} = rate) do
rate
|> Localize.Unit.convert!("hertz")
|> BB.Unit.convert!("hertz")
|> Units.extract_float()
|> hertz()
end
Expand Down
Loading
Loading