Unify navigation on NavigateToPose and remove legacy proxies

This commit is contained in:
2026-09-22 17:40:25 +08:00
parent 964d1fde67
commit 24e0b922bc
40 changed files with 461 additions and 2592 deletions
+2 -5
View File
@@ -33,9 +33,6 @@ def goal_for(name):
goal.target_pose.header.frame_id = "map"
goal.target_pose.pose.orientation.w = 1.0
goal.position_tolerance = goal.yaw_tolerance = 0.1
elif name == "navigate_semantic":
goal.kind, goal.reference, goal.registry_version = "LOCATION", "bin_A", 1
goal.position_tolerance = goal.orientation_tolerance = 0.1
elif name == "execute_manipulation":
goal.skill, goal.instruction = "pick", "pick the smoke target"
goal.target.object_ref, goal.target.description = "smoke-target", "smoke target"
@@ -111,8 +108,8 @@ def main():
if not cancel.goals_canceling:
raise RuntimeError("cancel was not acknowledged")
canceled = await_future(handle.get_result_async())
if (canceled.result.result.status != canceled.result.result.CANCELED or
canceled.result.result.stop_state != canceled.result.result.UNKNOWN):
if (canceled.result.status != canceled.result.CANCELED or
canceled.result.stop_state != canceled.result.STOP_UNKNOWN):
raise RuntimeError("cancel terminal did not preserve unknown stop")
state_client = client_node.create_client(GetRobotState, "get_robot_state")
@@ -38,7 +38,7 @@ def run(root, binary, output):
scenarios = {
'plan_task':[{'kind':'normal', 'plan':plan}],
'verify_state':[{'kind':'passed'}],
'navigate_semantic':[{'kind':'normal','final_pose':site['locations'][name]}
'navigate':[{'kind':'normal','final_pose':site['locations'][name]}
for name in ['shelf_A_stop','tote_A_stop']*2],
}
actions = [name for name in ACTION_TYPES if name != 'execute_task']
@@ -78,7 +78,7 @@ def run(root, binary, output):
def counts():
with server.lock:
return {name:server.counts[name] for name in ('plan_task','navigate_semantic','execute_manipulation','execute_posture')}
return {name:server.counts[name] for name in ('plan_task','navigate','execute_manipulation','execute_posture')}
with tempfile.TemporaryDirectory(prefix='native-coordinator-regression-') as state:
state = Path(state)
+56 -51
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Generated Navigate messages + real DDS + production C++ RosDriver regression.
"""Generated NavigateToPose messages + real DDS + production C++ RosDriver regression.
In a sourced ROS Humble overlay:
python3 tests/helpers/native_navigation_contract.py --build-native --output /tmp/nav-contract
@@ -30,13 +30,14 @@ find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(rclcpp_action REQUIRED)
find_package(bt_skill_interfaces REQUIRED)
find_package(navigation_interfaces REQUIRED)
find_package(geometry_msgs REQUIRED)
find_package(std_msgs REQUIRED)
find_package(nlohmann_json REQUIRED)
add_executable(native_navigation_probe %s %s %s)
target_include_directories(native_navigation_probe PRIVATE %s %s)
target_link_libraries(native_navigation_probe nlohmann_json::nlohmann_json)
ament_target_dependencies(native_navigation_probe rclcpp rclcpp_action bt_skill_interfaces geometry_msgs std_msgs)
ament_target_dependencies(native_navigation_probe rclcpp rclcpp_action bt_skill_interfaces navigation_interfaces geometry_msgs std_msgs)
''' % tuple(q(root / p) for p in (
'ros2/bt_executor/tools/native_navigation_probe.cpp',
'ros2/bt_executor/src/ros_driver.cpp', 'core/src/core.cpp',
@@ -60,21 +61,23 @@ def run(binary, output):
from rclpy.action import ActionClient, ActionServer, CancelResponse
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from bt_skill_interfaces.action import Navigate
from bt_skill_interfaces.msg import NavigationResult
from navigation_interfaces.action import NavigateToPose as Navigate
from action_msgs.msg import GoalStatus
assert [getattr(NavigationResult, name) for name in
assert [getattr(Navigate.Result, name) for name in
('SUCCEEDED', 'CANCELED', 'TIMEOUT', 'BLOCKED', 'NOT_READY', 'FAILED')] == list(range(6))
assert [getattr(Navigate.Feedback, name) for name in
('ACCEPTED', 'CHECKING', 'NAVIGATING', 'BLOCKED', 'STOPPING')] == list(range(5))
('ACCEPTED', 'CHECKING', 'PLANNING', 'NAVIGATING', 'BLOCKED', 'STOPPING')] == list(range(6))
assert set(Navigate.Goal.get_fields_and_field_types()) == {
'trace', 'target_pose', 'position_tolerance', 'yaw_tolerance', 'timeout'}
'task_id', 'subtask_id', 'target_pose', 'position_tolerance', 'yaw_tolerance', 'timeout'}
assert set(Navigate.Result.get_fields_and_field_types()) == {
'result', 'final_pose_valid', 'final_pose', 'final_position_error', 'final_yaw_error'}
'status', 'error_code', 'message', 'final_pose_valid', 'final_pose',
'final_position_error', 'final_yaw_error', 'stop_state', 'stopped_at', 'stop_evidence_ref'}
assert set(Navigate.Feedback.get_fields_and_field_types()) == {
'stamp', 'sequence', 'phase', 'current_pose_valid', 'current_pose', 'error_valid',
'position_error', 'yaw_error', 'blocked', 'elapsed_time', 'message'}
'position_error', 'yaw_error', 'blocked_valid', 'blocked', 'elapsed_time', 'message'}
assert Navigate.Feedback.get_fields_and_field_types()['sequence'] == 'uint64'
assert (Navigate.Result.STOP_UNKNOWN, Navigate.Result.STOP_CONFIRMED) == (0, 1)
namespace = '/sim/navigation_contract_' + str(os.getpid())
rclpy.init()
node = Node('navigation_contract_fixture', namespace=namespace)
@@ -85,51 +88,51 @@ def run(binary, output):
def execute(handle):
goal = handle.request
scenario = round(goal.target_pose.pose.position.x)
status = scenario if scenario < 6 else {6: 1, 7: 3, 8: 4}[scenario]
status = scenario if scenario < 6 else {6: 1, 7: 3, 8: 4, 9: 1, 10: 4}[scenario]
with lock:
counts[(goal.trace.task_id, scenario)] += 1
counts[(goal.task_id, scenario)] += 1
try:
assert goal.target_pose.header.frame_id == 'map'
assert goal.target_pose.pose.orientation.w == 1.
assert goal.position_tolerance == .05 and goal.yaw_tolerance == .1
assert goal.timeout.sec == 5
assert goal.trace.attempt == 1 and goal.trace.task_revision == 1
assert goal.trace.plan_version == 1 and goal.trace.execution_generation == 1
assert goal.task_id and goal.subtask_id == 'navigate'
except AssertionError:
failures.append('Goal contract mismatch: ' + str(goal))
for phase in range(5):
for phase in range(6):
feedback = Navigate.Feedback()
feedback.stamp = node.get_clock().now().to_msg()
feedback.sequence = phase + 1
feedback.sequence = (1 << 32) + phase + 1
feedback.phase = phase
feedback.current_pose_valid = True
feedback.current_pose = goal.target_pose
feedback.error_valid = True
feedback.position_error = .02
feedback.yaw_error = -.03
feedback.blocked_valid = phase != Navigate.Feedback.STOPPING
feedback.blocked = phase == Navigate.Feedback.BLOCKED
feedback.elapsed_time.nanosec = (phase + 1) * 10000000
feedback.message = 'phase-' + str(phase)
handle.publish_feedback(feedback)
time.sleep(.03)
if phase == 2 and goal.trace.task_id.startswith('probe-'):
if phase == 2 and goal.task_id.startswith('probe-'):
# A later malformed sample must not poison the accepted sequence.
wrong_frame = deepcopy(feedback)
wrong_frame.sequence = 100
wrong_frame.sequence = (1 << 32) + 100
wrong_frame.current_pose.header.frame_id = 'odom'
handle.publish_feedback(wrong_frame)
wrong_phase = deepcopy(feedback)
wrong_phase.sequence = 101
wrong_phase.sequence = (1 << 32) + 101
wrong_phase.phase = 255
handle.publish_feedback(wrong_phase)
result = Navigate.Result()
result.result.status = status
result.result.error_code = '' if scenario in (7, 8) else 'FIXTURE_' + str(scenario)
result.result.message = 'outcome-' + str(scenario)
result.result.stop_state = 0 if scenario == 6 else 1
result.result.stopped_at = node.get_clock().now().to_msg()
result.result.stop_evidence_ref = '' if scenario == 6 else 'sim://navigation/stop'
result.final_pose_valid = True
result.status = status
result.error_code = '' if scenario in (7, 8) else 'EXECUTION_BACKEND_NOT_CONFIGURED' if scenario == 10 else 'FIXTURE_' + str(scenario)
result.message = 'outcome-' + str(scenario)
result.stop_state = 0 if scenario in (6, 10) else 1
result.stopped_at = node.get_clock().now().to_msg()
result.stop_evidence_ref = '' if scenario in (6, 9, 10) else 'sim://navigation/stop'
result.final_pose_valid = scenario != 10
result.final_pose = goal.target_pose
result.final_position_error = .02
result.final_yaw_error = -.03
@@ -156,16 +159,14 @@ def run(binary, output):
executor.add_node(node)
thread = threading.Thread(target=executor.spin, daemon=True)
thread.start()
report = {'scope': 'simulation-only generated Navigate and production RosDriver over DDS',
report = {'scope': 'simulation-only generated NavigateToPose and production RosDriver over DDS',
'namespace': namespace, 'direct': [], 'native': []}
try:
assert client.wait_for_server(timeout_sec=10)
for scenario in range(9):
for scenario in range(11):
goal = Navigate.Goal()
goal.trace.task_id = 'direct-' + str(scenario)
goal.trace.run_id = goal.trace.task_id
goal.trace.subtask_id = 'navigate'
goal.trace.attempt = goal.trace.task_revision = goal.trace.plan_version = goal.trace.execution_generation = 1
goal.task_id = 'direct-' + str(scenario)
goal.subtask_id = 'navigate'
goal.target_pose.header.frame_id = 'map'
goal.target_pose.pose.position.x = float(scenario)
goal.target_pose.pose.orientation.w = 1.
@@ -175,25 +176,26 @@ def run(binary, output):
feedback = []
handle = wait(client.send_goal_async(goal, feedback_callback=lambda value: feedback.append(value.feedback)))
assert handle.accepted
if scenario in (1, 6):
if scenario in (1, 6, 9):
assert wait(handle.cancel_goal_async()).return_code == 0
response = wait(handle.get_result_async())
expected = scenario if scenario < 6 else {6: 1, 7: 3, 8: 4}[scenario]
assert response.result.result.status == expected
expected = scenario if scenario < 6 else {6: 1, 7: 3, 8: 4, 9: 1, 10: 4}[scenario]
assert response.result.status == expected
assert response.status == (GoalStatus.STATUS_SUCCEEDED if expected == 0 else
GoalStatus.STATUS_CANCELED if expected == 1 else GoalStatus.STATUS_ABORTED)
assert response.result.final_pose_valid
assert response.result.final_pose_valid == (scenario != 10)
assert response.result.final_pose.pose.position.x == float(scenario)
assert response.result.final_position_error == .02 and response.result.final_yaw_error == -.03
assert response.result.result.stop_state == (0 if scenario == 6 else 1)
assert response.result.result.error_code == ('' if scenario in (7, 8) else 'FIXTURE_' + str(scenario))
assert [f.phase for f in feedback] == list(range(5)), feedback
assert [f.sequence for f in feedback] == list(range(1, 6))
assert response.result.stop_state == (0 if scenario in (6, 10) else 1)
assert response.result.error_code == ('' if scenario in (7, 8) else 'EXECUTION_BACKEND_NOT_CONFIGURED' if scenario == 10 else 'FIXTURE_' + str(scenario))
assert [f.phase for f in feedback] == list(range(6)), feedback
assert [f.sequence for f in feedback] == [(1 << 32) + n for n in range(1, 7)]
assert all(f.current_pose_valid and f.error_valid and f.position_error == .02 and
f.yaw_error == -.03 and f.stamp.sec > 0 for f in feedback)
assert feedback[3].blocked and not feedback[4].blocked
assert feedback[4].blocked_valid and feedback[4].blocked
assert not feedback[5].blocked_valid and not feedback[5].blocked
report['direct'].append({'scenario': scenario, 'status': expected, 'feedback_phases': [f.phase for f in feedback]})
for scenario, expected_code in enumerate((0, 2, 3, 1, 4, 1, 2, 1, 4)):
for scenario, expected_code in enumerate((0, 2, 3, 1, 4, 1, 2, 1, 4, 2, 4)):
state = output / ('state-' + str(scenario))
state.mkdir(exist_ok=True)
completed = subprocess.run([str(binary), str(scenario), str(state), namespace],
@@ -208,19 +210,22 @@ def run(binary, output):
raise AssertionError('Native probe failed: ' + json.dumps(report['failure']))
result = json.loads(next(line for line in reversed(completed.stdout.splitlines()) if line.startswith('{')))
assert result['code'] == expected_code, result
assert result['stop'] == (0 if scenario == 6 else 1), result
assert result['state'] == (3 if scenario == 6 else 4), result
assert result['robot_locked'] == (scenario == 6), result
assert result['unknown_stop_blocks_redispatch'] == (scenario == 6), result
assert result['error_code'] == ({7: 'NAV_BLOCKED', 8: 'NAV_NOT_READY'}.get(scenario, 'FIXTURE_' + str(scenario))), result
assert result['feedback_sequence'] == 5, result
assert json.loads(result['feedback'])['phase'] == 4, result
assert result['stop'] == (0 if scenario in (6, 9, 10) else 1), result
assert result['state'] == (3 if scenario in (6, 9, 10) else 4), result
assert result['robot_locked'] == (scenario in (6, 9, 10)), result
assert result['unknown_stop_blocks_redispatch'] == (scenario in (6, 9, 10)), result
assert result['error_code'] == ({7: 'NAV_BLOCKED', 8: 'NAV_NOT_READY', 10: 'EXECUTION_BACKEND_NOT_CONFIGURED'}.get(scenario, 'FIXTURE_' + str(scenario))), result
assert result['feedback_sequence'] == (1 << 32) + 6, result
assert json.loads(result['feedback'])['phase'] == 5
assert json.loads(result['feedback'])['blocked_valid'] is False, result
assert json.loads(result['feedback'])['blocked'] is None, result
assert result['mapping_count'] == 1 and result['wire_result_bytes'] > 0, result
assert 'Navigate' in result['wire_request_type'] and 'Navigate' in result['wire_result_type'], result
assert result['response_valid'], result
assert result['wire_request_type'] == 'navigation_interfaces/action/NavigateToPose_Goal', result
assert result['wire_result_type'] == 'navigation_interfaces/action/NavigateToPose_Result', result
assert result['response_valid'] == (scenario != 10), result
report['native'].append(result)
assert not failures, failures
assert len(counts) == 18 and all(count == 1 for count in counts.values()), dict(counts)
assert len(counts) == 22 and all(count == 1 for count in counts.values()), dict(counts)
report['exactly_once_goal_count'] = sum(counts.values())
report['passed'] = True
(output / 'navigation-contract-report.json').write_text(json.dumps(report, indent=2) + '\n')
+23 -5
View File
@@ -94,6 +94,7 @@ def run_route(root, binary, output, suffix, recovery_enabled):
from rclpy.node import Node
from rclpy.serialization import deserialize_message
from bt_skill_interfaces.action import ExecuteTask, ExecuteManipulation
from navigation_interfaces.action import NavigateToPose
from bt_skill_interfaces.msg import RobotState
from bt_skill_interfaces.srv import ReconcileTask
from robot_bt_coordinator.plan_v2 import item_plan
@@ -105,7 +106,7 @@ def run_route(root, binary, output, suffix, recovery_enabled):
stops = ['shelf_A_stop', 'tote_A_stop'] if suffix == 'object_table' else ['observe_A', 'shelf_A_stop', 'tote_A_stop']
scenarios = {'verify_state': [{'kind': 'passed'}],
'locate_shelf_column': [{'kind': 'normal', 'shelf_id': 'shelf_A', 'side_id': 'FRONT', 'column_id': '1', 'tier_id': '2'}],
'navigate_semantic': [{'kind': 'normal', 'final_pose': site['locations'][name]} for name in stops]}
'navigate': [{'kind': 'normal', 'final_pose': site['locations'][name]} for name in stops]}
actions = [name for name in ACTION_TYPES if name != 'execute_task']
rclpy.init(args=['--ros-args', '-p', 'initial_holding_state:=EMPTY',
'-p', 'scenarios_json:=' + json.dumps(json.dumps(scenarios)),
@@ -178,7 +179,7 @@ def run_route(root, binary, output, suffix, recovery_enabled):
def motion_counts():
with server.lock:
return {name: server.counts[name] for name in ('navigate_semantic', 'execute_manipulation', 'execute_posture')}
return {name: server.counts[name] for name in ('navigate', 'execute_manipulation', 'execute_posture')}
try:
start()
@@ -195,6 +196,23 @@ def run_route(root, binary, output, suffix, recovery_enabled):
row['decoded_manipulation'] = assert_manipulation_evidence(
journal_records(Path(journal) / 'goal_registry.log'), goal.trace.run_id,
ExecuteManipulation, deserialize_message)
navigation_rows = [entry for entry in journal_records(Path(journal) / 'goal_registry.log')
if entry['run_id'] == goal.trace.run_id and entry['skill'] == 0]
assert len(navigation_rows) == len(stops)
expected_poses = {(site['locations'][name]['x'], site['locations'][name]['y']) for name in stops}
actual_poses = set()
for entry in navigation_rows:
assert entry['type'] == 'navigation_interfaces/action/NavigateToPose_Goal'
navigation_goal = deserialize_message(bytes.fromhex(entry['wire']), NavigateToPose.Goal)
assert navigation_goal.task_id == goal.trace.task_id and navigation_goal.subtask_id
assert navigation_goal.target_pose.header.frame_id == 'map'
actual_poses.add((navigation_goal.target_pose.pose.position.x, navigation_goal.target_pose.pose.position.y))
assert entry['result_type'] == 'navigation_interfaces/action/NavigateToPose_Result'
navigation_result = deserialize_message(bytes.fromhex(entry['result_wire']), NavigateToPose.Result)
assert navigation_result.status == NavigateToPose.Result.SUCCEEDED
assert navigation_result.stop_state == NavigateToPose.Result.STOP_CONFIRMED
assert actual_poses == expected_poses
row['direct_navigation_goals'] = len(navigation_rows)
assert motion_counts()['execute_manipulation'] == 2
if recovery_enabled:
stop()
@@ -228,11 +246,11 @@ def run_route(root, binary, output, suffix, recovery_enabled):
unknown_holding=unknown.error_code, recovered=state)
if suffix == 'object_table':
with server.lock:
server.scenarios['navigate_semantic'] = [{'kind': 'normal', 'duration_seconds': 3., 'final_pose': site['locations']['shelf_A_stop']}]
server.scenarios['navigate'] = [{'kind': 'normal', 'duration_seconds': 3., 'final_pose': site['locations']['shelf_A_stop']}]
canceled_goal = task_goal('cancel')
canceled_handle = wait(client.send_goal_async(canceled_goal), 8)
assert canceled_handle.accepted
eventually(lambda: motion_counts()['navigate_semantic'] > before['navigate_semantic'])
eventually(lambda: motion_counts()['navigate'] > before['navigate'])
wait(canceled_handle.cancel_goal_async(), 5)
canceled_result = wait(canceled_handle.get_result_async(), 15)
assert canceled_result.status == 5 and canceled_result.result.result.stop_state == 1
@@ -267,7 +285,7 @@ def run_route(root, binary, output, suffix, recovery_enabled):
if suffix == 'shelf_cell':
with server.lock:
server.scenarios['locate_shelf_column'] = [{'kind': 'ambiguous'}]
server.scenarios['navigate_semantic'] = [{'kind': 'normal', 'final_pose': site['locations']['observe_A']}]
server.scenarios['navigate'] = [{'kind': 'normal', 'final_pose': site['locations']['observe_A']}]
before_ask = motion_counts()
ask_handle = wait(client.send_goal_async(task_goal('ambiguous')), 8)
assert ask_handle.accepted
+37 -33
View File
@@ -81,14 +81,16 @@ def _make_class(name, lines):
if kwargs:
raise TypeError("unexpected fields: " + ", ".join(kwargs))
attrs = {"__slots__": slots, "__init__": init, **constants}
attrs = {"__slots__": slots, "__init__": init, **constants,
"get_fields_and_field_types": classmethod(lambda cls: {field: kind for kind, field in fields})}
return type(name, (), attrs)
def install():
"""Install deterministic rclpy and generated interface modules."""
_registry.clear()
for name in list(sys.modules):
if name == "rclpy" or name.startswith("rclpy.") or name.startswith("bt_skill_interfaces"):
if name == "rclpy" or name.startswith("rclpy.") or name.startswith(("bt_skill_interfaces", "navigation_interfaces")):
del sys.modules[name]
builtin = _module("builtin_interfaces")
@@ -127,39 +129,41 @@ def install():
setattr(geometry_msg, name, cls)
_registry[f"geometry_msgs/{name}"] = cls
package = _module("bt_skill_interfaces")
msg_module = _module("bt_skill_interfaces.msg")
package.msg = msg_module
pending = {p.stem: _fields(p) for p in (IDL / "msg").glob("*.msg")}
while pending:
progress = False
for name, lines in list(pending.items()):
deps = [line.split()[0].removesuffix("[]") for line in lines if "=" not in line]
if all(dep in _registry or dep in ("string", "bool") or dep.startswith(("uint", "int", "float")) for dep in deps):
cls = _make_class(name, lines)
setattr(msg_module, name, cls)
_registry[f"bt_skill_interfaces/{name}"] = cls
del pending[name]
progress = True
if not progress:
raise RuntimeError("unresolved IDL: " + repr(pending))
for package_name in ("bt_skill_interfaces", "navigation_interfaces"):
package_idl = IDL.parent / package_name
package = _module(package_name)
msg_module = _module(f"{package_name}.msg")
package.msg = msg_module
pending = {p.stem: _fields(p) for p in (package_idl / "msg").glob("*.msg")}
while pending:
progress = False
for name, lines in list(pending.items()):
deps = [line.split()[0].removesuffix("[]") for line in lines if "=" not in line]
if all(dep in _registry or dep in ("string", "bool") or dep.startswith(("uint", "int", "float")) for dep in deps):
cls = _make_class(name, lines)
setattr(msg_module, name, cls)
_registry[f"{package_name}/{name}"] = cls
del pending[name]
progress = True
if not progress:
raise RuntimeError("unresolved IDL: " + repr(pending))
action_module = _module("bt_skill_interfaces.action")
package.action = action_module
for path in (IDL / "action").glob("*.action"):
action = type(path.stem, (), {})
action.Goal = _make_class("Goal", _fields(path, 0))
action.Result = _make_class("Result", _fields(path, 1))
action.Feedback = _make_class("Feedback", _fields(path, 2))
setattr(action_module, path.stem, action)
action_module = _module(f"{package_name}.action")
package.action = action_module
for path in (package_idl / "action").glob("*.action"):
action = type(path.stem, (), {})
action.Goal = _make_class("Goal", _fields(path, 0))
action.Result = _make_class("Result", _fields(path, 1))
action.Feedback = _make_class("Feedback", _fields(path, 2))
setattr(action_module, path.stem, action)
srv_module = _module("bt_skill_interfaces.srv")
package.srv = srv_module
for path in (IDL / "srv").glob("*.srv"):
srv = type(path.stem, (), {})
srv.Request = _make_class("Request", _fields(path, 0))
srv.Response = _make_class("Response", _fields(path, 1))
setattr(srv_module, path.stem, srv)
srv_module = _module(f"{package_name}.srv")
package.srv = srv_module
for path in (package_idl / "srv").glob("*.srv"):
srv = type(path.stem, (), {})
srv.Request = _make_class("Request", _fields(path, 0))
srv.Response = _make_class("Response", _fields(path, 1))
setattr(srv_module, path.stem, srv)
rclpy = _module("rclpy")
rclpy.ok = lambda: True
-20
View File
@@ -1,7 +1,5 @@
"""DR semantic regression tests independent of ROS transport."""
import copy
import json
import math
import sys
import tempfile
import unittest
@@ -23,13 +21,6 @@ class DrSemanticsTests(unittest.TestCase):
for key in ('shelf_id','column_id','tier_id','station_binding_ref','expected_geometry_epoch'):
self.assertEqual(result[key],getattr(goal,key))
def test_semantic_navigation_accepts_positive_subsecond_budget(self):
from navigation_gateway.semantic_proxy import duration_seconds
from types import SimpleNamespace
self.assertAlmostEqual(duration_seconds(SimpleNamespace(sec=0,nanosec=500000000)),.5)
for sec,nanosec in [(0,0),(-1,1),(3600,1),(1,1000000000),(1,-1)]:
with self.assertRaises(ValueError):duration_seconds(SimpleNamespace(sec=sec,nanosec=nanosec))
def test_shelf_tier_can_be_empty_without_guessing(self):
# BT p15 and RB02 allow an unknown tier; execution must decide whether
# its chosen route can use the observation without that calibration.
@@ -48,17 +39,6 @@ class DrSemanticsTests(unittest.TestCase):
self.assertEqual(result['status'], 'SUCCEEDED')
self.assertEqual(result['tier_id'], '')
def test_navigation_reports_signed_shortest_yaw_error(self):
from navigation_gateway.gateway import pose_errors
from test_navigation_gateway import request
target = request()['target_pose']
current = copy.deepcopy(target)
current['orientation'].update(z=math.sin(.2/2), w=math.cos(.2/2))
distance, yaw = pose_errors(target, current)
self.assertEqual(distance, 0)
self.assertAlmostEqual(yaw, -.2)
current['orientation'].update(z=math.sin(-.2/2), w=math.cos(-.2/2))
self.assertAlmostEqual(pose_errors(target, current)[1], .2)
if __name__ == '__main__':
+80 -69
View File
@@ -1,4 +1,5 @@
import json
import math
import pathlib
import sys
import threading
@@ -34,9 +35,6 @@ class MockRuntimeTests(unittest.TestCase):
goal.target_pose.header.frame_id = "map"
goal.target_pose.pose.orientation.w = 1.0
goal.position_tolerance = goal.yaw_tolerance = 0.1
elif name == "navigate_semantic":
goal.kind, goal.reference, goal.registry_version = "LOCATION", "bin_A", 1
goal.position_tolerance = goal.orientation_tolerance = 0.1
elif name == "execute_manipulation":
goal.skill, goal.instruction = "pick", "pick bottle"
goal.target.object_ref, goal.target.description = "bottle", "bottle"
@@ -78,8 +76,8 @@ class MockRuntimeTests(unittest.TestCase):
self.node._accepted(name, handle)
return handle, self.node._execute(name, handle)
def test_all_twelve_actions_execute_success_and_failure(self):
self.assertEqual(len(self.module.ACTION_TYPES), 12)
def test_all_eleven_actions_execute_success_and_failure(self):
self.assertEqual(len(self.module.ACTION_TYPES), 11)
self.assertEqual(self.module.ACTION_ENDPOINTS["plan_task"], "tasks/plan")
self.assertEqual(self.module.ACTION_ENDPOINTS["execute_task"], "tasks/execute")
self.assertEqual(self.module.ACTION_ENDPOINTS["evaluate_progress"], "monitor/evaluate_progress")
@@ -89,7 +87,7 @@ class MockRuntimeTests(unittest.TestCase):
handle, result = self.execute(name, {"kind": positive_kind, "duration_seconds": 0})
self.assertEqual(handle.native, "succeeded")
if hasattr(result, "result"):
self.assertEqual(result.result.status, result.result.SUCCEEDED if name == "navigate" else result.result.COMPLETED)
self.assertEqual(result.result.status, result.result.COMPLETED)
self.assertEqual(result.result.stop_state, result.result.CONFIRMED)
elif hasattr(result, "status"):
self.assertNotEqual(result.status, result.FAILED)
@@ -118,7 +116,7 @@ class MockRuntimeTests(unittest.TestCase):
def test_success_feedback_follows_normal_lifecycle_and_populates_fields(self):
cases = {
"navigate": [0, 1, 2],
"navigate": [0, 1, 2, 3],
"execute_manipulation": [0, 1, 2, 3, 4],
"execute_posture": [0, 1, 2],
}
@@ -131,7 +129,7 @@ class MockRuntimeTests(unittest.TestCase):
self.assertTrue(all(item.stamp.sec > 0 and item.message for item in handle.feedback))
self.assertNotIn(getattr(self.module.ACTION_TYPES[name].Feedback, "STOPPING"), expected)
nav = self.execute("navigate", {"kind": "normal", "duration_seconds": 0.05})[0].feedback[-1]
self.assertTrue(nav.current_pose_valid and nav.error_valid)
self.assertTrue(nav.current_pose_valid and nav.error_valid and nav.blocked_valid)
self.assertFalse(nav.blocked)
self.assertGreaterEqual(nav.elapsed_time.nanosec, 0)
manipulation = self.execute("execute_manipulation", {"kind": "normal", "duration_seconds": 0.05})[0].feedback[-1]
@@ -139,11 +137,11 @@ class MockRuntimeTests(unittest.TestCase):
def test_each_action_feedback_lifecycle_and_navigation_recovery_execute(self):
expected = {
"navigate": [0, 1, 2], "execute_manipulation": [0, 1, 2, 3, 4],
"navigate": [0, 1, 2, 3], "execute_manipulation": [0, 1, 2, 3, 4],
"plan_task": [0, 1, 2], "locate_shelf_column": [0, 1],
"localize_target_3d": [0, 1], "check_free_space": [0, 1],
"assess_grasp": [0], "execute_posture": [0, 1, 2], "verify_state": [0],
"navigate_semantic": [0], "evaluate_progress": [0],
"evaluate_progress": [0],
}
for name, phases in expected.items():
self.node.counts[name] = 0
@@ -153,8 +151,8 @@ class MockRuntimeTests(unittest.TestCase):
self.assertEqual([item.phase for item in handle.feedback], phases)
self.node.counts["navigate"] = 0
recovery, _ = self.execute("navigate", {"kind": "obstacle_recovery", "duration_seconds": 0.7})
self.assertEqual([item.phase for item in recovery.feedback], [0, 1, 2, 3, 2])
self.assertEqual([item.blocked for item in recovery.feedback], [False, False, False, True, False])
self.assertEqual([item.phase for item in recovery.feedback], [0, 1, 2, 3, 4, 3])
self.assertEqual([item.blocked for item in recovery.feedback], [False, False, False, False, True, False])
self.node.counts["execute_task"] = 0
task, _ = self.execute("execute_task", {"kind": "normal", "duration_seconds": 0.05})
self.assertTrue(task.feedback[0].stage)
@@ -173,22 +171,22 @@ class MockRuntimeTests(unittest.TestCase):
self.assertTrue(thread.is_alive(), "cancel ACK must precede delayed stop termination")
thread.join(1)
result = box["result"]
self.assertEqual((handle.native, result.result.status, result.result.stop_state),
("canceled", result.result.CANCELED, result.result.CONFIRMED))
self.assertEqual(handle.feedback[-1].phase, self.module.Navigate.Feedback.STOPPING)
self.assertEqual((handle.native, result.status, result.stop_state),
("canceled", result.CANCELED, result.STOP_CONFIRMED))
self.assertEqual(handle.feedback[-1].phase, self.module.NavigateToPose.Feedback.STOPPING)
self.node.motion_reserved = False; self.node.counts["navigate"] = 0
goal = self.goal("navigate"); goal.timeout.sec = 0; goal.timeout.nanosec = 1_000_000
self.node.scenarios["navigate"] = [{"kind": "timeout"}]
self.assertEqual(self.node._goal("navigate", goal), self.module.GoalResponse.ACCEPT)
timeout_handle = GoalHandle(goal); self.node._accepted("navigate", timeout_handle)
timeout_result = self.node._execute("navigate", timeout_handle)
self.assertEqual((timeout_handle.native, timeout_result.result.status), ("aborted", timeout_result.result.TIMEOUT))
self.assertEqual(timeout_handle.feedback[-1].phase, self.module.Navigate.Feedback.STOPPING)
self.assertEqual((timeout_handle.native, timeout_result.status), ("aborted", timeout_result.TIMEOUT))
self.assertEqual(timeout_handle.feedback[-1].phase, self.module.NavigateToPose.Feedback.STOPPING)
self.node.motion_reserved = False; self.node.counts["navigate"] = 0
unknown_handle, unknown = self.execute("navigate", {"kind": "stop_unknown", "duration_seconds": 0})
self.assertEqual(unknown.result.stop_state, unknown.result.UNKNOWN)
self.assertEqual(unknown.stop_state, unknown.STOP_UNKNOWN)
self.assertTrue(self.node.motion_reserved)
self.assertEqual(unknown.result.stop_evidence_ref, "")
self.assertEqual(unknown.stop_evidence_ref, "")
def test_cancel_and_timeout_can_acknowledge_without_confirming_stop(self):
handle, canceled = self.execute(
@@ -203,7 +201,7 @@ class MockRuntimeTests(unittest.TestCase):
self.assertEqual(self.node._goal("navigate", goal), self.module.GoalResponse.ACCEPT)
timeout_handle = GoalHandle(goal); self.node._accepted("navigate", timeout_handle)
timed_out = self.node._execute("navigate", timeout_handle)
self.assertEqual(timed_out.result.stop_state, timed_out.result.UNKNOWN)
self.assertEqual(timed_out.stop_state, timed_out.STOP_UNKNOWN)
self.assertTrue(self.node.motion_reserved)
def test_navigation_status_mapping_and_readiness_reasons(self):
@@ -211,7 +209,7 @@ class MockRuntimeTests(unittest.TestCase):
("timeout", 2, "aborted", "MOCK_TERMINATED"), ("blocked", 3, "aborted", "BLOCKED"),
("failed", 5, "aborted", "MOCK_TERMINATED")]
cases += [("not_ready", 4, "aborted", code) for code in
("INPUTS_UNHEALTHY", "ROBOT_STATE_UNAVAILABLE", "BACKEND_NOT_CONFIGURED",
("INPUTS_UNHEALTHY", "ROBOT_STATE_UNAVAILABLE", "EXECUTION_BACKEND_NOT_CONFIGURED",
"ROBOT_EMERGENCY_STOP", "ROBOT_PROTECTIVE_STOP", "ROBOT_MOTION_NOT_ALLOWED")]
for kind, status, native, code in cases:
self.node.counts["navigate"] = 0
@@ -220,17 +218,17 @@ class MockRuntimeTests(unittest.TestCase):
self.module.parse_scenarios(json.dumps({"navigate": fixture}))
handle, result = self.execute("navigate", fixture, cancel=kind == "canceled")
with self.subTest(kind=kind, code=code):
self.assertEqual((result.result.status, handle.native), (status, native))
self.assertEqual(result.result.error_code, code)
self.assertEqual((result.status, handle.native), (status, native))
self.assertEqual(result.error_code, code)
self.assertEqual(result.final_pose_valid, status == 0)
if result.final_pose_valid:
self.assertEqual((result.final_position_error, result.final_yaw_error), (0.0, 0.0))
self.assertFalse(hasattr(result, "errors_valid"))
self.node.counts["navigate"] = 0
_, result = self.execute("navigate", {"kind": "stop_unknown", "duration_seconds": 0})
self.assertEqual((result.result.status, result.result.stop_state), (5, 0))
self.assertEqual((result.result.stopped_at.sec, result.result.stopped_at.nanosec,
result.result.stop_evidence_ref), (0, 0, ""))
self.assertEqual((result.status, result.stop_state), (5, 0))
self.assertEqual((result.stopped_at.sec, result.stopped_at.nanosec,
result.stop_evidence_ref), (0, 0, ""))
def test_navigation_goal_requires_map_and_finite_nonzero_quaternion(self):
mutations = (
@@ -256,7 +254,7 @@ class MockRuntimeTests(unittest.TestCase):
self.assertEqual(self.node._goal("navigate", goal), self.module.GoalResponse.ACCEPT)
handle = GoalHandle(goal); self.node._accepted("navigate", handle)
result = self.node._execute("navigate", handle)
self.assertEqual(result.result.status, 0)
self.assertEqual(result.status, 0)
self.assertTrue(result.final_pose_valid)
self.assertEqual(result.final_pose.pose.orientation.w, 1.0)
self.assertTrue(handle.feedback)
@@ -266,7 +264,6 @@ class MockRuntimeTests(unittest.TestCase):
def test_action_specific_malformed_goals_are_rejected(self):
mutations = {
"navigate": lambda g: setattr(g.target_pose.header, "frame_id", ""),
"navigate_semantic": lambda g: setattr(g, "registry_version", 0),
"execute_manipulation": lambda g: setattr(g.target, "object_ref", ""),
"execute_posture": lambda g: setattr(g, "expected_geometry_epoch", 0),
"plan_task": lambda g: setattr(g, "known_info_json", "[]"),
@@ -283,56 +280,65 @@ class MockRuntimeTests(unittest.TestCase):
with self.subTest(action=name):
self.assertEqual(self.node._goal(name, goal), self.module.GoalResponse.REJECT)
def test_semantic_navigation_accepts_production_kind_matrix_only(self):
accepted = (
("LOCATION", "destination_A", {}),
("OBJECT", "bottle", {}),
("CELL", "bottle", {"shelf_id": "shelf_A", "side_id": "FRONT",
"column_id": "1", "tier_id": "2"}),
)
for kind, reference, fields in accepted:
goal = self.goal("navigate_semantic")
goal.kind, goal.reference = kind, reference
for field, value in fields.items(): setattr(goal, field, value)
with self.subTest(kind=kind):
self.assertEqual(self.node._goal("navigate_semantic", goal), self.module.GoalResponse.ACCEPT)
self.node.inflight, self.node.motion_reserved = 0, False
cell = self.goal("navigate_semantic"); cell.kind = "CELL"
self.assertEqual(self.node._goal("navigate_semantic", cell), self.module.GoalResponse.REJECT)
for invented in ("region", "shelf", "station"):
goal = self.goal("navigate_semantic"); goal.kind = invented
with self.subTest(invented=invented):
self.assertEqual(self.node._goal("navigate_semantic", goal), self.module.GoalResponse.REJECT)
def test_semantic_navigation_pose_is_explicit_and_validated(self):
def test_navigation_explicit_pose_preserves_measurements_and_epoch(self):
pose = {"frame_id": "map", "x": 1.25, "y": -2.5, "z": 0.0,
"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0}
_, explicit = self.execute("navigate_semantic", {"kind": "normal", "duration_seconds": 0,
"final_pose": pose})
self.assertTrue(explicit.pose_valid and explicit.errors_valid)
self.assertEqual(explicit.final_pose.header.frame_id, "map")
self.assertEqual((explicit.final_pose.pose.position.x, explicit.final_pose.pose.position.y), (1.25, -2.5))
self.node.counts["navigate_semantic"] = 0
_, unspecified = self.execute("navigate_semantic", {"kind": "normal", "duration_seconds": 0})
self.assertFalse(unspecified.pose_valid)
self.assertFalse(unspecified.errors_valid)
for raw in (
'{"navigate_semantic":{"final_pose":{"frame_id":"map","x":NaN,"y":0,"z":0,"qx":0,"qy":0,"qz":0,"qw":1}}}',
'{"navigate_semantic":{"final_pose":{"frame_id":"map","x":1,"y":2,"z":0,"qx":0,"qy":0,"qz":0,"qw":0}}}',
):
with self.subTest(raw=raw), self.assertRaises(ValueError):
self.module.parse_scenarios(raw)
fixture = {"kind": "normal", "duration_seconds": 0, "final_pose": pose}
self.module.parse_scenarios(json.dumps({"navigate": fixture}))
before = self.node.geometry_epoch
_, result = self.execute("navigate", fixture)
self.assertEqual(result.status, result.SUCCEEDED)
self.assertTrue(result.final_pose_valid)
self.assertEqual((result.final_pose.pose.position.x, result.final_pose.pose.position.y), (1.25, -2.5))
self.assertGreater(result.final_position_error, 2.5)
self.assertEqual(result.final_yaw_error, 0.0)
self.assertEqual(self.node.geometry_epoch, before + 1)
self.assertEqual(result.stop_state, result.STOP_CONFIRMED)
self.assertTrue(result.stop_evidence_ref)
self.assertFalse(hasattr(result, "result"))
for invalid in (dict(pose, x=float("nan")), dict(pose, qw=0.0), dict(pose, frame_id="odom")):
with self.subTest(pose=invalid), self.assertRaises(ValueError):
self.module.parse_scenarios(json.dumps({"navigate": {"final_pose": invalid}}))
def test_navigation_final_yaw_error_is_signed_shortest_target_minus_current(self):
for target_deg, current_deg, expected_deg in ((30, 10, 20), (10, 30, -20),
(-170, 170, 20), (170, -170, -20)):
with self.subTest(target=target_deg, current=current_deg):
target, current = math.radians(target_deg), math.radians(current_deg)
goal = self.goal("navigate")
goal.target_pose.pose.orientation.z = math.sin(target / 2)
goal.target_pose.pose.orientation.w = math.cos(target / 2)
fixture = {"kind": "normal", "duration_seconds": 0, "final_pose": {
"frame_id": "map", "x": 0.0, "y": 0.0, "z": 0.0,
"qx": 0.0, "qy": 0.0, "qz": math.sin(current / 2), "qw": math.cos(current / 2)}}
self.module.parse_scenarios(json.dumps({"navigate": fixture}))
self.node.scenarios["navigate"] = [fixture]
self.assertEqual(self.node._goal("navigate", goal), self.module.GoalResponse.ACCEPT)
handle = GoalHandle(goal)
self.node._accepted("navigate", handle)
result = self.node._execute("navigate", handle)
self.assertEqual(result.status, result.SUCCEEDED)
self.assertAlmostEqual(result.final_yaw_error, math.radians(expected_deg), places=12)
def test_navigation_goal_has_native_identity_without_trace(self):
goal = self.goal("navigate")
self.assertFalse(hasattr(goal, "trace"))
for field in ("task_id", "subtask_id"):
invalid = self.goal("navigate")
setattr(invalid, field, "")
with self.subTest(field=field):
self.assertEqual(self.node._goal("navigate", invalid), self.module.GoalResponse.REJECT)
def test_enabled_interfaces_can_exclude_executor_owned_endpoints(self):
module = load_mock_module({
"scenarios_json": "{}", "max_goal_seconds": 1.0,
"allowed_postures": ["pregrasp", "transport", "home"],
"initial_holding_state": "UNKNOWN",
"enabled_actions": ["plan_task", "navigate_semantic"],
"enabled_actions": ["plan_task", "navigate"],
"enabled_topics": ["robot_state"],
})
node = module.MockSkills()
self.assertEqual(node.enabled_actions, ("plan_task", "navigate_semantic"))
self.assertEqual(node.enabled_actions, ("plan_task", "navigate"))
self.assertEqual(len(node.servers), 2)
self.assertTrue(hasattr(node, "state_pub"))
self.assertFalse(hasattr(node, "registry_pub"))
@@ -352,7 +358,7 @@ class MockRuntimeTests(unittest.TestCase):
self.assertEqual(missing.error_code, "UNKNOWN_ROBOT")
handle, unresolved = self.execute("navigate", {"kind": "stop_unknown", "duration_seconds": 0})
goal_id = bytes(handle.goal_id.uuid).hex()
self.assertEqual(unresolved.result.stop_state, unresolved.result.UNKNOWN)
self.assertEqual(unresolved.stop_state, unresolved.STOP_UNKNOWN)
req = srv.ReconcileGoal.Request(trace=self.trace(), goal_id="never-accepted", operator_id="op", reason="review")
req.evidence.status = req.evidence.PASSED
req.evidence.context.source_goal_id = "never-accepted"
@@ -367,6 +373,11 @@ class MockRuntimeTests(unittest.TestCase):
self.assertFalse(rejected.accepted)
self.assertTrue(self.node.motion_reserved)
req.goal_id = req.evidence.context.source_goal_id = goal_id
req.trace.subtask_id = req.evidence.context.trace.subtask_id = "other"
wrong_identity = self.node._reconcile(req, srv.ReconcileGoal.Response())
self.assertFalse(wrong_identity.accepted)
self.assertTrue(self.node.motion_reserved)
req.trace.subtask_id = req.evidence.context.trace.subtask_id = "s"
accepted = self.node._reconcile(req, srv.ReconcileGoal.Response())
self.assertTrue(accepted.accepted)
self.assertFalse(self.node.motion_reserved)
@@ -445,7 +456,7 @@ class MockRuntimeTests(unittest.TestCase):
self.node.counts["navigate"] = 0
handle, result = self.execute("navigate", {"kind": "native_mismatch", "duration_seconds": 0})
self.assertEqual(handle.native, "aborted")
self.assertEqual(result.result.status, result.result.SUCCEEDED)
self.assertEqual(result.status, result.SUCCEEDED)
def test_topic_and_result_fixture_values_are_finite_and_bounded(self):
for raw in (
-42
View File
@@ -1,42 +0,0 @@
import unittest
class CatalogTests(unittest.TestCase):
def test_names_cells_and_version_are_exact(self):
from navigation_gateway.catalog import Catalog
site={'registry_version':3,'locations':{'stop':{'frame_id':'map','x':1,'y':2,'z':0,'qx':0,'qy':0,'qz':0,'qw':1}},'object_locations':{'water':'stop'},'cell_locations':{'s/FRONT/1/2':'stop'}}
c=Catalog(site)
self.assertEqual(c.resolve('OBJECT','water',3)['pose']['x'],1)
self.assertEqual(c.resolve('CELL','',3,shelf='s',side='FRONT',column='1',tier='2')['location_id'],'stop')
for args in [('OBJECT','unknown',3),('OBJECT','water',2),('LOCATION','water',3)]:
with self.assertRaises(ValueError):c.resolve(*args)
class SemanticTranslationTests(unittest.TestCase):
def test_navigation_status_is_translated_not_copied(self):
from types import SimpleNamespace as NS
from navigation_gateway import semantic_proxy as m
nav = NS(SUCCEEDED=0, CANCELED=1, TIMEOUT=2, BLOCKED=3, NOT_READY=4, FAILED=5, UNKNOWN=0, CONFIRMED=1)
old = NS(COMPLETED=0, FAILED=1, CANCELED=2, TIMED_OUT=3, REJECTED=4, UNKNOWN=0, CONFIRMED=1)
self.assertTrue(hasattr(m, 'translate_navigation_result'))
for status, expected in ((0,0),(1,2),(2,3),(3,1),(4,4),(5,1)):
source = NS(result=NS(status=status, stop_state=1, error_code='INPUTS_UNHEALTHY', message='source', stopped_at=NS(sec=123,nanosec=9), stop_evidence_ref='proof'), final_pose_valid=False, final_pose=object(), final_position_error=float('nan'), final_yaw_error=float('nan'))
target = NS(result=NS())
m.translate_navigation_result(source, target, nav, old)
self.assertEqual(target.result.status, expected)
self.assertEqual((target.result.error_code, target.result.stop_state, target.result.stopped_at.sec, target.result.stop_evidence_ref), ('INPUTS_UNHEALTHY',1,123,'proof'))
self.assertFalse(target.errors_valid)
def test_invalid_pose_does_not_make_default_zero_errors_valid(self):
from types import SimpleNamespace as NS
from navigation_gateway.semantic_proxy import translate_navigation_result
nav=NS(SUCCEEDED=0,CANCELED=1,TIMEOUT=2,BLOCKED=3,NOT_READY=4,FAILED=5,UNKNOWN=0,CONFIRMED=1)
old=NS(COMPLETED=0,FAILED=1,CANCELED=2,TIMED_OUT=3,REJECTED=4,UNKNOWN=0,CONFIRMED=1)
for status,code in [(3,'NAV_BLOCKED'),(4,'NAV_NOT_READY')]:
source=NS(result=NS(status=status,stop_state=0,error_code='',message='',stopped_at=None,stop_evidence_ref=''),final_pose_valid=False,final_pose=None,final_position_error=0.,final_yaw_error=0.)
target=NS(result=NS())
translate_navigation_result(source,target,nav,old)
self.assertFalse(target.errors_valid)
self.assertEqual(target.result.error_code,code)
source.final_pose_valid=True
for p,y in [(-1.,0.),(0.,4.)]:
source.final_position_error=p;source.final_yaw_error=y
translate_navigation_result(source,target,nav,old)
self.assertFalse(target.errors_valid)
-646
View File
@@ -1,646 +0,0 @@
"""Safety contract tests; no ROS installation or robot required."""
import copy
import json
import sys
import tempfile
import threading
import unittest
import urllib.error
import urllib.request
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
try:
from navigation_gateway.gateway import Gateway, GatewayError, SafetyConfig, validate_goal
from navigation_gateway.backends import MockBackend
from navigation_gateway.server import make_server
IMPORT_ERROR = None
except ImportError as exc:
IMPORT_ERROR = str(exc)
class Clock:
def __init__(self): self.now = 100.0
def __call__(self): return self.now
def advance(self, seconds): self.now += seconds
def request(goal_id="11111111-1111-4111-8111-111111111111"):
return {"goal_id": goal_id, "trace": {"task_id": "task-1", "subtask_id": "navigate", "attempt": 1},
"map_id": "sim-map", "target_pose": {"frame_id": "map", "position": {"x": 1.0, "y": 2.0, "z": 0.0},
"orientation": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0}},
"position_tolerance": 0.05, "yaw_tolerance": 0.05, "timeout_sec": 10.0}
class GatewayTests(unittest.TestCase):
def setUp(self):
self.assertIsNone(IMPORT_ERROR, "navigation gateway implementation missing: " + str(IMPORT_ERROR))
self.tmp = tempfile.TemporaryDirectory()
self.clock = Clock()
self.config = SafetyConfig(odom_max_age_sec=0.2, stationary_window_sec=0.3,
linear_stopped_mps=0.005, angular_stopped_radps=0.005, pose_max_age_sec=0.2,
readiness_max_age_sec=0.5, stop_wait_timeout_sec=1.0)
self.backend = MockBackend(self.clock, map_id="sim-map", source_clock=lambda: 1700000000.0 + self.clock())
self.path = str(Path(self.tmp.name) / "journal.sqlite3")
self.gateway = Gateway(self.path, self.backend, self.config, self.clock)
self.addCleanup(self.tmp.cleanup)
self.addCleanup(lambda: self.gateway.close())
def sample(self, state="ACTIVE", linear=0.0, pose=None, fresh=True):
self.backend.health_sample(True)
self.backend.set_snapshot(request()["goal_id"], state, linear=linear, angular=0.0,
pose=pose or request()["target_pose"], source_fresh=fresh)
return self.gateway.poll(request()["goal_id"])
def stop_window(self, state="SUCCEEDED", pose=None):
out = self.sample(state, pose=pose)
for _ in range(4):
self.clock.advance(0.1)
out = self.sample(state, pose=pose)
return out
def test_goal_is_sent_once_and_replay_is_immutable(self):
first = self.gateway.submit(request())
self.assertEqual(first["goal_id"], self.gateway.submit(copy.deepcopy(request()))["goal_id"])
self.assertEqual(self.backend.send_count, 1)
changed = request(); changed["timeout_sec"] = 8
with self.assertRaises(GatewayError) as cm: self.gateway.submit(changed)
self.assertEqual(cm.exception.http_status, 409)
self.assertEqual(self.backend.send_count, 1)
def test_goal_validation_rejects_nonfinite_bad_quaternion_and_duration(self):
for path, value in [(('position_tolerance',), float('nan')), (('yaw_tolerance',), -1),
(('timeout_sec',), float('inf')), (('timeout_sec',), 0),
(('target_pose', 'position', 'x'), float('inf')),
(('target_pose', 'orientation', 'w'), 0), (('target_pose', 'frame_id'), 'odom')]:
body = request(); node = body
for key in path[:-1]: node = node[key]
node[path[-1]] = value
with self.subTest(path=path, value=value), self.assertRaises(GatewayError): validate_goal(body)
def test_readiness_cannot_be_claimed_by_http_caller(self):
self.backend.health_sample(False)
with self.assertRaises(GatewayError): self.gateway.submit(request())
self.assertEqual(self.backend.send_count, 0)
body = request(); body["ready"] = True
with self.assertRaises(GatewayError): self.gateway.submit(body)
def test_stale_health_and_wrong_map_reject_before_sending(self):
self.clock.advance(1)
with self.assertRaises(GatewayError): self.gateway.submit(request())
self.backend.health_sample(True)
body = request(); body["map_id"] = "another-map"
with self.assertRaises(GatewayError): self.gateway.submit(body)
self.assertEqual(self.backend.send_count, 0)
def test_cancel_ack_is_not_stop_confirmation_and_cancel_is_idempotent(self):
self.gateway.submit(request())
out = self.gateway.cancel(request()["goal_id"])
self.assertEqual(out["stop_state"], "UNKNOWN")
self.gateway.cancel(request()["goal_id"])
self.assertEqual(self.backend.cancel_count, 1)
self.assertEqual(self.stop_window("ACTIVE")["stop_state"], "UNKNOWN")
stopped = self.stop_window("PREEMPTED")
self.assertEqual((stopped["outcome"], stopped["stop_state"]), ("CANCELED", "CONFIRMED"))
def test_success_requires_terminal_pose_and_continuous_fresh_odom(self):
self.gateway.submit(request())
out = self.sample("SUCCEEDED")
self.assertEqual(out["stop_state"], "UNKNOWN")
self.clock.advance(0.5)
self.assertEqual(self.gateway.poll(request()["goal_id"])["stop_state"], "UNKNOWN")
out = self.stop_window()
self.assertEqual((out["outcome"], out["stop_state"]), ("COMPLETED", "CONFIRMED"))
def test_stale_source_odom_never_confirms_stop(self):
self.gateway.submit(request())
for _ in range(10):
self.clock.advance(0.1)
out = self.sample("SUCCEEDED", fresh=False)
self.assertEqual(out["stop_state"], "UNKNOWN")
def test_moving_sample_resets_stationary_window(self):
self.gateway.submit(request()); self.sample("SUCCEEDED")
self.clock.advance(0.15); self.sample("SUCCEEDED", linear=0.1)
self.clock.advance(0.15); self.sample("SUCCEEDED")
self.clock.advance(0.15)
self.assertEqual(self.sample("SUCCEEDED")["stop_state"], "UNKNOWN")
def test_odom_samples_between_polls_cannot_hide_motion(self):
self.gateway.submit(request()); self.sample("SUCCEEDED")
for _ in range(2):
self.clock.advance(0.1)
self.sample("SUCCEEDED")
self.clock.advance(0.05)
self.backend.set_snapshot(request()["goal_id"], "SUCCEEDED", linear=0.1, pose=request()["target_pose"])
self.clock.advance(0.1)
self.backend.set_snapshot(request()["goal_id"], "SUCCEEDED", linear=0.0, pose=request()["target_pose"])
out = self.gateway.poll(request()["goal_id"])
self.assertEqual(out["stop_state"], "UNKNOWN")
def test_readiness_loss_during_motion_requests_stop(self):
self.gateway.submit(request())
self.backend.health_sample(False)
out = self.gateway.poll(request()["goal_id"])
self.assertEqual(self.backend.cancel_count, 1)
self.assertEqual(out["stop_state"], "UNKNOWN")
out = self.stop_window("PREEMPTED")
self.assertEqual(out["outcome"], "FAILED")
def test_success_with_health_lost_does_not_complete(self):
self.gateway.submit(request()); self.sample("SUCCEEDED")
self.backend.health_sample(False)
self.gateway.poll(request()["goal_id"])
out = self.stop_window("SUCCEEDED")
self.assertEqual(out["outcome"], "FAILED")
def test_success_outside_tolerance_becomes_failed_not_completed(self):
self.gateway.submit(request())
pose = copy.deepcopy(request()["target_pose"]); pose["position"]["x"] = 2.0
out = self.stop_window(pose=pose)
self.assertEqual((out["outcome"], out["stop_state"]), ("FAILED", "CONFIRMED"))
self.assertIn("tolerance", out["message"])
def test_execution_timeout_cancels_once_and_waits_for_actual_stop(self):
body = request(); body["timeout_sec"] = 0.2
self.gateway.submit(body); self.clock.advance(0.3)
out = self.gateway.poll(body["goal_id"])
self.assertEqual(out["stop_state"], "UNKNOWN")
self.assertEqual(self.backend.cancel_count, 1)
out = self.stop_window("PREEMPTED")
self.assertEqual(out["outcome"], "TIMED_OUT")
def test_ambiguous_send_is_quarantined_and_never_retried(self):
self.backend.send_mode = "UNKNOWN"
out = self.gateway.submit(request())
self.assertEqual(out["status"], "STOP_UNKNOWN")
self.gateway.submit(request())
with self.assertRaises(GatewayError): self.gateway.submit(request("22222222-2222-4222-8222-222222222222"))
self.assertEqual(self.backend.send_count, 1)
self.assertEqual(self.stop_window()["stop_state"], "UNKNOWN")
self.assertEqual(self.backend.cancel_count, 1)
def test_controller_state_loss_requests_cancel_and_keeps_lock(self):
self.gateway.submit(request())
self.backend.set_snapshot(request()["goal_id"], "LOST", pose=request()["target_pose"])
out = self.gateway.poll(request()["goal_id"])
self.assertEqual(self.backend.cancel_count, 1)
self.assertEqual(out["stop_state"], "UNKNOWN")
with self.assertRaises(GatewayError): self.gateway.submit(request("22222222-2222-4222-8222-222222222222"))
def test_second_process_cannot_open_the_same_robot_journal(self):
with self.assertRaises(RuntimeError): Gateway(self.path, self.backend, self.config, self.clock)
def test_completed_record_survives_restart_without_resending(self):
self.gateway.submit(request()); expected = self.stop_window()
self.gateway.close()
self.clock.advance(100)
self.gateway = Gateway(self.path, self.backend, self.config, self.clock)
actual = self.gateway.submit(request())
self.assertEqual(actual, expected)
self.assertEqual(self.backend.send_count, 1)
def test_stop_proof_keeps_original_source_timestamp_across_queries_and_restart(self):
self.gateway.submit(request())
out = self.stop_window()
self.assertEqual(out["stopped_at"], 1700000000.0 + self.clock())
original_stamp = out["stopped_at"]
self.clock.advance(50)
self.assertEqual(self.gateway.poll(request()["goal_id"])["stopped_at"], original_stamp)
self.gateway.close()
self.gateway = Gateway(self.path, self.backend, self.config, self.clock)
self.assertEqual(self.gateway.get(request()["goal_id"])["stopped_at"], original_stamp)
def test_rejected_goal_requires_stop_evidence(self):
self.backend.send_mode = "REJECTED"
out = self.gateway.submit(request())
self.assertEqual(out["stop_state"], "UNKNOWN")
out = self.stop_window("REJECTED")
self.assertEqual((out["outcome"], out["stop_state"]), ("REJECTED", "CONFIRMED"))
def test_restart_locks_unfinished_goal_and_does_not_resend(self):
self.gateway.submit(request()); self.gateway.close()
self.gateway = Gateway(self.path, self.backend, self.config, self.clock)
old = self.gateway.get(request()["goal_id"])
self.assertEqual(old["status"], "STOP_UNKNOWN")
self.assertTrue(old["quarantined"])
self.gateway.submit(request())
with self.assertRaises(GatewayError): self.gateway.submit(request("22222222-2222-4222-8222-222222222222"))
self.assertEqual(self.backend.send_count, 1)
def test_http_all_routes_require_bearer_and_goal_query_cancel_work(self):
server = make_server(self.gateway, "test-secret-token", host="127.0.0.1", port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start()
self.addCleanup(server.server_close); self.addCleanup(server.shutdown)
base = f"http://127.0.0.1:{server.server_address[1]}"
with self.assertRaises(urllib.error.HTTPError) as cm: urllib.request.urlopen(base + "/healthz")
self.assertEqual(cm.exception.code, 401)
def call(path, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(base + path, data=data,
headers={"Authorization": "Bearer test-secret-token", "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=2) as response: return json.load(response)
self.assertTrue(call("/healthz")["ready"])
self.assertEqual(call("/v1/goals", request())["goal_id"], request()["goal_id"])
self.assertEqual(call("/v1/goals/" + request()["goal_id"])["status"], "ACTIVE")
self.assertEqual(call("/v1/goals/" + request()["goal_id"] + "/cancel", {})["stop_state"], "UNKNOWN")
class RosSourceTimeTests(unittest.TestCase):
"""Exercise actual Noetic callbacks and gateway checks with injected clocks."""
def setUp(self):
from types import SimpleNamespace as NS
from unittest.mock import patch
from navigation_gateway.backends import Ros1MoveBaseBackend
self.NS = NS
self.ros_time, self.monotonic_time = [100.0], [10.0]
self.backend = Ros1MoveBaseBackend.__new__(Ros1MoveBaseBackend)
self.backend.rospy = NS(Time=NS(now=lambda: NS(to_sec=lambda: self.ros_time[0])))
self.backend.max_age = 1.0
self.backend.map_id = "sim-map"
self.backend.lock = threading.RLock()
self.backend.sequence = 0
self.backend.previous_odom_stamp = None
self.backend.odom_queue = []
self.gateway = Gateway.__new__(Gateway)
self.gateway.backend = self.backend
self.gateway.clock = lambda: self.monotonic_time[0]
self.patch = patch("navigation_gateway.backends.time.monotonic", lambda: self.monotonic_time[0])
self.patch.start()
self.addCleanup(self.patch.stop)
def receive_all(self, stamp):
NS = self.NS
header = NS(stamp=NS(to_sec=lambda: stamp), frame_id="map")
self.backend._odom(NS(header=header, twist=NS(twist=NS(linear=NS(x=0., y=0., z=0.), angular=NS(x=0., y=0., z=0.)))))
self.backend._pose(NS(header=header, pose=NS(position=NS(x=1., y=2., z=0.), orientation=NS(x=0., y=0., z=0., w=1.))))
self.backend._health(NS(data=json.dumps({"ready": True, "map_id": "sim-map", "stamp": stamp})))
return (self.backend.odom, self.backend.pose, self.backend.health_value)
def test_source_age_is_rechecked_even_when_receipt_is_fresh(self):
samples = self.receive_all(99.1)
self.assertTrue(all(self.gateway._fresh(sample, 1.0) for sample in samples))
self.ros_time[0], self.monotonic_time[0] = 100.5, 10.5
for sample in samples:
with self.subTest(sample=sample): self.assertFalse(self.gateway._fresh(sample, 1.0))
def test_backward_ros_jump_invalidates_old_epoch_even_when_age_is_in_range(self):
samples = self.receive_all(99.8)
self.ros_time[0], self.monotonic_time[0] = 99.9, 10.05
for sample in samples:
with self.subTest(sample=sample): self.assertFalse(self.gateway._fresh(sample, 1.0))
self.ros_time[0] = 100.1
self.assertTrue(all(not self.gateway._fresh(sample, 1.0) for sample in samples))
def test_future_source_time_is_never_admitted_later_as_a_cached_sample(self):
samples = self.receive_all(100.1)
self.assertTrue(all(not self.gateway._fresh(sample, 1.0) for sample in samples))
self.ros_time[0], self.monotonic_time[0] = 100.2, 10.1
self.assertTrue(all(not self.gateway._fresh(sample, 1.0) for sample in samples))
# HTTP proxy contract tests use real loopback sockets; ROS imports remain lazy.
from navigation_gateway import ros2_proxy as module
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ID = '11111111-1111-4111-8111-111111111111'
BODY = {'goal_id': ID, 'trace': {'task_id': 't', 'subtask_id': 's', 'attempt': 1}, 'map_id': 'map-a', 'target_pose': {'frame_id': 'map', 'position': {'x': 1., 'y': 2., 'z': 0.}, 'orientation': {'x': 0., 'y': 0., 'z': 0., 'w': 1.}}, 'position_tolerance': .1, 'yaw_tolerance': .2, 'timeout_sec': 2.}
def snapshot(**updates):
value = {'goal_id': ID, 'status': 'ACTIVE', 'outcome': None, 'stop_state': 'UNKNOWN', 'controller_state': 'ACTIVE', 'message': '', 'position_error': None, 'yaw_error': None, 'sequence': 1, 'elapsed': .1, 'stopped_at': 1234.5}
value.update(updates)
return value
class ProxyTests(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(module, 'ROS2 proxy behavior is not implemented')
self.requests = []
self.reply = snapshot()
self.delay = 0
self.dribble = False
self.cancel_reply = None
owner = self
class Handler(BaseHTTPRequestHandler):
def do_GET(self): self.respond()
def do_POST(self): self.respond()
def log_message(self, *args): pass
def respond(self):
raw = self.rfile.read(int(self.headers.get('Content-Length', '0')))
owner.requests.append((self.command, self.path, self.headers.get('Authorization'), json.loads(raw) if raw else None))
if owner.delay: time.sleep(owner.delay)
value = {'ready': True, 'reason': '', 'map_id': 'map-a'} if self.path == '/healthz' else copy.deepcopy(owner.reply)
if self.path.endswith('/cancel') and owner.cancel_reply is not None:
value = owner.cancel_reply
data = json.dumps(value).encode()
try:
self.send_response(200)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
if owner.dribble:
for byte in data:
self.wfile.write(bytes([byte]))
self.wfile.flush()
time.sleep(.005)
else:
self.wfile.write(data)
except (BrokenPipeError, ConnectionResetError): pass
self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
self.server.daemon_threads = True
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.config = module.ProxyConfig(endpoint='http://127.0.0.1:%d' % self.server.server_port, token='test-secret-token', connect_timeout_sec=.1, read_timeout_sec=.1, request_timeout_sec=.2, poll_interval_sec=.01, readiness_max_age_sec=.5, feedback_silence_timeout_sec=.5)
self.client = module.GatewayClient(self.config)
def tearDown(self):
if hasattr(self, 'server'):
self.server.shutdown()
self.server.server_close()
def wait_for(self, predicate):
deadline = time.monotonic() + 2
while time.monotonic() < deadline:
if predicate(): return
time.sleep(.005)
self.fail('condition not reached')
def test_body_is_frozen_and_all_http_routes_are_authenticated(self):
body = copy.deepcopy(BODY)
session = module.GoalSession(self.client, body)
body['trace']['task_id'] = 'changed'
self.client.health()
session.start()
self.wait_for(lambda: len(self.requests) >= 3)
self.assertEqual(self.requests[1][3]['trace']['task_id'], 't')
session.request_cancel()
self.wait_for(lambda: any(r[1].endswith('/cancel') for r in self.requests))
self.assertTrue(all(r[2] == 'Bearer test-secret-token' for r in self.requests))
self.assertEqual([r[3]['goal_id'] for r in self.requests if r[1] == '/v1/goals'], [ID])
self.reply = snapshot(status='TERMINAL', outcome='CANCELED', stop_state='CONFIRMED', controller_state='PREEMPTED', sequence=9)
events = []
self.wait_for(lambda: bool(events.extend(session.drain_events())) or any(e.terminal for e in events))
self.assertTrue(any(e.terminal and e.snapshot.outcome == 'CANCELED' for e in events))
def test_cancel_ack_and_unconfirmed_terminal_do_not_finish(self):
session = module.GoalSession(self.client, BODY)
session.start()
self.wait_for(lambda: len(self.requests) >= 2)
self.cancel_reply = snapshot(status='TERMINAL', outcome='CANCELED', controller_state='PREEMPTED', stop_state='CONFIRMED', sequence=8)
session.request_cancel()
self.wait_for(lambda: any(r[1].endswith('/cancel') for r in self.requests))
self.reply = snapshot(status='TERMINAL', outcome='CANCELED', controller_state='PREEMPTED', sequence=2)
time.sleep(.06)
self.assertFalse(any(e.terminal for e in session.drain_events()))
self.reply = snapshot(status='TERMINAL', outcome='CANCELED', controller_state='PREEMPTED', stop_state='CONFIRMED', sequence=3)
events = []
self.wait_for(lambda: bool(events.extend(session.drain_events())) or any(e.terminal for e in events))
self.assertEqual([e.snapshot.stop_state for e in events if e.terminal], ['CONFIRMED'])
def test_transport_timeout_produces_unknown_error_and_bounded_wait(self):
self.delay = .7
session = module.GoalSession(self.client, BODY)
started = time.monotonic()
session.start()
self.assertLess(time.monotonic() - started, .05)
events = []
self.wait_for(lambda: bool(events.extend(session.drain_events())) or any(e.error for e in events))
self.assertLess(time.monotonic() - started, .6)
self.assertFalse(any(e.terminal for e in events))
self.assertTrue(any(e.error for e in events))
def test_completion_outside_tolerance_never_releases(self):
self.reply = snapshot(status='TERMINAL', outcome='COMPLETED', controller_state='SUCCEEDED', stop_state='CONFIRMED', position_error=.9, yaw_error=.01)
session = module.GoalSession(self.client, BODY)
session.start()
events = []
self.wait_for(lambda: bool(events.extend(session.drain_events())) or any(e.error or e.terminal for e in events))
self.assertFalse(any(e.terminal for e in events))
self.assertTrue(any(e.error for e in events))
def test_native_arrived_with_stop_and_tolerances_completes(self):
self.reply = snapshot(status='TERMINAL', outcome='COMPLETED', controller_state='ARRIVED', stop_state='CONFIRMED', position_error=.02, yaw_error=.01)
session = module.GoalSession(self.client, BODY)
session.start()
events = []
self.wait_for(lambda: bool(events.extend(session.drain_events())) or any(e.error or e.terminal for e in events))
self.assertTrue(any(e.terminal for e in events))
self.assertFalse(any(e.error for e in events))
def test_short_token_is_rejected_before_network_io(self):
from dataclasses import replace
with self.assertRaises(ValueError):
replace(self.config, token='short')
def test_actual_pose_reaches_ros_pose_stamped_without_invented_stamp(self):
from types import SimpleNamespace as NS
pose = {'frame_id': 'map', 'position': {'x': 1.0, 'y': 2.0, 'z': 0.0}, 'orientation': {'x': 0.0, 'y': 0.0, 'z': 0.0, 'w': 1.0}}
value = module.GatewaySnapshot.parse(snapshot(pose_valid=True, current_pose=pose, final_pose=pose), ID)
self.assertTrue(hasattr(value, 'pose_valid'), 'actual pose evidence is missing from gateway snapshot')
self.assertTrue(value.pose_valid)
target = NS(header=NS(frame_id='', stamp=NS(sec=0, nanosec=0)), pose=NS(position=NS(x=0., y=0., z=0.), orientation=NS(x=0., y=0., z=0., w=0.)))
module.assign_ros_pose(target, value.final_pose)
self.assertEqual((target.header.frame_id, target.pose.position.x, target.pose.position.y, target.pose.orientation.w), ('map', 1.0, 2.0, 1.0))
self.assertEqual((target.header.stamp.sec, target.header.stamp.nanosec), (0, 0))
def test_invalid_observed_pose_is_rejected(self):
for changes in ({'frame_id': 'odom'}, {'orientation': {'x': 0., 'y': 0., 'z': 0., 'w': 0.}}, {'position': {'x': float('nan'), 'y': 0., 'z': 0.}}):
pose = {'frame_id': 'map', 'position': {'x': 1., 'y': 2., 'z': 0.}, 'orientation': {'x': 0., 'y': 0., 'z': 0., 'w': 1.}}
pose.update(changes)
with self.assertRaises(module.GatewayError):
module.GatewaySnapshot.parse(snapshot(pose_valid=True, final_pose=pose), ID)
def test_confirmed_terminal_rejects_missing_or_invalid_stop_stamp(self):
for stamp in (None, 0.0, -1.0, True, float('nan'), float('inf'), 2147483648.0):
with self.subTest(stamp=stamp), self.assertRaises(module.GatewayError):
module.GatewaySnapshot.parse(snapshot(status='TERMINAL', outcome='CANCELED', controller_state='PREEMPTED', stop_state='CONFIRMED', stopped_at=stamp), ID)
def test_ros_stop_time_normalizes_nanosecond_rounding_and_range(self):
from types import SimpleNamespace as NS
self.assertTrue(hasattr(module, 'assign_ros_time'), 'source stop time mapping is missing')
for source, expected in ((1234.5, (1234, 500000000)), (1.9999999996, (2, 0)), (2147483647.0, (2147483647, 0))):
result = NS(sec=0, nanosec=0)
module.assign_ros_time(result, source)
self.assertEqual((result.sec, result.nanosec), expected)
for invalid in (-1.0, 2147483648.0, float('inf'), True):
with self.assertRaises(module.GatewayError):
module.assign_ros_time(NS(sec=0, nanosec=0), invalid)
def test_repeated_query_preserves_original_stop_evidence_stamp(self):
self.reply = snapshot(status='TERMINAL', outcome='COMPLETED', controller_state='SUCCEEDED', stop_state='CONFIRMED', position_error=.02, yaw_error=.01, stopped_at=1234.5)
first = self.client.query(ID)
second = self.client.query(ID)
self.assertTrue(hasattr(first, 'stopped_at'), 'original stop evidence timestamp was dropped')
self.assertEqual((first.stopped_at, second.stopped_at), (1234.5, 1234.5))
def test_request_deadline_bounds_slow_trickle_response(self):
self.dribble = True
started = time.monotonic()
with self.assertRaises(module.GatewayError):
self.client.query(ID)
self.assertLess(time.monotonic() - started, .4)
def test_mismatched_uuid_never_completes_current_goal(self):
self.reply = snapshot(goal_id='22222222-2222-4222-8222-222222222222', status='TERMINAL', outcome='COMPLETED', controller_state='SUCCEEDED', stop_state='CONFIRMED')
session = module.GoalSession(self.client, BODY)
session.start()
events=[]
self.wait_for(lambda: bool(events.extend(session.drain_events())) or any(e.error for e in events))
self.assertFalse(any(e.terminal for e in events))
self.assertTrue(any(e.error for e in events))
class NavigationWireTests(unittest.TestCase):
def test_blocked_unknown_is_not_reported_as_clear(self):
s = module.GatewaySnapshot.parse(snapshot(), ID)
self.assertIsNone(getattr(s, 'blocked', 'missing'))
with self.assertRaises(module.GatewayError):
module.assign_navigation_feedback(object(), s)
def test_explicit_blocked_and_error_code_survive_parse(self):
s = module.GatewaySnapshot.parse(snapshot(blocked=True, error_code='INPUTS_UNHEALTHY'), ID)
self.assertEqual((getattr(s, 'blocked', None), getattr(s, 'error_code', None)), (True, 'INPUTS_UNHEALTHY'))
for invalid in (0, 'false'):
with self.assertRaises(module.GatewayError):
module.GatewaySnapshot.parse(snapshot(blocked=invalid), ID)
def test_new_feedback_maps_yaw_and_blocked_phase(self):
from types import SimpleNamespace as NS
f = NS(STOPPING=4, CHECKING=1, NAVIGATING=2, BLOCKED=3, elapsed_time=NS(sec=0, nanosec=0))
s = module.GatewaySnapshot.parse(snapshot(blocked=True, position_error=.5, yaw_error=-.2), ID)
self.assertTrue(hasattr(module, 'assign_navigation_feedback'))
module.assign_navigation_feedback(f, s)
self.assertEqual((f.phase, f.blocked, f.error_valid, f.yaw_error), (3, True, True, -.2))
self.assertFalse(f.current_pose_valid)
def test_goal_uses_yaw_tolerance(self):
from types import SimpleNamespace as NS
goal = NS(trace=NS(**{k: 't' if k in ('task_id', 'subtask_id', 'run_id') else 1 for k in module._TRACE_FIELDS}), timeout=NS(sec=2, nanosec=0), position_tolerance=.1, yaw_tolerance=.23,
target_pose=NS(header=NS(frame_id='map'), pose=NS(position=NS(x=1., y=2., z=0.), orientation=NS(x=0., y=0., z=0., w=1.))))
self.assertEqual(module.build_goal_body(goal, ID, 'map-a')['yaw_tolerance'], .23)
class GatewayTelemetryTests(unittest.TestCase):
setUp = GatewayTests.setUp
def test_only_fresh_explicit_blocked_sample_is_published(self):
self.gateway.submit(request())
self.backend.set_snapshot(request()['goal_id'], 'ACTIVE', pose=request()['target_pose'])
sample = self.backend.samples[request()['goal_id']]
sample['blocked'] = dict(self.backend.health_value, value=True)
self.assertIs(self.gateway.poll(request()['goal_id']).get('blocked'), True)
self.clock.advance(1)
self.assertIsNone(self.gateway.poll(request()['goal_id']).get('blocked'))
def test_readiness_code_requires_fresh_explicit_backend_diagnostic(self):
self.gateway.submit(request())
self.backend.health_sample(False)
self.backend.health_value['error_code'] = 'INPUTS_UNHEALTHY'
out = self.gateway.poll(request()['goal_id'])
self.assertEqual(out.get('error_code'), 'INPUTS_UNHEALTHY')
self.assertEqual(out['stop_state'], 'UNKNOWN')
class NavigationOutcomeTests(unittest.TestCase):
def test_terminal_status_mapping_distinguishes_timeout_blocked_and_not_ready(self):
from types import SimpleNamespace as NS
enum = NS(SUCCEEDED=0, CANCELED=1, TIMEOUT=2, BLOCKED=3, NOT_READY=4, FAILED=5)
for outcome, expected in [('COMPLETED',0),('CANCELED',1),('TIMED_OUT',2),('BLOCKED',3),('NOT_READY',4),('REJECTED',5),('FAILED',5)]:
value = module.GatewaySnapshot.parse(snapshot(outcome=outcome), ID)
self.assertEqual(module.navigation_status(value, enum), expected)
value = module.GatewaySnapshot.parse(snapshot(outcome='FAILED', error_code='ROBOT_STATE_UNAVAILABLE'), ID)
self.assertEqual(module.navigation_status(value, enum), 4)
class GoalValidationTests(unittest.TestCase):
def test_trace_ranges_quaternion_and_yaw_are_checked_before_network(self):
from types import SimpleNamespace as NS
goal = NS(trace=NS(task_id='t', subtask_id='s', run_id='r', attempt=1, task_revision=1, plan_version=1, execution_generation=1), timeout=NS(sec=2,nanosec=0), position_tolerance=.1, yaw_tolerance=.2, target_pose=NS(header=NS(frame_id='map'), pose=NS(position=NS(x=0.,y=0.,z=0.),orientation=NS(x=0.,y=0.,z=0.,w=1.))))
for field,value in [('run_id',''),('attempt',True),('attempt',2**32),('task_revision',0),('plan_version',True),('execution_generation',2**64)]:
bad=copy.deepcopy(goal);setattr(bad.trace,field,value)
with self.subTest(field=field), self.assertRaises(ValueError):module.build_goal_body(bad,ID,'map-a')
goal.yaw_tolerance=4.
goal.target_pose.pose.orientation.w=2.
body=module.build_goal_body(goal,ID,'map-a')
self.assertEqual(body['yaw_tolerance'],4.)
self.assertEqual(body['target_pose']['orientation']['w'],1.)
self.assertEqual(validate_goal(body)['yaw_tolerance'],4.)
goal.target_pose.pose.orientation.w=0.
with self.assertRaises(ValueError):module.build_goal_body(goal,ID,'map-a')
class BackendDiagnosticTests(unittest.TestCase):
def test_health_callback_keeps_explicit_machine_readable_cause(self):
from types import SimpleNamespace as NS
from navigation_gateway.backends import Ros1MoveBaseBackend
backend = Ros1MoveBaseBackend.__new__(Ros1MoveBaseBackend)
backend.map_id='map-a'; backend.lock=threading.RLock()
backend._source_metadata=lambda stamp: {'source_fresh':True,'source_stamp':stamp}
backend._health(NS(data=json.dumps({'ready':False,'map_id':'map-a','stamp':12.,'error_code':'INPUTS_UNHEALTHY'})))
self.assertEqual(backend.health_value.get('error_code'), 'INPUTS_UNHEALTHY')
backend._health(NS(data=json.dumps({'ready':False,'map_id':'map-a','stamp':12.,'reason':'some prose'})))
self.assertEqual(backend.health_value.get('error_code', ''), '')
class LocalNotReadyTests(unittest.IsolatedAsyncioTestCase):
async def test_valid_unready_goal_returns_not_ready_without_http_submit(self):
from types import SimpleNamespace as NS
from unittest.mock import patch, Mock
class Node:
def __init__(self,*args):pass
def create_timer(self,*args):pass
nav=NS(SUCCEEDED=0,CANCELED=1,TIMEOUT=2,BLOCKED=3,NOT_READY=4,FAILED=5,UNKNOWN=0,CONFIRMED=1)
imports={'rclpy':NS(), 'rclpy.action':NS(ActionServer=lambda *a,**k:NS(),CancelResponse=NS(ACCEPT=1),GoalResponse=NS(ACCEPT=1,REJECT=2)),
'rclpy.node':NS(Node=Node),'rclpy.task':NS(Future=lambda:object()),'rclpy.callback_groups':NS(ReentrantCallbackGroup=lambda:object()),
'bt_skill_interfaces':NS(), 'bt_skill_interfaces.action':NS(Navigate=NS(Result=lambda:NS(result=NS()))),'bt_skill_interfaces.msg':NS(NavigationResult=nav)}
goal=NS(trace=NS(task_id='t',subtask_id='s',run_id='r',attempt=1,task_revision=1,plan_version=1,execution_generation=1),timeout=NS(sec=2,nanosec=0),position_tolerance=.1,yaw_tolerance=.2,target_pose=NS(header=NS(frame_id='map'),pose=NS(position=NS(x=0.,y=0.,z=0.),orientation=NS(x=0.,y=0.,z=0.,w=1.))))
config=module.ProxyConfig(token='a-long-test-token',connect_timeout_sec=.1,read_timeout_sec=.1,request_timeout_sec=.2,poll_interval_sec=.01,readiness_max_age_sec=.5,feedback_silence_timeout_sec=.5)
client=Mock()
with patch.dict(sys.modules,imports), patch.object(module,'GatewayClient',return_value=client), patch.object(module.threading,'Thread'):
node=module.create_ros_node(config,map_id='map-a',action_name='skills/navigate')
node._health_at=time.monotonic();node._health_error_code='INPUTS_UNHEALTHY'
self.assertEqual(node._accept(goal),1)
handle=NS(request=goal,goal_id=NS(uuid=list(__import__('uuid').UUID(ID).bytes)),abort=Mock())
result=await node._execute(handle)
self.assertEqual((result.result.status,result.result.error_code,result.result.stop_state),(4,'INPUTS_UNHEALTHY',0))
self.assertFalse(node._reserved)
client.submit.assert_not_called()
node._health_at=0
self.assertEqual(node._accept(goal),1)
result=await node._execute(handle)
self.assertEqual(result.result.error_code,'NAV_NOT_READY')
client.submit.assert_not_called()
class BlockedAdmissionTests(unittest.TestCase):
setUp = GatewayTests.setUp
def test_missing_or_stale_blocked_rejects_before_send(self):
for blocked in (None, {'value':False,'received_at':0.,'source_fresh':False}):
self.backend.health_sample(True)
self.backend.health_value['blocked']=blocked
self.assertEqual(self.gateway.health()['error_code'],'INPUTS_UNHEALTHY')
with self.assertRaises(GatewayError):self.gateway.submit(request())
self.assertEqual(self.backend.send_count,0)
def test_explicit_simulation_blocked_can_progress_then_loss_cancels(self):
self.assertTrue(self.gateway.health()['ready'])
self.gateway.submit(request())
for blocked in (False,True):
self.backend.set_snapshot(request()['goal_id'],'ACTIVE',pose=request()['target_pose'],blocked=blocked)
out=self.gateway.poll(request()['goal_id'])
self.assertIs(out['blocked'],blocked)
self.assertEqual(self.backend.cancel_count,0)
self.backend.set_snapshot(request()['goal_id'],'ACTIVE',pose=request()['target_pose'],blocked=None)
out=self.gateway.poll(request()['goal_id'])
self.assertEqual((self.backend.cancel_count,out['error_code'],out['stop_state']),(1,'INPUTS_UNHEALTHY','UNKNOWN'))
def test_ros_health_carries_only_explicit_blocked_with_original_source_time(self):
from types import SimpleNamespace as NS
from navigation_gateway.backends import Ros1MoveBaseBackend
backend=Ros1MoveBaseBackend.__new__(Ros1MoveBaseBackend)
backend.map_id='sim-map';backend.lock=threading.RLock();backend.connected=True
backend._source_metadata=lambda stamp:{'source_stamp':stamp,'source_fresh':True}
backend.source_is_fresh=lambda sample:sample.get('source_fresh') is True
for blocked in (False,True,None,0):
payload={'ready':True,'map_id':'sim-map','stamp':100.,'blocked':blocked}
backend._health(NS(data=json.dumps(payload)))
sample=backend.health().get('blocked')
if type(blocked) is bool:
self.assertEqual((sample['value'],sample['source_stamp']),(blocked,100.))
else:self.assertIsNone(sample)
if __name__ == "__main__": unittest.main()
+31 -26
View File
@@ -9,6 +9,7 @@ from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[1]
INTERFACES = ROOT / "ros2" / "bt_skill_interfaces"
NAVIGATION = ROOT / "ros2" / "navigation_interfaces"
MOCKS = ROOT / "ros2" / "bt_mock_servers"
PRIMITIVES = {"bool", "byte", "char", "float32", "float64", "int8", "uint8", "int16", "uint16", "int32", "uint32", "int64", "uint64", "string", "wstring"}
EXTERNAL = {"builtin_interfaces/Time", "builtin_interfaces/Duration", "geometry_msgs/PoseStamped", "geometry_msgs/PointStamped", "std_msgs/Header"}
@@ -43,7 +44,7 @@ class RosContractTests(unittest.TestCase):
self.assertIn("g.destination.description=r.destination_id;", verify)
def test_action_and_service_sections(self):
self.assertEqual(len(list((INTERFACES / "action").glob("*.action"))), 12)
self.assertEqual(len(list((INTERFACES / "action").glob("*.action"))), 10)
for suffix, expected in (("action", 2), ("srv", 1), ("msg", 0)):
for path in (INTERFACES / suffix).glob("*." + suffix):
with self.subTest(path=path.name):
@@ -75,32 +76,36 @@ class RosContractTests(unittest.TestCase):
else:
self.assertRegex(name, r"^[a-z][a-z0-9_]*$")
def test_navigate_exact_source_outer_contract(self):
goal, result, feedback = sections("Navigate")
self.assertEqual(goal, "\n".join([
"bt_skill_interfaces/TaskTrace trace", "geometry_msgs/PoseStamped target_pose",
"float64 position_tolerance", "float64 yaw_tolerance", "builtin_interfaces/Duration timeout",
]))
self.assertEqual(result, "\n".join([
"bt_skill_interfaces/NavigationResult result", "bool final_pose_valid", "geometry_msgs/PoseStamped final_pose",
"float64 final_position_error", "float64 final_yaw_error",
]))
self.assertEqual(feedback, "\n".join([
"uint8 ACCEPTED=0", "uint8 CHECKING=1", "uint8 NAVIGATING=2", "uint8 BLOCKED=3",
"uint8 STOPPING=4", "builtin_interfaces/Time stamp",
"uint32 sequence", "uint8 phase", "bool current_pose_valid", "geometry_msgs/PoseStamped current_pose",
"bool error_valid", "float64 position_error", "float64 yaw_error",
"bool blocked", "builtin_interfaces/Duration elapsed_time", "string message",
]))
self.assertFalse((INTERFACES / "action" / "ExecuteNavigation.action").exists())
def test_navigate_flat_canonical_contract(self):
goal, result, feedback = "\n".join(fields(NAVIGATION / "action/NavigateToPose.action")).split("\n---\n")
self.assertEqual(goal.splitlines(), ["string task_id", "string subtask_id",
"geometry_msgs/PoseStamped target_pose", "float64 position_tolerance",
"float64 yaw_tolerance", "builtin_interfaces/Duration timeout"])
self.assertEqual(result.splitlines(), ["uint8 SUCCEEDED=0", "uint8 CANCELED=1",
"uint8 TIMEOUT=2", "uint8 BLOCKED=3", "uint8 NOT_READY=4", "uint8 FAILED=5",
"uint8 STOP_UNKNOWN=0", "uint8 STOP_CONFIRMED=1", "uint8 status", "string error_code",
"string message", "bool final_pose_valid", "geometry_msgs/PoseStamped final_pose",
"float64 final_position_error", "float64 final_yaw_error", "uint8 stop_state",
"builtin_interfaces/Time stopped_at", "string stop_evidence_ref"])
self.assertEqual(feedback.splitlines(), ["uint8 ACCEPTED=0", "uint8 CHECKING=1",
"uint8 PLANNING=2", "uint8 NAVIGATING=3", "uint8 BLOCKED=4", "uint8 STOPPING=5",
"builtin_interfaces/Time stamp", "uint64 sequence", "uint8 phase", "bool current_pose_valid",
"geometry_msgs/PoseStamped current_pose", "bool error_valid", "float64 position_error",
"float64 yaw_error", "bool blocked_valid", "bool blocked",
"builtin_interfaces/Duration elapsed_time", "string message"])
self.assertIn('"action/NavigateToPose.action"', (NAVIGATION / "CMakeLists.txt").read_text())
self.assertEqual(ET.parse(NAVIGATION / "package.xml").getroot().findtext("name"), "navigation_interfaces")
def test_navigation_result_is_separate_from_other_skill_results(self):
self.assertEqual(fields(INTERFACES / "msg" / "NavigationResult.msg"), [
"uint8 SUCCEEDED=0", "uint8 CANCELED=1", "uint8 TIMEOUT=2", "uint8 BLOCKED=3",
"uint8 NOT_READY=4", "uint8 FAILED=5", "uint8 UNKNOWN=0", "uint8 CONFIRMED=1",
"uint8 status", "string error_code", "string message", "uint8 stop_state",
"builtin_interfaces/Time stopped_at", "string stop_evidence_ref",
])
def test_navigation_has_no_duplicate_wire_contract_or_proxy(self):
for path in ("action/Navigate.action", "action/NavigateSemantic.action", "msg/NavigationResult.msg"):
self.assertFalse((INTERFACES / path).exists())
self.assertEqual(list((ROOT / "navigation_gateway").rglob("*.py")), [])
header = (ROOT / "ros2/bt_executor/include/bt_executor/ros_driver.hpp").read_text()
self.assertIn("navigation_interfaces::action::NavigateToPose", header)
self.assertNotIn("semantic_", header)
for package in ("bt_executor", "bt_mock_servers"):
manifest = ET.parse(ROOT / "ros2" / package / "package.xml").getroot()
self.assertIn("navigation_interfaces", [v.text for v in manifest if v.tag in ("depend", "exec_depend")])
def test_manipulation_exact_source_outer_contract(self):
goal, result, feedback = sections("ExecuteManipulation")