From e5bcb03d6d819159cdb8b9065650b327a8826ae4 Mon Sep 17 00:00:00 2001 From: ved197338 Date: Fri, 4 Sep 2026 14:09:30 +0530 Subject: [PATCH 1/2] feat(nav2): add workspace bounds and coordinate safety validation Introduce workspace boundary bounding and coordinate safety validation for Nav2 navigation tools (NavigateToPoseTool, NavigateToPoseBlockingTool, and Nav2Toolkit). When autonomous agents or LLMs propose target poses, unconstrained or out-of-bounds coordinates risk sending physical robots and simulation models into dangerous or unmapped areas. This change provides deterministic spatial bounding and numeric validation: - Adds validate_finite_coordinate, validate_workspace_bounds, and validate_pose_within_bounds in rai.tools.coordinates. - Re-exports bounds validation helpers in rai.tools.ros2.navigation.bounds. - Adds optional workspace_bounds_min and workspace_bounds_max parameters to NavigateToPoseTool, NavigateToPoseBlockingTool, and Nav2Toolkit. - Validates that target coordinates are finite numbers and within configured workspace bounds before dispatching ROS 2 action goals. - Provides comprehensive offline unit test suite with 37 test cases covering valid coordinates, bounds boundary conditions, dimension violations, and non-finite number rejection. --- src/rai_core/rai/tools/__init__.py | 9 + src/rai_core/rai/tools/coordinates.py | 137 ++++++++++++++ .../rai/tools/ros2/navigation/__init__.py | 8 + .../rai/tools/ros2/navigation/bounds.py | 27 +++ .../rai/tools/ros2/navigation/nav2.py | 39 +++- .../tools/ros2/navigation/nav2_blocking.py | 22 ++- tests/tools/test_coordinates.py | 173 ++++++++++++++++++ 7 files changed, 406 insertions(+), 9 deletions(-) create mode 100644 src/rai_core/rai/tools/coordinates.py create mode 100644 src/rai_core/rai/tools/ros2/navigation/bounds.py create mode 100644 tests/tools/test_coordinates.py diff --git a/src/rai_core/rai/tools/__init__.py b/src/rai_core/rai/tools/__init__.py index a2f5a1099..93bec1310 100644 --- a/src/rai_core/rai/tools/__init__.py +++ b/src/rai_core/rai/tools/__init__.py @@ -12,5 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +from .coordinates import ( + validate_finite_coordinate as validate_finite_coordinate, +) +from .coordinates import ( + validate_pose_within_bounds as validate_pose_within_bounds, +) +from .coordinates import ( + validate_workspace_bounds as validate_workspace_bounds, +) from .timeout import timeout as timeout from .timeout import timeout_method as timeout_method diff --git a/src/rai_core/rai/tools/coordinates.py b/src/rai_core/rai/tools/coordinates.py new file mode 100644 index 000000000..a74252478 --- /dev/null +++ b/src/rai_core/rai/tools/coordinates.py @@ -0,0 +1,137 @@ +# Copyright (C) 2026 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Coordinate and workspace bounds validation helpers for robot tools.""" + +import math +from collections.abc import Sequence + + +def validate_finite_coordinate(value: object, *, name: str = "coordinate") -> float: + """Validate that a coordinate value is a finite number. + + Args: + value: Input value to validate. + name: Name of the coordinate parameter for error reporting. + + Returns: + float: Validated finite float value. + + Raises: + ValueError: If value is a boolean, non-numeric, NaN, or infinite. + """ + if value is True or value is False or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be a finite number, got {value!r}") + + number = float(value) + if not math.isfinite(number): + raise ValueError(f"{name} must be a finite number, got {value!r}") + + return number + + +def validate_workspace_bounds( + bounds_min: Sequence[float] | None, + bounds_max: Sequence[float] | None, +) -> None: + """Validate that workspace bounding box specifications are well-formed. + + Args: + bounds_min: Optional minimum (x, y, z) coordinates. + bounds_max: Optional maximum (x, y, z) coordinates. + + Raises: + ValueError: If bounds are improperly shaped or if minimum exceeds maximum. + """ + axes = ("x", "y", "z") + + if bounds_min is not None: + if len(bounds_min) != 3: + raise ValueError( + f"bounds_min must contain exactly 3 coordinates (x, y, z), got {len(bounds_min)}" + ) + for i, val in enumerate(bounds_min): + validate_finite_coordinate(val, name=f"bounds_min[{axes[i]}]") + + if bounds_max is not None: + if len(bounds_max) != 3: + raise ValueError( + f"bounds_max must contain exactly 3 coordinates (x, y, z), got {len(bounds_max)}" + ) + for i, val in enumerate(bounds_max): + validate_finite_coordinate(val, name=f"bounds_max[{axes[i]}]") + + if bounds_min is not None and bounds_max is not None: + for i, axis in enumerate(axes): + min_val = float(bounds_min[i]) + max_val = float(bounds_max[i]) + if min_val > max_val: + raise ValueError( + f"Invalid workspace bounds: minimum {axis} ({min_val}) cannot exceed maximum {axis} ({max_val})" + ) + + +def validate_pose_within_bounds( + x: object, + y: object, + z: object, + yaw: object, + bounds_min: Sequence[float] | None = None, + bounds_max: Sequence[float] | None = None, +) -> tuple[float, float, float, float]: + """Validate target pose coordinates and verify they fall within workspace boundaries. + + Args: + x: The target x coordinate. + y: The target y coordinate. + z: The target z coordinate. + yaw: The target orientation yaw angle. + bounds_min: Optional minimum (x, y, z) workspace bounds. + bounds_max: Optional maximum (x, y, z) workspace bounds. + + Returns: + Tuple[float, float, float, float]: Validated (x, y, z, yaw) floats. + + Raises: + ValueError: If any coordinate is non-finite, bounds are malformed, + or target coordinates violate configured workspace boundaries. + """ + valid_x = validate_finite_coordinate(x, name="x") + valid_y = validate_finite_coordinate(y, name="y") + valid_z = validate_finite_coordinate(z, name="z") + valid_yaw = validate_finite_coordinate(yaw, name="yaw") + + validate_workspace_bounds(bounds_min, bounds_max) + + coordinates = [("x", valid_x), ("y", valid_y), ("z", valid_z)] + + if bounds_min is not None: + for i, (axis, val) in enumerate(coordinates): + min_val = float(bounds_min[i]) + if val < min_val: + raise ValueError( + f"Target coordinate {axis}={val} is below minimum workspace boundary {min_val}. " + "Goal rejected for safety." + ) + + if bounds_max is not None: + for i, (axis, val) in enumerate(coordinates): + max_val = float(bounds_max[i]) + if val > max_val: + raise ValueError( + f"Target coordinate {axis}={val} exceeds maximum workspace boundary {max_val}. " + "Goal rejected for safety." + ) + + return valid_x, valid_y, valid_z, valid_yaw diff --git a/src/rai_core/rai/tools/ros2/navigation/__init__.py b/src/rai_core/rai/tools/ros2/navigation/__init__.py index 4142fcc68..51ae5615f 100644 --- a/src/rai_core/rai/tools/ros2/navigation/__init__.py +++ b/src/rai_core/rai/tools/ros2/navigation/__init__.py @@ -12,6 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +from .bounds import ( + validate_finite_coordinate, + validate_pose_within_bounds, + validate_workspace_bounds, +) from .nav2 import ( CancelNavigateToPoseTool, GetNavigateToPoseFeedbackTool, @@ -30,4 +35,7 @@ "Nav2Toolkit", "NavigateToPoseBlockingTool", "NavigateToPoseTool", + "validate_finite_coordinate", + "validate_pose_within_bounds", + "validate_workspace_bounds", ] diff --git a/src/rai_core/rai/tools/ros2/navigation/bounds.py b/src/rai_core/rai/tools/ros2/navigation/bounds.py new file mode 100644 index 000000000..de34b8c9c --- /dev/null +++ b/src/rai_core/rai/tools/ros2/navigation/bounds.py @@ -0,0 +1,27 @@ +# Copyright (C) 2026 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Workspace bounds and coordinate safety validation for navigation tools.""" + +from rai.tools.coordinates import ( + validate_finite_coordinate, + validate_pose_within_bounds, + validate_workspace_bounds, +) + +__all__ = [ + "validate_finite_coordinate", + "validate_pose_within_bounds", + "validate_workspace_bounds", +] diff --git a/src/rai_core/rai/tools/ros2/navigation/nav2.py b/src/rai_core/rai/tools/ros2/navigation/nav2.py index 0ca4417fe..6c5320427 100644 --- a/src/rai_core/rai/tools/ros2/navigation/nav2.py +++ b/src/rai_core/rai/tools/ros2/navigation/nav2.py @@ -14,7 +14,7 @@ import base64 import time -from typing import List, Optional, Type, cast +from typing import cast import cv2 import numpy as np @@ -29,10 +29,11 @@ from rai.communication.ros2.connectors import ROS2Connector from rai.messages import MultimodalArtifact from rai.tools.ros2.base import BaseROS2Tool, BaseROS2Toolkit +from rai.tools.ros2.navigation.bounds import validate_pose_within_bounds -current_action_id: Optional[str] = None -current_feedback: Optional[NavigateToPose.Feedback] = None -current_result: Optional[NavigateToPose.Result] = None +current_action_id: str | None = None +current_feedback: NavigateToPose.Feedback | None = None +current_result: NavigateToPose.Result | None = None class Nav2Toolkit(BaseROS2Toolkit): @@ -43,13 +44,23 @@ class Nav2Toolkit(BaseROS2Toolkit): action_name: str = Field( default="navigate_to_pose", description="The name of the NavigateToPose action" ) + workspace_bounds_min: tuple[float, float, float] | None = Field( + default=None, + description="Optional minimum (x, y, z) workspace bounds for navigation goals", + ) + workspace_bounds_max: tuple[float, float, float] | None = Field( + default=None, + description="Optional maximum (x, y, z) workspace bounds for navigation goals", + ) - def get_tools(self) -> List[BaseTool]: + def get_tools(self) -> list[BaseTool]: return [ NavigateToPoseTool( connector=self.connector, frame_id=self.frame_id, action_name=self.action_name, + workspace_bounds_min=self.workspace_bounds_min, + workspace_bounds_max=self.workspace_bounds_max, ), CancelNavigateToPoseTool(connector=self.connector), GetNavigateToPoseFeedbackTool(connector=self.connector), @@ -68,7 +79,7 @@ class NavigateToPoseTool(BaseROS2Tool): name: str = "navigate_to_pose" description: str = "Navigate to a specific pose" - args_schema: Type[NavigateToPoseToolInput] = NavigateToPoseToolInput + args_schema: type[NavigateToPoseToolInput] = NavigateToPoseToolInput frame_id: str = Field( default="map", description="The frame id of the Nav2 stack (map, odom, etc.)" @@ -76,6 +87,14 @@ class NavigateToPoseTool(BaseROS2Tool): action_name: str = Field( default="navigate_to_pose", description="The name of the NavigateToPose action" ) + workspace_bounds_min: tuple[float, float, float] | None = Field( + default=None, + description="Optional minimum (x, y, z) workspace bounds for navigation goals", + ) + workspace_bounds_max: tuple[float, float, float] | None = Field( + default=None, + description="Optional maximum (x, y, z) workspace bounds for navigation goals", + ) def on_feedback(self, feedback: NavigateToPose.Feedback) -> None: global current_feedback @@ -86,6 +105,14 @@ def on_done(self, result: NavigateToPose.Result) -> None: current_result = result def _run(self, x: float, y: float, z: float, yaw: float) -> str: + x, y, z, yaw = validate_pose_within_bounds( + x=x, + y=y, + z=z, + yaw=yaw, + bounds_min=self.workspace_bounds_min, + bounds_max=self.workspace_bounds_max, + ) pose = PoseStamped() pose.header.frame_id = self.frame_id pose.header.stamp = self.connector.node.get_clock().now().to_msg() diff --git a/src/rai_core/rai/tools/ros2/navigation/nav2_blocking.py b/src/rai_core/rai/tools/ros2/navigation/nav2_blocking.py index 52c0b704f..157a35f7f 100644 --- a/src/rai_core/rai/tools/ros2/navigation/nav2_blocking.py +++ b/src/rai_core/rai/tools/ros2/navigation/nav2_blocking.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Type from action_msgs.msg import GoalStatus from geometry_msgs.msg import PoseStamped, Quaternion @@ -22,6 +21,7 @@ from tf_transformations import quaternion_from_euler from rai.tools.ros2.base import BaseROS2Tool +from rai.tools.ros2.navigation.bounds import validate_pose_within_bounds def _get_status_string(status: int) -> str: @@ -71,7 +71,7 @@ class GetCurrentPoseTool(BaseROS2Tool): default="base_link", description="The frame id of the robot's base frame (base_link, base_footprint, etc.)", ) - args_schema: Type[GetCurrentPoseToolInput] = GetCurrentPoseToolInput + args_schema: type[GetCurrentPoseToolInput] = GetCurrentPoseToolInput def _run(self) -> str: transform_stamped = self.connector.get_transform( @@ -96,9 +96,25 @@ class NavigateToPoseBlockingTool(BaseROS2Tool): action_name: str = Field( default="navigate_to_pose", description="The name of the Nav2 action" ) - args_schema: Type[NavigateToPoseBlockingToolInput] = NavigateToPoseBlockingToolInput + workspace_bounds_min: tuple[float, float, float] | None = Field( + default=None, + description="Optional minimum (x, y, z) workspace bounds for navigation goals", + ) + workspace_bounds_max: tuple[float, float, float] | None = Field( + default=None, + description="Optional maximum (x, y, z) workspace bounds for navigation goals", + ) + args_schema: type[NavigateToPoseBlockingToolInput] = NavigateToPoseBlockingToolInput def _run(self, x: float, y: float, z: float, yaw: float) -> str: + x, y, z, yaw = validate_pose_within_bounds( + x=x, + y=y, + z=z, + yaw=yaw, + bounds_min=self.workspace_bounds_min, + bounds_max=self.workspace_bounds_max, + ) action_client = ActionClient( self.connector.node, NavigateToPose, self.action_name ) diff --git a/tests/tools/test_coordinates.py b/tests/tools/test_coordinates.py new file mode 100644 index 000000000..5e6737680 --- /dev/null +++ b/tests/tools/test_coordinates.py @@ -0,0 +1,173 @@ +# Copyright (C) 2026 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math + +import pytest +from rai.tools.coordinates import ( + validate_finite_coordinate, + validate_pose_within_bounds, + validate_workspace_bounds, +) + + +@pytest.mark.parametrize( + "value,expected", + [ + (0, 0.0), + (1, 1.0), + (-5, -5.0), + (3.14159, 3.14159), + (-42.75, -42.75), + (1e6, 1e6), + ], +) +def test_validate_finite_coordinate_accepts(value, expected): + assert validate_finite_coordinate(value, name="test_coord") == expected + + +@pytest.mark.parametrize( + "value", + [ + True, + False, + math.nan, + math.inf, + -math.inf, + "1.0", + "invalid", + None, + [], + {}, + ], +) +def test_validate_finite_coordinate_rejects(value): + with pytest.raises(ValueError, match="finite number"): + validate_finite_coordinate(value, name="test_coord") + + +def test_validate_workspace_bounds_accepts(): + validate_workspace_bounds(None, None) + validate_workspace_bounds((-10.0, -10.0, -0.5), (10.0, 10.0, 3.0)) + validate_workspace_bounds([-5, 0, 0], [5, 10, 2]) + validate_workspace_bounds((-10.0, -10.0, 0.0), None) + validate_workspace_bounds(None, (10.0, 10.0, 3.0)) + + +@pytest.mark.parametrize( + "bounds", + [ + (1.0, 2.0), + (1.0, 2.0, 3.0, 4.0), + [], + ], +) +def test_validate_workspace_bounds_rejects_length(bounds): + with pytest.raises(ValueError, match="exactly 3 coordinates"): + validate_workspace_bounds(bounds, None) + + with pytest.raises(ValueError, match="exactly 3 coordinates"): + validate_workspace_bounds(None, bounds) + + +@pytest.mark.parametrize( + "bounds", + [ + (1.0, math.nan, 3.0), + (math.inf, 2.0, 3.0), + (1.0, 2.0, True), + (1.0, "2.0", 3.0), + ], +) +def test_validate_workspace_bounds_rejects_non_finite(bounds): + with pytest.raises(ValueError, match="finite number"): + validate_workspace_bounds(bounds, None) + + +@pytest.mark.parametrize( + "min_bounds,max_bounds,violated_axis", + [ + ((5.0, 0.0, 0.0), (2.0, 10.0, 2.0), "x"), + ((0.0, 15.0, 0.0), (10.0, 5.0, 2.0), "y"), + ((0.0, 0.0, 5.0), (10.0, 10.0, 1.0), "z"), + ], +) +def test_validate_workspace_bounds_rejects_inverted( + min_bounds, max_bounds, violated_axis +): + with pytest.raises( + ValueError, + match=f"minimum {violated_axis} .* cannot exceed maximum {violated_axis}", + ): + validate_workspace_bounds(min_bounds, max_bounds) + + +def test_validate_pose_within_bounds_unbounded(): + x, y, z, yaw = validate_pose_within_bounds(1.5, -2.5, 0.25, 0.785) + assert x == 1.5 + assert y == -2.5 + assert z == 0.25 + assert yaw == 0.785 + + +def test_validate_pose_within_bounds_bounded_valid(): + bounds_min = (-10.0, -10.0, -0.5) + bounds_max = (10.0, 10.0, 3.0) + x, y, z, yaw = validate_pose_within_bounds( + x=5.0, y=-3.0, z=1.2, yaw=1.57, bounds_min=bounds_min, bounds_max=bounds_max + ) + assert (x, y, z, yaw) == (5.0, -3.0, 1.2, 1.57) + + +def test_validate_pose_within_bounds_boundary_limits(): + bounds_min = (-10.0, -5.0, 0.0) + bounds_max = (10.0, 5.0, 2.0) + # Exact boundaries should be allowed + x, y, z, yaw = validate_pose_within_bounds( + x=-10.0, y=5.0, z=0.0, yaw=0.0, bounds_min=bounds_min, bounds_max=bounds_max + ) + assert (x, y, z, yaw) == (-10.0, 5.0, 0.0, 0.0) + + +@pytest.mark.parametrize( + "x,y,z,violated_axis,direction", + [ + (-10.1, 0.0, 1.0, "x", "below minimum"), + (10.1, 0.0, 1.0, "x", "exceeds maximum"), + (0.0, -5.5, 1.0, "y", "below minimum"), + (0.0, 5.5, 1.0, "y", "exceeds maximum"), + (0.0, 0.0, -0.1, "z", "below minimum"), + (0.0, 0.0, 3.1, "z", "exceeds maximum"), + ], +) +def test_validate_pose_within_bounds_violations(x, y, z, violated_axis, direction): + bounds_min = (-10.0, -5.0, 0.0) + bounds_max = (10.0, 5.0, 3.0) + + pattern = f"Target coordinate {violated_axis}=.* {direction} workspace boundary" + with pytest.raises(ValueError, match=pattern): + validate_pose_within_bounds( + x=x, y=y, z=z, yaw=0.0, bounds_min=bounds_min, bounds_max=bounds_max + ) + + +def test_validate_pose_within_bounds_rejects_non_finite_inputs(): + bounds_min = (-10.0, -10.0, 0.0) + bounds_max = (10.0, 10.0, 3.0) + + with pytest.raises(ValueError, match="x must be a finite number"): + validate_pose_within_bounds(math.nan, 0.0, 0.0, 0.0, bounds_min, bounds_max) + + with pytest.raises(ValueError, match="yaw must be a finite number"): + validate_pose_within_bounds(0.0, 0.0, 0.0, math.inf, bounds_min, bounds_max) From f2c03872143a61442a76c8a5a238b581018b9d79 Mon Sep 17 00:00:00 2001 From: Maciej Majek Date: Tue, 8 Sep 2026 13:23:39 +0200 Subject: [PATCH 2/2] refactor(nav2): express workspace bounds as pydantic field constraints Keeps the feature and folds the validation into the tool models. The bounds live on a WorkspaceBounds mixin shared by NavigateToPoseTool, NavigateToPoseBlockingTool and Nav2Toolkit, which propagates them. Their ordering is checked once when the tool is built rather than on every goal, and Tuple[Coordinate, ...] with allow_inf_nan=False covers the shape and finiteness that coordinates.py checked by hand, so that module and its re-exports are gone. Also adds allow_inf_nan=False to NavigateToPoseBlockingToolInput, which was missed in #872, and constrains the bounds themselves: a plain float tuple accepts NaN, and NaN bounds silently disable every comparison. Tests now exercise the tools rather than the helpers: an out-of-bounds goal is refused before the connector is touched, one-sided bounds only constrain their side, and inverted or malformed bounds fail at construction. --- src/rai_core/pyproject.toml | 2 +- src/rai_core/rai/tools/__init__.py | 9 - src/rai_core/rai/tools/coordinates.py | 137 -------------- .../rai/tools/ros2/navigation/__init__.py | 8 - .../rai/tools/ros2/navigation/bounds.py | 59 ++++-- .../rai/tools/ros2/navigation/nav2.py | 43 +---- .../tools/ros2/navigation/nav2_blocking.py | 42 ++--- tests/tools/ros2/test_nav2_bounds.py | 97 ++++++++++ tests/tools/test_coordinates.py | 173 ------------------ uv.lock | 2 +- 10 files changed, 173 insertions(+), 399 deletions(-) delete mode 100644 src/rai_core/rai/tools/coordinates.py create mode 100644 tests/tools/ros2/test_nav2_bounds.py delete mode 100644 tests/tools/test_coordinates.py diff --git a/src/rai_core/pyproject.toml b/src/rai_core/pyproject.toml index 009a9a15f..e9f1e308c 100644 --- a/src/rai_core/pyproject.toml +++ b/src/rai_core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rai_core" -version = "2.12.3" +version = "2.12.4" description = "Core functionality for RAI framework" readme = "README.md" requires-python = ">=3.10,<3.13" diff --git a/src/rai_core/rai/tools/__init__.py b/src/rai_core/rai/tools/__init__.py index 93bec1310..a2f5a1099 100644 --- a/src/rai_core/rai/tools/__init__.py +++ b/src/rai_core/rai/tools/__init__.py @@ -12,14 +12,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .coordinates import ( - validate_finite_coordinate as validate_finite_coordinate, -) -from .coordinates import ( - validate_pose_within_bounds as validate_pose_within_bounds, -) -from .coordinates import ( - validate_workspace_bounds as validate_workspace_bounds, -) from .timeout import timeout as timeout from .timeout import timeout_method as timeout_method diff --git a/src/rai_core/rai/tools/coordinates.py b/src/rai_core/rai/tools/coordinates.py deleted file mode 100644 index a74252478..000000000 --- a/src/rai_core/rai/tools/coordinates.py +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright (C) 2026 Robotec.AI -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Coordinate and workspace bounds validation helpers for robot tools.""" - -import math -from collections.abc import Sequence - - -def validate_finite_coordinate(value: object, *, name: str = "coordinate") -> float: - """Validate that a coordinate value is a finite number. - - Args: - value: Input value to validate. - name: Name of the coordinate parameter for error reporting. - - Returns: - float: Validated finite float value. - - Raises: - ValueError: If value is a boolean, non-numeric, NaN, or infinite. - """ - if value is True or value is False or not isinstance(value, (int, float)): - raise ValueError(f"{name} must be a finite number, got {value!r}") - - number = float(value) - if not math.isfinite(number): - raise ValueError(f"{name} must be a finite number, got {value!r}") - - return number - - -def validate_workspace_bounds( - bounds_min: Sequence[float] | None, - bounds_max: Sequence[float] | None, -) -> None: - """Validate that workspace bounding box specifications are well-formed. - - Args: - bounds_min: Optional minimum (x, y, z) coordinates. - bounds_max: Optional maximum (x, y, z) coordinates. - - Raises: - ValueError: If bounds are improperly shaped or if minimum exceeds maximum. - """ - axes = ("x", "y", "z") - - if bounds_min is not None: - if len(bounds_min) != 3: - raise ValueError( - f"bounds_min must contain exactly 3 coordinates (x, y, z), got {len(bounds_min)}" - ) - for i, val in enumerate(bounds_min): - validate_finite_coordinate(val, name=f"bounds_min[{axes[i]}]") - - if bounds_max is not None: - if len(bounds_max) != 3: - raise ValueError( - f"bounds_max must contain exactly 3 coordinates (x, y, z), got {len(bounds_max)}" - ) - for i, val in enumerate(bounds_max): - validate_finite_coordinate(val, name=f"bounds_max[{axes[i]}]") - - if bounds_min is not None and bounds_max is not None: - for i, axis in enumerate(axes): - min_val = float(bounds_min[i]) - max_val = float(bounds_max[i]) - if min_val > max_val: - raise ValueError( - f"Invalid workspace bounds: minimum {axis} ({min_val}) cannot exceed maximum {axis} ({max_val})" - ) - - -def validate_pose_within_bounds( - x: object, - y: object, - z: object, - yaw: object, - bounds_min: Sequence[float] | None = None, - bounds_max: Sequence[float] | None = None, -) -> tuple[float, float, float, float]: - """Validate target pose coordinates and verify they fall within workspace boundaries. - - Args: - x: The target x coordinate. - y: The target y coordinate. - z: The target z coordinate. - yaw: The target orientation yaw angle. - bounds_min: Optional minimum (x, y, z) workspace bounds. - bounds_max: Optional maximum (x, y, z) workspace bounds. - - Returns: - Tuple[float, float, float, float]: Validated (x, y, z, yaw) floats. - - Raises: - ValueError: If any coordinate is non-finite, bounds are malformed, - or target coordinates violate configured workspace boundaries. - """ - valid_x = validate_finite_coordinate(x, name="x") - valid_y = validate_finite_coordinate(y, name="y") - valid_z = validate_finite_coordinate(z, name="z") - valid_yaw = validate_finite_coordinate(yaw, name="yaw") - - validate_workspace_bounds(bounds_min, bounds_max) - - coordinates = [("x", valid_x), ("y", valid_y), ("z", valid_z)] - - if bounds_min is not None: - for i, (axis, val) in enumerate(coordinates): - min_val = float(bounds_min[i]) - if val < min_val: - raise ValueError( - f"Target coordinate {axis}={val} is below minimum workspace boundary {min_val}. " - "Goal rejected for safety." - ) - - if bounds_max is not None: - for i, (axis, val) in enumerate(coordinates): - max_val = float(bounds_max[i]) - if val > max_val: - raise ValueError( - f"Target coordinate {axis}={val} exceeds maximum workspace boundary {max_val}. " - "Goal rejected for safety." - ) - - return valid_x, valid_y, valid_z, valid_yaw diff --git a/src/rai_core/rai/tools/ros2/navigation/__init__.py b/src/rai_core/rai/tools/ros2/navigation/__init__.py index 51ae5615f..4142fcc68 100644 --- a/src/rai_core/rai/tools/ros2/navigation/__init__.py +++ b/src/rai_core/rai/tools/ros2/navigation/__init__.py @@ -12,11 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .bounds import ( - validate_finite_coordinate, - validate_pose_within_bounds, - validate_workspace_bounds, -) from .nav2 import ( CancelNavigateToPoseTool, GetNavigateToPoseFeedbackTool, @@ -35,7 +30,4 @@ "Nav2Toolkit", "NavigateToPoseBlockingTool", "NavigateToPoseTool", - "validate_finite_coordinate", - "validate_pose_within_bounds", - "validate_workspace_bounds", ] diff --git a/src/rai_core/rai/tools/ros2/navigation/bounds.py b/src/rai_core/rai/tools/ros2/navigation/bounds.py index de34b8c9c..5c136815d 100644 --- a/src/rai_core/rai/tools/ros2/navigation/bounds.py +++ b/src/rai_core/rai/tools/ros2/navigation/bounds.py @@ -12,16 +12,49 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Workspace bounds and coordinate safety validation for navigation tools.""" - -from rai.tools.coordinates import ( - validate_finite_coordinate, - validate_pose_within_bounds, - validate_workspace_bounds, -) - -__all__ = [ - "validate_finite_coordinate", - "validate_pose_within_bounds", - "validate_workspace_bounds", -] +from math import inf +from typing import Annotated, Optional, Tuple + +from pydantic import BaseModel, Field, model_validator +from typing_extensions import Self + +# a plain float tuple would accept NaN, which silently disables every comparison +Coordinate = Annotated[float, Field(allow_inf_nan=False)] +Bounds = Optional[Tuple[Coordinate, Coordinate, Coordinate]] + + +class WorkspaceBounds(BaseModel): + """Optional (x, y, z) box a navigation goal has to fall inside.""" + + workspace_bounds_min: Bounds = Field( + default=None, + description="Optional minimum (x, y, z) workspace bounds for navigation goals", + ) + workspace_bounds_max: Bounds = Field( + default=None, + description="Optional maximum (x, y, z) workspace bounds for navigation goals", + ) + + @model_validator(mode="after") + def _check_bounds_ordering(self) -> Self: + lo, hi = self.workspace_bounds_min, self.workspace_bounds_max + if ( + lo is not None + and hi is not None + and any(low > high for low, high in zip(lo, hi)) + ): + raise ValueError( + f"workspace_bounds_min {lo} exceeds workspace_bounds_max {hi}" + ) + return self + + def reject_out_of_bounds(self, x: float, y: float, z: float) -> None: + """Raise if the goal falls outside the configured workspace.""" + lo = self.workspace_bounds_min or (-inf, -inf, -inf) + hi = self.workspace_bounds_max or (inf, inf, inf) + for axis, value, low, high in zip("xyz", (x, y, z), lo, hi): + if not low <= value <= high: + raise ValueError( + f"Goal rejected: {axis}={value} outside the workspace " + f"[{low}, {high}]" + ) diff --git a/src/rai_core/rai/tools/ros2/navigation/nav2.py b/src/rai_core/rai/tools/ros2/navigation/nav2.py index 6ec02505e..149df6cfc 100644 --- a/src/rai_core/rai/tools/ros2/navigation/nav2.py +++ b/src/rai_core/rai/tools/ros2/navigation/nav2.py @@ -14,7 +14,7 @@ import base64 import time -from typing import cast +from typing import List, Optional, Type, cast import cv2 import numpy as np @@ -29,14 +29,14 @@ from rai.communication.ros2.connectors import ROS2Connector from rai.messages import MultimodalArtifact from rai.tools.ros2.base import BaseROS2Tool, BaseROS2Toolkit -from rai.tools.ros2.navigation.bounds import validate_pose_within_bounds +from rai.tools.ros2.navigation.bounds import WorkspaceBounds -current_action_id: str | None = None -current_feedback: NavigateToPose.Feedback | None = None -current_result: NavigateToPose.Result | None = None +current_action_id: Optional[str] = None +current_feedback: Optional[NavigateToPose.Feedback] = None +current_result: Optional[NavigateToPose.Result] = None -class Nav2Toolkit(BaseROS2Toolkit): +class Nav2Toolkit(WorkspaceBounds, BaseROS2Toolkit): connector: ROS2Connector frame_id: str = Field( default="map", description="The frame id of the Nav2 stack (map, odom, etc.)" @@ -44,16 +44,8 @@ class Nav2Toolkit(BaseROS2Toolkit): action_name: str = Field( default="navigate_to_pose", description="The name of the NavigateToPose action" ) - workspace_bounds_min: tuple[float, float, float] | None = Field( - default=None, - description="Optional minimum (x, y, z) workspace bounds for navigation goals", - ) - workspace_bounds_max: tuple[float, float, float] | None = Field( - default=None, - description="Optional maximum (x, y, z) workspace bounds for navigation goals", - ) - def get_tools(self) -> list[BaseTool]: + def get_tools(self) -> List[BaseTool]: return [ NavigateToPoseTool( connector=self.connector, @@ -83,11 +75,11 @@ class NavigateToPoseToolInput(BaseModel): ) -class NavigateToPoseTool(BaseROS2Tool): +class NavigateToPoseTool(WorkspaceBounds, BaseROS2Tool): name: str = "navigate_to_pose" description: str = "Navigate to a specific pose" - args_schema: type[NavigateToPoseToolInput] = NavigateToPoseToolInput + args_schema: Type[NavigateToPoseToolInput] = NavigateToPoseToolInput frame_id: str = Field( default="map", description="The frame id of the Nav2 stack (map, odom, etc.)" @@ -95,14 +87,6 @@ class NavigateToPoseTool(BaseROS2Tool): action_name: str = Field( default="navigate_to_pose", description="The name of the NavigateToPose action" ) - workspace_bounds_min: tuple[float, float, float] | None = Field( - default=None, - description="Optional minimum (x, y, z) workspace bounds for navigation goals", - ) - workspace_bounds_max: tuple[float, float, float] | None = Field( - default=None, - description="Optional maximum (x, y, z) workspace bounds for navigation goals", - ) def on_feedback(self, feedback: NavigateToPose.Feedback) -> None: global current_feedback @@ -113,14 +97,7 @@ def on_done(self, result: NavigateToPose.Result) -> None: current_result = result def _run(self, x: float, y: float, z: float, yaw: float) -> str: - x, y, z, yaw = validate_pose_within_bounds( - x=x, - y=y, - z=z, - yaw=yaw, - bounds_min=self.workspace_bounds_min, - bounds_max=self.workspace_bounds_max, - ) + self.reject_out_of_bounds(x, y, z) pose = PoseStamped() pose.header.frame_id = self.frame_id pose.header.stamp = self.connector.node.get_clock().now().to_msg() diff --git a/src/rai_core/rai/tools/ros2/navigation/nav2_blocking.py b/src/rai_core/rai/tools/ros2/navigation/nav2_blocking.py index 157a35f7f..166097ebc 100644 --- a/src/rai_core/rai/tools/ros2/navigation/nav2_blocking.py +++ b/src/rai_core/rai/tools/ros2/navigation/nav2_blocking.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Type from action_msgs.msg import GoalStatus from geometry_msgs.msg import PoseStamped, Quaternion @@ -21,7 +22,7 @@ from tf_transformations import quaternion_from_euler from rai.tools.ros2.base import BaseROS2Tool -from rai.tools.ros2.navigation.bounds import validate_pose_within_bounds +from rai.tools.ros2.navigation.bounds import WorkspaceBounds def _get_status_string(status: int) -> str: @@ -71,7 +72,7 @@ class GetCurrentPoseTool(BaseROS2Tool): default="base_link", description="The frame id of the robot's base frame (base_link, base_footprint, etc.)", ) - args_schema: type[GetCurrentPoseToolInput] = GetCurrentPoseToolInput + args_schema: Type[GetCurrentPoseToolInput] = GetCurrentPoseToolInput def _run(self) -> str: transform_stamped = self.connector.get_transform( @@ -81,13 +82,21 @@ def _run(self) -> str: class NavigateToPoseBlockingToolInput(BaseModel): - x: float = Field(..., description="The x coordinate of the pose") - y: float = Field(..., description="The y coordinate of the pose") - z: float = Field(..., description="The z coordinate of the pose") - yaw: float = Field(..., description="The yaw angle of the pose") + x: float = Field( + ..., allow_inf_nan=False, description="The x coordinate of the pose" + ) + y: float = Field( + ..., allow_inf_nan=False, description="The y coordinate of the pose" + ) + z: float = Field( + ..., allow_inf_nan=False, description="The z coordinate of the pose" + ) + yaw: float = Field( + ..., allow_inf_nan=False, description="The yaw angle of the pose" + ) -class NavigateToPoseBlockingTool(BaseROS2Tool): +class NavigateToPoseBlockingTool(WorkspaceBounds, BaseROS2Tool): name: str = "navigate_to_pose_blocking" description: str = "Navigate to a specific pose" frame_id: str = Field( @@ -96,25 +105,10 @@ class NavigateToPoseBlockingTool(BaseROS2Tool): action_name: str = Field( default="navigate_to_pose", description="The name of the Nav2 action" ) - workspace_bounds_min: tuple[float, float, float] | None = Field( - default=None, - description="Optional minimum (x, y, z) workspace bounds for navigation goals", - ) - workspace_bounds_max: tuple[float, float, float] | None = Field( - default=None, - description="Optional maximum (x, y, z) workspace bounds for navigation goals", - ) - args_schema: type[NavigateToPoseBlockingToolInput] = NavigateToPoseBlockingToolInput + args_schema: Type[NavigateToPoseBlockingToolInput] = NavigateToPoseBlockingToolInput def _run(self, x: float, y: float, z: float, yaw: float) -> str: - x, y, z, yaw = validate_pose_within_bounds( - x=x, - y=y, - z=z, - yaw=yaw, - bounds_min=self.workspace_bounds_min, - bounds_max=self.workspace_bounds_max, - ) + self.reject_out_of_bounds(x, y, z) action_client = ActionClient( self.connector.node, NavigateToPose, self.action_name ) diff --git a/tests/tools/ros2/test_nav2_bounds.py b/tests/tools/ros2/test_nav2_bounds.py new file mode 100644 index 000000000..3a01e2dde --- /dev/null +++ b/tests/tools/ros2/test_nav2_bounds.py @@ -0,0 +1,97 @@ +# Copyright (C) 2026 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A navigation tool configured with workspace bounds must refuse a goal +outside them before it reaches the action server.""" + +import pytest + +try: + import rclpy # noqa: F401 + + _ = rclpy # noqa: F841 +except ImportError: + pytest.skip("ROS2 is not installed", allow_module_level=True) + +from unittest.mock import MagicMock + +from pydantic import ValidationError +from rai.communication.ros2.connectors import ROS2Connector +from rai.tools.ros2.navigation.nav2 import Nav2Toolkit, NavigateToPoseTool +from rai.tools.ros2.navigation.nav2_blocking import NavigateToPoseBlockingTool + +MIN = (-5.0, -5.0, 0.0) +MAX = (5.0, 5.0, 2.0) + + +@pytest.fixture +def connector(): + return MagicMock(spec=ROS2Connector) + + +@pytest.fixture(params=[NavigateToPoseTool, NavigateToPoseBlockingTool]) +def bounded_tool(request, connector): + return request.param( + connector=connector, workspace_bounds_min=MIN, workspace_bounds_max=MAX + ) + + +@pytest.mark.parametrize( + "x,y,z", + [ + (6.0, 0.0, 1.0), + (-6.0, 0.0, 1.0), + (0.0, 6.0, 1.0), + (0.0, 0.0, 3.0), + (0.0, 0.0, -1.0), + ], +) +def test_goal_outside_bounds_is_refused(bounded_tool, x, y, z): + with pytest.raises(ValueError, match="Goal rejected"): + bounded_tool._run(x=x, y=y, z=z, yaw=0.0) + bounded_tool.connector.start_action.assert_not_called() + + +def test_unbounded_tool_accepts_anything(connector): + NavigateToPoseTool(connector=connector).reject_out_of_bounds(1e9, -1e9, 1e9) + + +def test_one_sided_bounds_only_constrain_that_side(connector): + tool = NavigateToPoseTool(connector=connector, workspace_bounds_max=MAX) + tool.reject_out_of_bounds(-1e9, -1e9, 0.0) + with pytest.raises(ValueError, match="outside the workspace"): + tool.reject_out_of_bounds(6.0, 0.0, 0.0) + + +def test_inverted_bounds_are_rejected_at_construction(connector): + with pytest.raises(ValidationError, match="exceeds"): + NavigateToPoseTool( + connector=connector, workspace_bounds_min=MAX, workspace_bounds_max=MIN + ) + + +def test_nan_bounds_are_rejected_at_construction(connector): + with pytest.raises(ValidationError): + NavigateToPoseTool( + connector=connector, workspace_bounds_min=(1.0, 2.0, float("nan")) + ) + + +def test_toolkit_passes_bounds_to_the_goal_tool(connector): + toolkit = Nav2Toolkit( + connector=connector, workspace_bounds_min=MIN, workspace_bounds_max=MAX + ) + tool = next(t for t in toolkit.get_tools() if isinstance(t, NavigateToPoseTool)) + assert tool.workspace_bounds_min == MIN + assert tool.workspace_bounds_max == MAX diff --git a/tests/tools/test_coordinates.py b/tests/tools/test_coordinates.py deleted file mode 100644 index 5e6737680..000000000 --- a/tests/tools/test_coordinates.py +++ /dev/null @@ -1,173 +0,0 @@ -# Copyright (C) 2026 Robotec.AI -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import math - -import pytest -from rai.tools.coordinates import ( - validate_finite_coordinate, - validate_pose_within_bounds, - validate_workspace_bounds, -) - - -@pytest.mark.parametrize( - "value,expected", - [ - (0, 0.0), - (1, 1.0), - (-5, -5.0), - (3.14159, 3.14159), - (-42.75, -42.75), - (1e6, 1e6), - ], -) -def test_validate_finite_coordinate_accepts(value, expected): - assert validate_finite_coordinate(value, name="test_coord") == expected - - -@pytest.mark.parametrize( - "value", - [ - True, - False, - math.nan, - math.inf, - -math.inf, - "1.0", - "invalid", - None, - [], - {}, - ], -) -def test_validate_finite_coordinate_rejects(value): - with pytest.raises(ValueError, match="finite number"): - validate_finite_coordinate(value, name="test_coord") - - -def test_validate_workspace_bounds_accepts(): - validate_workspace_bounds(None, None) - validate_workspace_bounds((-10.0, -10.0, -0.5), (10.0, 10.0, 3.0)) - validate_workspace_bounds([-5, 0, 0], [5, 10, 2]) - validate_workspace_bounds((-10.0, -10.0, 0.0), None) - validate_workspace_bounds(None, (10.0, 10.0, 3.0)) - - -@pytest.mark.parametrize( - "bounds", - [ - (1.0, 2.0), - (1.0, 2.0, 3.0, 4.0), - [], - ], -) -def test_validate_workspace_bounds_rejects_length(bounds): - with pytest.raises(ValueError, match="exactly 3 coordinates"): - validate_workspace_bounds(bounds, None) - - with pytest.raises(ValueError, match="exactly 3 coordinates"): - validate_workspace_bounds(None, bounds) - - -@pytest.mark.parametrize( - "bounds", - [ - (1.0, math.nan, 3.0), - (math.inf, 2.0, 3.0), - (1.0, 2.0, True), - (1.0, "2.0", 3.0), - ], -) -def test_validate_workspace_bounds_rejects_non_finite(bounds): - with pytest.raises(ValueError, match="finite number"): - validate_workspace_bounds(bounds, None) - - -@pytest.mark.parametrize( - "min_bounds,max_bounds,violated_axis", - [ - ((5.0, 0.0, 0.0), (2.0, 10.0, 2.0), "x"), - ((0.0, 15.0, 0.0), (10.0, 5.0, 2.0), "y"), - ((0.0, 0.0, 5.0), (10.0, 10.0, 1.0), "z"), - ], -) -def test_validate_workspace_bounds_rejects_inverted( - min_bounds, max_bounds, violated_axis -): - with pytest.raises( - ValueError, - match=f"minimum {violated_axis} .* cannot exceed maximum {violated_axis}", - ): - validate_workspace_bounds(min_bounds, max_bounds) - - -def test_validate_pose_within_bounds_unbounded(): - x, y, z, yaw = validate_pose_within_bounds(1.5, -2.5, 0.25, 0.785) - assert x == 1.5 - assert y == -2.5 - assert z == 0.25 - assert yaw == 0.785 - - -def test_validate_pose_within_bounds_bounded_valid(): - bounds_min = (-10.0, -10.0, -0.5) - bounds_max = (10.0, 10.0, 3.0) - x, y, z, yaw = validate_pose_within_bounds( - x=5.0, y=-3.0, z=1.2, yaw=1.57, bounds_min=bounds_min, bounds_max=bounds_max - ) - assert (x, y, z, yaw) == (5.0, -3.0, 1.2, 1.57) - - -def test_validate_pose_within_bounds_boundary_limits(): - bounds_min = (-10.0, -5.0, 0.0) - bounds_max = (10.0, 5.0, 2.0) - # Exact boundaries should be allowed - x, y, z, yaw = validate_pose_within_bounds( - x=-10.0, y=5.0, z=0.0, yaw=0.0, bounds_min=bounds_min, bounds_max=bounds_max - ) - assert (x, y, z, yaw) == (-10.0, 5.0, 0.0, 0.0) - - -@pytest.mark.parametrize( - "x,y,z,violated_axis,direction", - [ - (-10.1, 0.0, 1.0, "x", "below minimum"), - (10.1, 0.0, 1.0, "x", "exceeds maximum"), - (0.0, -5.5, 1.0, "y", "below minimum"), - (0.0, 5.5, 1.0, "y", "exceeds maximum"), - (0.0, 0.0, -0.1, "z", "below minimum"), - (0.0, 0.0, 3.1, "z", "exceeds maximum"), - ], -) -def test_validate_pose_within_bounds_violations(x, y, z, violated_axis, direction): - bounds_min = (-10.0, -5.0, 0.0) - bounds_max = (10.0, 5.0, 3.0) - - pattern = f"Target coordinate {violated_axis}=.* {direction} workspace boundary" - with pytest.raises(ValueError, match=pattern): - validate_pose_within_bounds( - x=x, y=y, z=z, yaw=0.0, bounds_min=bounds_min, bounds_max=bounds_max - ) - - -def test_validate_pose_within_bounds_rejects_non_finite_inputs(): - bounds_min = (-10.0, -10.0, 0.0) - bounds_max = (10.0, 10.0, 3.0) - - with pytest.raises(ValueError, match="x must be a finite number"): - validate_pose_within_bounds(math.nan, 0.0, 0.0, 0.0, bounds_min, bounds_max) - - with pytest.raises(ValueError, match="yaw must be a finite number"): - validate_pose_within_bounds(0.0, 0.0, 0.0, math.inf, bounds_min, bounds_max) diff --git a/uv.lock b/uv.lock index 3c0ed6bb8..aa2918fe1 100644 --- a/uv.lock +++ b/uv.lock @@ -4714,7 +4714,7 @@ requires-dist = [ [[package]] name = "rai-core" -version = "2.12.3" +version = "2.12.4" source = { editable = "src/rai_core" } dependencies = [ { name = "coloredlogs" },