Skip to content

UnitFrame

The one value object that makes "runs but does not move" impossible.

from strands_robots.core.units import Space, UnitFrame

policy_frame = UnitFrame(Space.DEGREES, dof=6)    # scale defaults to pi/180
arm_frame = UnitFrame(Space.RADIANS, dof=6)
policy_frame.compatible_with(arm_frame)           # False: not the same space
policy_frame.convertible_to(arm_frame)            # True: the scalar ladder converts in the runner
UnitFrame(Space.NORMALIZED, dof=6).convertible_to(arm_frame)   # False: normalized is a per-joint map
field meaning
space normalized, degrees, radians, cartesian, ticks, velocity
dof how many values one action carries
scale, offset the linear map to SI radians for degrees/radians (si = (value + offset) * scale; degrees default pi/180)

Every Policy declares the frame it emits (action_frame). Every Driver declares the frame it consumes (native_frame). Robot.preflight(policy) compares the two:

policy -> robot result
same space, same dof accepted
degrees <-> radians (the scalar ladder) accepted, converted by the runner
normalized -> any joint frame accepted when every joint has limits: [-1, 1] maps onto each joint's (lo, hi) from Robot.limits(); refused FRAME_MISMATCH naming the joints without limits
ticks: the servo drivers' own frame a policy emits ticks only for a robot whose driver consumes ticks (mode="twin"/real servo arms); no scalar conversion exists
no action_frame on a frame_agnostic policy (the mock) accepted: it adopts the robot's frame at preflight
different dof refused: DOF_MISMATCH, names both counts
cartesian <-> joint refused: FRAME_MISMATCH, names both spaces

A refusal happens at setup, as a typed RefusalError, not as a motionless arm mid-rollout. compatible_with is same space and dof; convertible_to is the scalar ladder; to_si and from_si do that conversion (normalized, ticks, cartesian and velocity refuse them); describe() prints radians[6]. The six contracts are here.