Skip to content

feat(skills): typed 2D Odometry state for skills — quaternions kept as legacy - #516

Merged
theo-michel merged 10 commits into
mainfrom
theo/skills-odometry-state
Jul 10, 2026
Merged

feat(skills): typed 2D Odometry state for skills — quaternions kept as legacy#516
theo-michel merged 10 commits into
mainfrom
theo/skills-odometry-state

Conversation

@theo-michel

@theo-michel theo-michel commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Why

RobotState(RobotStateType.LAST_ODOM) handed skills a raw-message dict: nested pose.pose with a quaternion orientation, plus a bolted-on theta_degrees. For a differential-drive base on flat ground that's the wrong shape — every consumer immediately dug out (x, y, yaw) — and the docs promised velocity data that was never actually injected (and showed attribute access that never worked on the dict).

What

  • New innate.Odometry frozen dataclass — the state a skill author actually wants:
    • x, y (meters, odom frame), theta (radians, CCW, wrapped to (-pi, pi])
    • theta_degrees property (consistent with navigate_to_position / turn_in_place degree-based APIs) and position(x, y)
    • linear_velocity / angular_velocity from the twist — newly exposed
    • stamp (seconds) for staleness checks, frame_id / child_frame_id
    • lives in a ROS-free module (brain_client/skills/odometry.py) so it imports and tests without rclpy
  • Escape hatch: odom.raw carries the complete nav_msgs/Odometry as plain data with rosbridge-style keys — real quaternion, z, pose/twist covariances, full twist — so power users (filtering, fusion, non-flat ground) aren't boxed in by the flat 2D view
  • RobotStateProvider builds an Odometry from the ROS message instead of serializing the dict
  • move_straight / turn_in_place read the attributes
  • Backwards compat: dict-style access from ≤0.6.x user skills (odom["theta_degrees"], odom["pose"]["pose"]["position"]) still works via a deprecated __getitem__ shim, served from raw (the real message data); a yaw-only reconstruction covers hand-built instances without raw

Testing

  • Verified locally with a throwaway pytest suite covering the attribute API, the raw passthrough, and the legacy mapping protocol (all passed); not kept in the tree
  • ruff check / ruff format / pre-commit clean

Docs PR: innate-inc/docs#19

LAST_ODOM used to inject a raw-message dict (nested pose + quaternion
orientation, with a bolted-on theta_degrees). Skills on a diff-drive base
only ever want the flat 2D pose, and the docs promised velocity data that
was never provided.

- new innate.Odometry frozen dataclass: x, y, theta (rad) +
  theta_degrees/position shorthands, body velocities from the twist,
  stamp and frame ids. ROS-free module so it tests without rclpy.
- RobotStateProvider injects Odometry instead of the dict
- move_straight / turn_in_place use the attributes
- dict-style access from <=0.6.x skills still works via a deprecated
  __getitem__ shim (legacy shape reconstructed, incl. the quaternion);
  pinned in test_odometry_state.py in the fast no-ROS bucket
@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes odometry state for skills from a raw dict to a typed 2D object. The main changes are:

  • New Odometry dataclass with pose, yaw, velocity, timestamp, frame data, and raw-message access.
  • RobotStateProvider now injects Odometry for LAST_ODOM.
  • Legacy dict-style odometry access is kept through a read-only mapping shim.
  • move_straight and turn_in_place now use the typed odometry attributes.
  • Odometry is exported from the innate namespace.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
ros2_ws/src/brain/brain_client/brain_client/skills/odometry.py Adds the typed odometry object, lazy raw conversion, and legacy mapping compatibility.
ros2_ws/src/brain/brain_client/brain_client/skills/robot_state.py Builds and injects typed odometry snapshots from ROS odometry messages.
ros2_ws/src/brain/brain_client/innate/init.py Exports Odometry from the public innate namespace.
workspace/innate_skills/move_straight.py Reads current position through the typed odometry API.
workspace/innate_skills/turn_in_place.py Reads current heading through the typed odometry API.

