-
Notifications
You must be signed in to change notification settings - Fork 37
feat(skills): typed 2D Odometry state for skills — quaternions kept as legacy #516
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
4d1847f
feat(skills): typed 2D Odometry state for skills -- no more quaternions
theo-michel fa939ec
feat(skills): odom.raw escape hatch -- full message data for power users
theo-michel 64dad84
docs(skills): pin odom dict-format deprecation to its intro release (…
theo-michel 4b811f1
docs(skills): mark odom dict shim as a permanent compat layer (no rem…
theo-michel b257571
fix(skills): full legacy mapping surface on Odometry -- get/in/keys/i…
theo-michel f5d710e
fix(skills): complete the legacy mapping protocol -- __iter__/__len__…
theo-michel cafa175
test(skills): drop test_odometry_state.py
theo-michel 331c76f
docs(skills): drop stale test-bucket reference from odometry docstring
theo-michel 65d6f9c
fix(skills): lazy odom.raw + hashable Odometry + path-independent leg…
theo-michel 1241019
fix(skills): enforce theta wrap in __post_init__; document [-pi, pi]
theo-michel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
188 changes: 188 additions & 0 deletions
188
ros2_ws/src/brain/brain_client/brain_client/skills/odometry.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # Copyright (c) 2026 Innate Inc | ||
| """Skill-facing odometry state. | ||
|
|
||
| MARS is a differential-drive base on flat ground, so its pose is fully | ||
| described by (x, y, yaw) — skills get that directly instead of the raw ROS | ||
| Odometry message with its quaternion orientation. This module is ROS-free on | ||
| purpose; contexts without rclpy should import it from here directly (the | ||
| `innate` namespace, where it is also exported, pulls in ROS-dependent skill | ||
| types). | ||
| """ | ||
|
|
||
| import math | ||
| import warnings | ||
| from dataclasses import dataclass, field | ||
| from functools import cached_property | ||
| from typing import Any | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class Odometry: | ||
| """A 2D odometry snapshot: pose in the odom frame plus body velocities. | ||
|
|
||
| Injected for ``RobotState(RobotStateType.LAST_ODOM)`` and refreshed at | ||
| 50 Hz while a skill runs. | ||
| """ | ||
|
|
||
| x: float | ||
| """Position along X in meters, odom frame.""" | ||
| y: float | ||
| """Position along Y in meters, odom frame.""" | ||
| theta: float | ||
| """Yaw in radians, counter-clockwise positive, wrapped to [-pi, pi].""" | ||
| linear_velocity: float = 0.0 | ||
| """Forward speed in m/s (negative when driving backward).""" | ||
| angular_velocity: float = 0.0 | ||
| """Turn rate in rad/s, counter-clockwise positive.""" | ||
| stamp: float = 0.0 | ||
| """Sensor timestamp in seconds (ROS time). float64: sub-microsecond | ||
| precision at present-day epochs; the exact integer sec/nanosec are in | ||
| ``raw``.""" | ||
| frame_id: str = "odom" | ||
| child_frame_id: str = "base_link" | ||
| raw_source: Any = field(default=None, repr=False, compare=False) | ||
| """Source for ``raw``: a rosbridge-style dict (served as-is) or a | ||
| nav_msgs/Odometry-like message (converted lazily on first access). | ||
| Excluded from ==/hash — it is provenance, not part of the 2D snapshot; | ||
| including it would also make production instances unhashable (dicts).""" | ||
|
|
||
| def __post_init__(self): | ||
| # theta's docstring is a contract (turn_in_place does heading math on | ||
| # it), so enforce it for hand-built instances too. The injected path | ||
| # already gets atan2 output; the guard keeps it trig-free at 50 Hz. | ||
| if not -math.pi <= self.theta <= math.pi: | ||
| # frozen dataclass: object.__setattr__ bypasses the immutability guard | ||
| object.__setattr__(self, "theta", math.atan2(math.sin(self.theta), math.cos(self.theta))) | ||
|
|
||
| @cached_property | ||
| def raw(self) -> dict | None: | ||
| """Escape hatch: the full nav_msgs/Odometry as plain data | ||
| (rosbridge-style keys) — real quaternion, z, covariances, full twist — | ||
| for skills that need more than the flat 2D pose. Built lazily so the | ||
| 50 Hz injection path pays nothing for it until a skill asks.""" | ||
| source = self.raw_source | ||
| if source is None or isinstance(source, dict): | ||
| return source | ||
| return _msg_to_raw(source) | ||
|
|
||
| @property | ||
| def theta_degrees(self) -> float: | ||
| """Yaw in degrees, counter-clockwise positive.""" | ||
| return math.degrees(self.theta) | ||
|
|
||
| @property | ||
| def position(self) -> tuple[float, float]: | ||
| """(x, y) in meters, odom frame.""" | ||
| return (self.x, self.y) | ||
|
|
||
| # --- legacy dict compatibility --------------------------------------- | ||
| # LAST_ODOM injected a raw-message dict from 0.3.0 through 0.6.x, so old | ||
| # skill files use dict-style access: odom["theta_degrees"], .get(), `in`, | ||
| # iteration, .keys()/.items()/.values(). The full read-only mapping | ||
| # protocol is provided so that code behaves exactly as it did on the real | ||
| # dict. Soft-deprecated (each call warns to nudge authors to the | ||
| # attributes) but kept as a permanent compatibility layer -- there is no | ||
| # scheduled removal, old skills keep working indefinitely. Do not delete. | ||
|
|
||
| def __getitem__(self, key): | ||
| return self._legacy_mapping()[key] | ||
|
|
||
| def __iter__(self): | ||
| return iter(self._legacy_mapping()) | ||
|
|
||
| def __len__(self) -> int: | ||
| return len(self._legacy_mapping()) | ||
|
|
||
| def __bool__(self) -> bool: | ||
| # without this, __len__ would define truthiness -- making the | ||
| # documented `if self.odom:` None-check fire the deprecation warning | ||
| return True | ||
|
|
||
| def get(self, key, default=None): | ||
| return self._legacy_mapping().get(key, default) | ||
|
|
||
| def __contains__(self, key) -> bool: | ||
| return key in self._legacy_mapping() | ||
|
|
||
| def keys(self): | ||
| return self._legacy_mapping().keys() | ||
|
|
||
| def items(self): | ||
| return self._legacy_mapping().items() | ||
|
|
||
| def values(self): | ||
| return self._legacy_mapping().values() | ||
|
|
||
| def _legacy_mapping(self) -> dict: | ||
| warnings.warn( | ||
| "dict-style odometry access is deprecated; use the Odometry " | ||
| "attributes instead (odom.x, odom.theta_degrees, ...) or odom.raw " | ||
| "for the full message", | ||
| DeprecationWarning, | ||
| stacklevel=3, # past the dunder/method that called us, at user code | ||
| ) | ||
| return self._legacy_dict | ||
|
|
||
| @cached_property | ||
| def _legacy_dict(self) -> dict: | ||
| """Exactly the 0.3.0-0.6.x injected shape — {header, child_frame_id, | ||
| pose.pose, theta_degrees}, no twist, no covariance — whichever way the | ||
| instance was built, so legacy access is path-independent. The extra | ||
| data ``raw`` carries stays on ``raw``. Memoized: enumeration protocols | ||
| (dict(odom), {**odom}) hit the mapping once per key.""" | ||
| base = self.raw if self.raw is not None else self._reconstructed_raw() | ||
| return { | ||
| "header": base["header"], | ||
| "child_frame_id": base["child_frame_id"], | ||
| "pose": {"pose": base["pose"]["pose"]}, | ||
| "theta_degrees": self.theta_degrees, | ||
| } | ||
|
|
||
| def _reconstructed_raw(self) -> dict: | ||
| """Legacy-shape fallback for instances built without ``raw_source`` | ||
| (hand-constructed); the quaternion carries yaw only.""" | ||
| sec = int(self.stamp) | ||
| half = self.theta / 2.0 | ||
| return { | ||
| "header": { | ||
| "stamp": {"sec": sec, "nanosec": int(round((self.stamp - sec) * 1e9))}, | ||
| "frame_id": self.frame_id, | ||
| }, | ||
| "child_frame_id": self.child_frame_id, | ||
| "pose": { | ||
| "pose": { | ||
| "position": {"x": self.x, "y": self.y, "z": 0.0}, | ||
| "orientation": {"x": 0.0, "y": 0.0, "z": math.sin(half), "w": math.cos(half)}, | ||
| } | ||
| }, | ||
| } | ||
|
|
||
|
|
||
| def _msg_to_raw(msg) -> dict: | ||
| """nav_msgs/Odometry message -> plain rosbridge-style dict. Duck-typed | ||
| (attribute access only) so this module stays ROS-free.""" | ||
| pos = msg.pose.pose.position | ||
| ori = msg.pose.pose.orientation | ||
| twist = msg.twist.twist | ||
| return { | ||
| "header": { | ||
| "stamp": {"sec": msg.header.stamp.sec, "nanosec": msg.header.stamp.nanosec}, | ||
| "frame_id": msg.header.frame_id, | ||
| }, | ||
| "child_frame_id": msg.child_frame_id, | ||
| "pose": { | ||
| "pose": { | ||
| "position": {"x": pos.x, "y": pos.y, "z": pos.z}, | ||
| "orientation": {"x": ori.x, "y": ori.y, "z": ori.z, "w": ori.w}, | ||
| }, | ||
| "covariance": list(msg.pose.covariance), | ||
| }, | ||
| "twist": { | ||
| "twist": { | ||
| "linear": {"x": twist.linear.x, "y": twist.linear.y, "z": twist.linear.z}, | ||
| "angular": {"x": twist.angular.x, "y": twist.angular.y, "z": twist.angular.z}, | ||
| }, | ||
| "covariance": list(msg.twist.covariance), | ||
| }, | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.