Reviews (8): Last reviewed commit: "fix(skills): enforce theta wrap in __pos..." | Re-trigger Greptile

Comment thread ros2_ws/src/brain/brain_client/brain_client/skills/odometry.py Outdated
Comment thread ros2_ws/src/brain/brain_client/innate/__init__.py
The flat 2D pose covers the common case; skills doing their own filtering
or fusion get the complete nav_msgs/Odometry as plain data (real
quaternion, z, covariances, full twist) on Odometry.raw. The legacy
dict shim now serves that real data instead of a yaw-reconstructed
quaternion (reconstruction kept only for raw-less hand-built instances).
…0.3.0)

The legacy dict shape was introduced with the RobotState descriptor API in
0.3.0 and shipped unchanged through 0.6.0; name that range in the
deprecation warning, shim comment, test, and docs instead of the vaguer
'up to 0.6.x'.
…oval)

Per decision to keep backwards compat indefinitely: state explicitly in the
shim docstring and the docs Note that dict-style access is not scheduled for
removal, so a future maintainer doesn't delete it and old skills never break.
…tems/values

Review follow-up (Greptile): the old LAST_ODOM value was a real dict, so
skills could use defensive access (odom.get('pose'), 'theta_degrees' in
odom, .keys()) that bracket-only __getitem__ didn't cover. All mapping
methods now delegate to one _legacy_mapping() (raw or reconstructed +
theta_degrees) and warn once per call. __iter__ deliberately omitted --
an iterable dataclass invites accidental tuple-unpacking. Also clarified
that the ROS-free property is this module's, not the innate namespace's.
Comment thread ros2_ws/src/brain/brain_client/brain_client/skills/odometry.py Outdated
…/__bool__

Review follow-up: old skills can also enumerate the odom dict directly
(for k in odom, list(odom), dict(odom)), so provide the full read-only
mapping protocol rather than a partial one. Explicit __bool__ keeps the
documented 'if self.odom:' None-check warning-free -- __len__ would
otherwise define truthiness and fire the deprecation warning at 50 Hz.
@theo-michel theo-michel changed the title feat(skills): typed 2D Odometry state for skills — no more quaternions feat(skills): typed 2D Odometry state for skills — quaternions kept as legacy Jul 10, 2026
…acy shape

Review fixes for PR #516:

- raw is now a cached_property built lazily from raw_source (the ROS
  message, duck-typed so the module stays ROS-free). The 50 Hz injection
  path no longer materializes the full rosbridge dict (twist + two
  36-float covariance copies) that no current skill reads.
- raw_source is compare=False, so ==/hash use only the 2D snapshot
  fields; production instances (which carry a dict/message) are no
  longer unhashable while hand-built ones were hashable.
- Legacy dict access now projects to exactly the 0.3.0-0.6.x injected
  shape ({header, child_frame_id, pose.pose, theta_degrees}) on both
  construction paths, instead of leaking twist/covariance keys the old
  dict never had when raw was present. Memoized, so dict(odom)/{**odom}
  builds the mapping once rather than once per key.
Comment thread ros2_ws/src/brain/brain_client/brain_client/skills/odometry.py Outdated
Comment thread ros2_ws/src/brain/brain_client/brain_client/skills/odometry.py Outdated
theta's docstring promised a wrapped angle but the dataclass stored
whatever it was given, so a hand-built Odometry(theta=10.0) silently
violated the contract that turn_in_place's heading math relies on.
__post_init__ now normalizes out-of-range values via atan2(sin, cos)
(guarded, so the 50 Hz injected path -- already atan2 output -- stays
trig-free and bit-exact). Range documented as [-pi, pi], which is what
atan2 actually returns: the old (-pi, pi] claim was wrong even on the
real path (atan2 can return -pi).
@theo-michel
theo-michel merged commit 33c5153 into main Jul 10, 2026
3 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.

2 participants