fix: align navigation contract and readiness handling

This commit is contained in:
2026-09-22 14:35:14 +08:00
parent 91bcd92d6b
commit 964d1fde67
21 changed files with 922 additions and 139 deletions
+5
View File
@@ -132,6 +132,11 @@ TickStatus StageRunner::run_goal(Stage stage,Skill skill,SteadyTime now,RosTime
if(error_code_.empty()&&record->result&&!record->result->error_code.empty()&&(record->state==GoalState::STOP_UNKNOWN||record->cancel_intent||record->result->code!=ResultCode::COMPLETED||record->result->stop!=StopState::CONFIRMED||!record->result->response.valid))error_code_=record->result->error_code;
if(record->state==GoalState::STOP_UNKNOWN)return fail("physical stop unknown; robot quarantined");
if(record->state!=GoalState::TERMINAL)return TickStatus::RUNNING;
if(skill==Skill::NAVIGATE&&record->result&&record->result->code==ResultCode::REJECTED) {
// NOT_READY is a deployment/input gate, not permission to retry motion or
// admit another queued task. Recovery uses the existing trusted recheck.
return fail("navigation not ready: "+record->result->error_code+"; awaiting input/backend recovery and explicit recheck");
}
if(record->cancel_intent||!record->result||record->result->code!=ResultCode::COMPLETED||record->result->stop!=StopState::CONFIRMED) {return fail("goal failed/canceled/timed out; automatic motion retry disabled",false);}
response=record->result->response;completed_goal=active_goal_;
if(!response.valid) {
+25
View File
@@ -0,0 +1,25 @@
#include "workflow_fixture.hpp"
int main() {
for(const auto* code:{"INPUTS_UNHEALTHY","ROBOT_STATE_UNAVAILABLE","ROBOT_ESTOP","BACKEND_NOT_CONFIGURED","NAV_NOT_READY"}) {
Fixture f(std::string("navigation_")+code);auto runner=f.runner();Workflow flow(runner);
auto status=TickStatus::RUNNING;
for(unsigned i=0;i<100&&status==TickStatus::RUNNING;++i) {
f.driver.now=1000000+static_cast<RosTime>(i)*1000000;
runner.update_safety({true,true,Holding::EMPTY,f.driver.now,f.driver.now+1000000000});
status=flow.tick(SteadyTime{}+Milliseconds(i),f.driver.now);
for(auto& event:f.driver.events) {
if(event.kind==EventKind::RESULT&&!f.driver.sent.empty()&&f.driver.sent.back().skill==Skill::NAVIGATE) {
event.native_status=NativeStatus::ABORTED;event.result.code=ResultCode::REJECTED;
event.result.error_code=code;event.result.stop=StopState::CONFIRMED;
}
}
}
assert(status==TickStatus::INTERVENTION_REQUIRED);
assert(runner.error_code()==code);assert(f.count(Skill::NAVIGATE)==1);assert(f.count(Skill::PICK)==0);
for(unsigned i=100;i<120;++i) {
assert(flow.tick(SteadyTime{}+Milliseconds(i),f.driver.now)==TickStatus::INTERVENTION_REQUIRED);
}
assert(f.count(Skill::NAVIGATE)==1);
}
std::cout<<"navigation NOT_READY retains reason and requires explicit recovery without motion resend\n";
}
+18 -8
View File
@@ -16,7 +16,7 @@ class NavigationBackend(ABC):
@abstractmethod
def health(self):
"""Return ready, reason, map_id, received_at (local monotonic), source_fresh."""
"""Return readiness plus a timestamped blocked boolean sample; absence is unknown."""
@abstractmethod
def send(self, goal):
@@ -48,9 +48,11 @@ class MockBackend(NavigationBackend):
return (sample.get("source_fresh") is True and isinstance(stamp, (int, float))
and math.isfinite(stamp) and 0 < stamp <= self.source_clock())
def health_sample(self, ready):
def health_sample(self, ready, blocked=False):
self.health_value = {"ready": ready, "reason": "SIMULATION", "map_id": self.map_id,
"received_at": self.clock(), "source_fresh": True, "source_stamp": self.source_clock()}
self.health_value["blocked"] = ({"value": blocked, "received_at": self.clock(),
"source_fresh": True, "source_stamp": self.source_clock()} if type(blocked) is bool else None)
def health(self):
if self.auto_complete_sec is not None: self.health_sample(True)
@@ -59,7 +61,8 @@ class MockBackend(NavigationBackend):
def send(self, goal):
self.send_count += 1
self.goals[goal["goal_id"]] = (copy.deepcopy(goal), self.clock())
self.samples[goal["goal_id"]] = {"controller_state": "REJECTED" if self.send_mode == "REJECTED" else "ACTIVE"}
self.samples[goal["goal_id"]] = {"controller_state": "REJECTED" if self.send_mode == "REJECTED" else "ACTIVE",
"blocked": copy.deepcopy(self.health_value.get("blocked"))}
return self.send_mode
def cancel(self, goal_id):
@@ -67,9 +70,11 @@ class MockBackend(NavigationBackend):
self.cancelled.add(goal_id)
return True # ACK only; tests supply independent telemetry.
def set_snapshot(self, goal_id, state, linear=0.0, angular=0.0, pose=None, source_fresh=True):
def set_snapshot(self, goal_id, state, linear=0.0, angular=0.0, pose=None, source_fresh=True, blocked=False):
self.sequence += 1
self.samples[goal_id] = {"controller_state": state,
"blocked": ({"value": blocked, "received_at": self.clock(), "source_fresh": source_fresh,
"source_stamp": self.source_clock()} if type(blocked) is bool else None),
"odom": {"sequence": self.sequence, "received_at": self.clock(), "source_fresh": source_fresh,
"source_stamp": self.source_clock(), "linear": linear, "angular": angular},
"pose": {"value": copy.deepcopy(pose), "received_at": self.clock(), "source_fresh": source_fresh,
@@ -92,7 +97,7 @@ class Ros1MoveBaseBackend(NavigationBackend):
All ROS endpoint names and freshness limits must be configured. A trusted ROS
safety/health monitor publishes JSON String: ready, map_id, stamp (ROS seconds),
reason; HTTP clients cannot set these values. ROS1 graph must be access controlled.
reason, blocked (explicit bool); HTTP clients cannot set these values. ROS1 graph must be access controlled.
"""
def __init__(self, *, action_name, odom_topic, pose_topic, readiness_topic, map_id,
source_max_age_sec, server_wait_sec):
@@ -176,10 +181,14 @@ class Ros1MoveBaseBackend(NavigationBackend):
import json
try:
data = json.loads(msg.data)
if not isinstance(data, dict): raise ValueError("health payload must be an object")
metadata = self._source_metadata(float(data["stamp"]))
valid = data.get("ready") is True and data.get("map_id") == self.map_id and metadata["source_fresh"]
value = {"ready": valid, "map_id": data.get("map_id"), **metadata,
"reason": str(data.get("reason", "")), "received_at": time.monotonic()}
"reason": str(data.get("reason", "")), "received_at": time.monotonic(),
"error_code": data.get("error_code") if isinstance(data.get("error_code"), str) else ""}
value["blocked"] = ({"value": data["blocked"], **metadata, "received_at": value["received_at"]}
if type(data.get("blocked")) is bool else None)
except (ValueError, TypeError, KeyError):
value = {"ready": False, "map_id": self.map_id, "source_fresh": False,
"reason": "invalid health monitor payload", "received_at": time.monotonic()}
@@ -223,8 +232,9 @@ class Ros1MoveBaseBackend(NavigationBackend):
# get_state also surfaces LOST without waiting for a done callback.
state = self.status_names.get(self.client.get_state(), self.state)
out = {"controller_state": state, "odom": copy.deepcopy(self.odom), "pose": copy.deepcopy(self.pose),
"odom_samples": copy.deepcopy(self.odom_queue)}
for sample in [out["odom"], out["pose"]] + out["odom_samples"]:
"odom_samples": copy.deepcopy(self.odom_queue),
"blocked": copy.deepcopy(self.health_value.get("blocked"))}
for sample in [out["odom"], out["pose"], out["blocked"]] + out["odom_samples"]:
if isinstance(sample, dict): sample["source_fresh"] = self.source_is_fresh(sample)
self.odom_queue.clear()
return out
+22 -4
View File
@@ -55,7 +55,6 @@ def validate_goal(body):
elif not isinstance(value, str) or len(value) > 256: raise GatewayError("invalid trace text")
validate_pose(body["target_pose"])
for key in ("position_tolerance", "yaw_tolerance", "timeout_sec"): number(body[key], key, positive=True)
if body["yaw_tolerance"] > math.pi: raise GatewayError("yaw_tolerance is radians and must be <= pi")
return copy.deepcopy(body)
@@ -130,10 +129,14 @@ class Gateway:
try: health = self.backend.health()
except Exception: health = {}
fresh = self._fresh(health, self.config.readiness_max_age_sec)
ready = health.get("ready") is True and fresh and not self._occupied()
blocked_known = self._blocked_known(health.get("blocked"))
ready = health.get("ready") is True and fresh and blocked_known and not self._occupied()
return {"ready": ready, "map_id": health.get("map_id"), "stamp_monotonic": self.clock(),
"error_code": ("" if self._occupied() else "INPUTS_UNHEALTHY" if not blocked_known
else health.get("error_code", "") if fresh else ""),
"reason": "motion resource occupied or quarantined" if self._occupied() else
(health.get("reason", "") if fresh else "readiness missing or stale")}
("blocked telemetry missing or stale" if not blocked_known else
health.get("reason", "") if fresh else "readiness missing or stale")}
def submit(self, body):
goal = validate_goal(body)
@@ -189,6 +192,10 @@ class Gateway:
stamp = sample.get("received_at")
return isinstance(stamp, (int, float)) and math.isfinite(stamp) and 0 <= self.clock()-stamp <= maximum
def _blocked_known(self, sample):
return (self._fresh(sample, self.config.readiness_max_age_sec) and
type(sample.get("value")) is bool)
def poll(self, goal_id):
with self.lock:
r = self._record(goal_id)
@@ -198,14 +205,25 @@ class Gateway:
if not r["_cancel_sent"]:
try: health = self.backend.health()
except Exception: health = {}
if not self._fresh(health, self.config.readiness_max_age_sec) or health.get("ready") is not True or health.get("map_id") != r["request"]["map_id"]:
if not self._fresh(health, self.config.readiness_max_age_sec) or health.get("ready") is not True or health.get("map_id") != r["request"]["map_id"] or not self._blocked_known(health.get("blocked")):
self.cancel(goal_id, "FAILED")
if (self._fresh(health, self.config.readiness_max_age_sec) and
health.get("error_code") in {"INPUTS_UNHEALTHY", "ROBOT_STATE_UNAVAILABLE", "BACKEND_NOT_CONFIGURED"}):
r["error_code"] = health["error_code"]
if not self._blocked_known(health.get("blocked")): r["error_code"] = "INPUTS_UNHEALTHY"
r["message"] = "readiness lost during execution; stop requested"
try: snapshot = self.backend.snapshot(goal_id)
except Exception: snapshot = {"controller_state": "UNKNOWN"}
state = snapshot.get("controller_state", "UNKNOWN")
r.update(controller_state=state, sequence=r["sequence"]+1, elapsed=max(0, self.clock()-r["_started"]))
r.update(pose_valid=False, current_pose=None)
blocked_sample = snapshot.get("blocked")
r["blocked"] = (blocked_sample["value"] if
self._fresh(blocked_sample, self.config.readiness_max_age_sec) and
type(blocked_sample.get("value")) is bool else None)
if r["blocked"] is None:
if not r["_cancel_sent"]: self.cancel(goal_id, "FAILED")
r["error_code"] = "INPUTS_UNHEALTHY"
pose_sample = snapshot.get("pose")
if self._fresh(pose_sample, self.config.pose_max_age_sec):
try:
+100 -42
View File
@@ -21,7 +21,7 @@ from urllib.parse import urlsplit
_STATES = {'SENDING', 'ACTIVE', 'CANCEL_REQUESTED', 'STOP_UNKNOWN', 'TERMINAL'}
_OUTCOMES = {'COMPLETED', 'FAILED', 'CANCELED', 'TIMED_OUT', 'REJECTED'}
_OUTCOMES = {'COMPLETED', 'FAILED', 'CANCELED', 'TIMED_OUT', 'REJECTED', 'BLOCKED', 'NOT_READY'}
_CONTROLLER_TERMINALS = {'ARRIVED', 'SUCCEEDED', 'ABORTED', 'PREEMPTED', 'RECALLED', 'REJECTED'}
_TRACE_FIELDS = ('task_id', 'subtask_id', 'attempt', 'task_revision', 'plan_version',
'run_id', 'execution_generation')
@@ -92,18 +92,24 @@ class GatewaySnapshot:
current_pose: dict[str, Any] | None = None
final_pose: dict[str, Any] | None = None
stopped_at: float | None = None
blocked: bool | None = None
error_code: str = ''
@classmethod
def parse(cls, data: Mapping[str, Any], goal_id: str) -> 'GatewaySnapshot':
try:
required = {name: data[name] for name in cls.__dataclass_fields__
if name not in {'pose_valid', 'current_pose', 'final_pose', 'stopped_at'}}
if name not in {'pose_valid', 'current_pose', 'final_pose', 'stopped_at', 'blocked', 'error_code'}}
snapshot = cls(**required, pose_valid=data.get('pose_valid', False),
current_pose=validate_observed_pose(data.get('current_pose')),
final_pose=validate_observed_pose(data.get('final_pose')),
stopped_at=data.get('stopped_at'))
stopped_at=data.get('stopped_at'), blocked=data.get('blocked'), error_code=data.get('error_code', ''))
except (TypeError, KeyError) as exc:
raise GatewayError('Gateway snapshot is missing required fields') from exc
if snapshot.blocked is not None and type(snapshot.blocked) is not bool:
raise GatewayError('Gateway blocked must be boolean or unknown')
if not isinstance(snapshot.error_code, str):
raise GatewayError('Gateway error_code must be text')
stop_time_parts = None if snapshot.stopped_at is None else _ros_time_parts(snapshot.stopped_at)
if type(snapshot.pose_valid) is not bool:
raise GatewayError('Gateway pose_valid must be boolean')
@@ -191,6 +197,37 @@ def assign_ros_pose(destination: Any, source: Mapping[str, Any]) -> None:
setattr(getattr(destination.pose, name), axis, float(source[name][axis]))
def navigation_status(snapshot, enum):
"""Translate wire outcomes by name; execution and navigation numbers differ."""
names = {'COMPLETED': 'SUCCEEDED', 'CANCELED': 'CANCELED', 'TIMED_OUT': 'TIMEOUT',
'BLOCKED': 'BLOCKED', 'NOT_READY': 'NOT_READY', 'REJECTED': 'FAILED', 'FAILED': 'FAILED'}
if snapshot.outcome in {'FAILED', 'REJECTED', 'NOT_READY'} and snapshot.error_code in {'INPUTS_UNHEALTHY', 'ROBOT_STATE_UNAVAILABLE', 'BACKEND_NOT_CONFIGURED'}:
return enum.NOT_READY
return getattr(enum, names[snapshot.outcome])
def assign_navigation_feedback(feedback, snapshot):
"""A required bool cannot truthfully encode missing blocked telemetry."""
if snapshot.blocked is None:
raise GatewayError('blocked telemetry unavailable; stop requested')
feedback.sequence = snapshot.sequence
feedback.phase = (feedback.STOPPING if snapshot.status in {'CANCEL_REQUESTED', 'STOP_UNKNOWN', 'TERMINAL'}
else feedback.CHECKING if snapshot.status == 'SENDING'
else feedback.BLOCKED if snapshot.blocked else feedback.NAVIGATING)
feedback.blocked = snapshot.blocked
feedback.current_pose_valid = snapshot.pose_valid and snapshot.current_pose is not None
if feedback.current_pose_valid:
assign_ros_pose(feedback.current_pose, snapshot.current_pose)
feedback.error_valid = snapshot.position_error is not None and snapshot.yaw_error is not None
if feedback.error_valid:
feedback.position_error = float(snapshot.position_error)
feedback.yaw_error = float(snapshot.yaw_error)
seconds = min(snapshot.elapsed, 2147483647.0)
feedback.elapsed_time.sec = int(seconds)
feedback.elapsed_time.nanosec = int((seconds - int(seconds)) * 1e9)
feedback.message = snapshot.message
class GatewayClient:
"""One fresh bounded connection per request; no redirect or proxy handling."""
@@ -396,14 +433,27 @@ def build_goal_body(goal: Any, goal_id: str, map_id: str) -> dict[str, Any]:
'orientation': {axis: getattr(pose.orientation, axis) for axis in ('x', 'y', 'z', 'w')},
},
'position_tolerance': _positive(goal.position_tolerance, 'position_tolerance'),
'yaw_tolerance': _positive(goal.orientation_tolerance, 'orientation_tolerance'),
'yaw_tolerance': _positive(goal.yaw_tolerance, 'yaw_tolerance'),
'timeout_sec': _positive(timeout, 'timeout'),
}
if not body['trace']['task_id'] or not body['trace']['subtask_id'] or body['trace']['attempt'] < 1:
raise ValueError('TaskTrace task_id, subtask_id and attempt are required')
for name in ('task_id', 'subtask_id', 'run_id'):
if not isinstance(body['trace'][name], str) or not body['trace'][name]:
raise ValueError('TaskTrace identifiers must be nonempty strings')
for name in ('attempt', 'task_revision', 'plan_version', 'execution_generation'):
value = body['trace'][name]
maximum = 2**64 if name == 'execution_generation' else 2**32
if type(value) is not int or not 0 < value < maximum:
raise ValueError('TaskTrace counters must be positive and fit their wire types')
values = list(body['target_pose']['position'].values()) + list(body['target_pose']['orientation'].values())
if any(isinstance(v, bool) or not isinstance(v, (float, int)) or not math.isfinite(v) for v in values):
raise ValueError('target pose must contain finite coordinates')
orientation = body['target_pose']['orientation']
scale = max(abs(v) for v in orientation.values())
if scale == 0:
raise ValueError('target quaternion must be nonzero')
scaled = {axis: value / scale for axis, value in orientation.items()}
norm = math.sqrt(sum(v*v for v in scaled.values()))
body['target_pose']['orientation'] = {axis: value / norm for axis, value in scaled.items()}
return json.loads(json.dumps(body, allow_nan=False))
@@ -418,7 +468,7 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
from rclpy.node import Node
from rclpy.task import Future
from bt_skill_interfaces.action import Navigate
from bt_skill_interfaces.msg import ExecutionResult
from bt_skill_interfaces.msg import NavigationResult
if not map_id or not action_name:
raise ValueError('map_id and action_name must be explicitly configured')
@@ -430,6 +480,7 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
self._guard = threading.Lock()
self._ready = False
self._health_at = 0.0
self._health_error_code = ''
self._reserved = False
self._stop_unknown = False
self._record = None
@@ -448,10 +499,14 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
try:
response = self._client.health()
ready = response['ready'] and response.get('map_id') == map_id
error_code = response.get('error_code', '')
if not isinstance(error_code, str): error_code = ''
except Exception:
ready = False
error_code = ''
with self._guard:
self._ready, self._health_at = ready, time.monotonic()
self._health_error_code = error_code
self._closing.wait(config.poll_interval_sec)
def _accept(self, request):
@@ -462,13 +517,23 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
except (AttributeError, TypeError, ValueError):
return GoalResponse.REJECT
with self._guard:
if (self._reserved or self._stop_unknown or not self._ready or
time.monotonic() - self._health_at > config.readiness_max_age_sec):
if self._reserved or self._stop_unknown:
return GoalResponse.REJECT
self._reserved = True
return GoalResponse.ACCEPT
async def _execute(self, handle):
with self._guard:
fresh = time.monotonic() - self._health_at <= config.readiness_max_age_sec
ready = self._ready and fresh
if not ready:
code = self._health_error_code if fresh else ''
self._reserved = False
if not ready:
result = self._unknown_result('navigation backend is not ready; no goal dispatched', code or 'NAV_NOT_READY')
result.result.status = NavigationResult.NOT_READY
handle.abort()
return result
goal_id = str(uuid.UUID(bytes=bytes(handle.goal_id.uuid)))
future = Future()
try:
@@ -494,14 +559,16 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
# covering cancellation before a worker has been registered.
return CancelResponse.ACCEPT
def _unknown_result(self, message):
def _unknown_result(self, message, error_code='NAV_GATEWAY_STOP_UNKNOWN'):
result = Navigate.Result()
result.result.status = ExecutionResult.FAILED
result.result.stop_state = ExecutionResult.UNKNOWN
result.result.error_code = 'NAV_GATEWAY_STOP_UNKNOWN'
result.result.status = (NavigationResult.NOT_READY if error_code in
{'INPUTS_UNHEALTHY', 'ROBOT_STATE_UNAVAILABLE', 'BACKEND_NOT_CONFIGURED'} else NavigationResult.FAILED)
result.result.stop_state = NavigationResult.UNKNOWN
result.result.error_code = error_code
result.result.message = message
result.pose_valid = False
result.errors_valid = False
result.final_pose_valid = False
result.final_position_error = math.nan
result.final_yaw_error = math.nan
return result
def _pump(self):
@@ -521,19 +588,17 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
snapshot = event.snapshot
if event.terminal:
result = Navigate.Result()
result.result.status = getattr(ExecutionResult, snapshot.outcome)
result.result.stop_state = ExecutionResult.CONFIRMED
result.result.status = navigation_status(snapshot, NavigationResult)
result.result.stop_state = NavigationResult.CONFIRMED
result.result.message = snapshot.message
result.result.error_code = '' if snapshot.outcome == 'COMPLETED' else 'NAV_' + snapshot.outcome
result.result.error_code = snapshot.error_code or ('' if snapshot.outcome == 'COMPLETED' else 'NAV_' + snapshot.outcome)
result.result.stop_evidence_ref = f'nav-gateway:{snapshot.goal_id}:sequence:{snapshot.sequence}'
assign_ros_time(result.result.stopped_at, snapshot.stopped_at)
result.pose_valid = snapshot.pose_valid and snapshot.final_pose is not None
if result.pose_valid:
result.final_pose_valid = snapshot.pose_valid and snapshot.final_pose is not None
if result.final_pose_valid:
assign_ros_pose(result.final_pose, snapshot.final_pose)
result.errors_valid = snapshot.position_error is not None and snapshot.yaw_error is not None
if result.errors_valid:
result.final_position_error = float(snapshot.position_error)
result.final_orientation_error = float(snapshot.yaw_error)
result.final_position_error = math.nan if snapshot.position_error is None else float(snapshot.position_error)
result.final_yaw_error = math.nan if snapshot.yaw_error is None else float(snapshot.yaw_error)
if snapshot.outcome == 'COMPLETED':
handle.succeed()
elif snapshot.outcome == 'CANCELED' and handle.is_cancel_requested:
@@ -542,7 +607,7 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
if snapshot.outcome == 'CANCELED':
# A remote cancellation has no matching ROS cancel
# transition. Preserve physical stop, report FAILED.
result.result.status = ExecutionResult.FAILED
result.result.status = NavigationResult.FAILED
result.result.error_code = 'NAV_REMOTE_CANCELED'
handle.abort()
with self._guard:
@@ -552,23 +617,16 @@ def create_ros_node(config: ProxyConfig, *, map_id: str, action_name: str):
else:
feedback = Navigate.Feedback()
feedback.stamp = self.get_clock().now().to_msg()
feedback.sequence = snapshot.sequence
feedback.phase = (Navigate.Feedback.STOPPING if snapshot.status in
{'CANCEL_REQUESTED', 'STOP_UNKNOWN', 'TERMINAL'} else
Navigate.Feedback.CHECKING if snapshot.status == 'SENDING' else
Navigate.Feedback.NAVIGATING)
feedback.pose_valid = snapshot.pose_valid and snapshot.current_pose is not None
if feedback.pose_valid:
assign_ros_pose(feedback.current_pose, snapshot.current_pose)
feedback.blocked_valid = False
feedback.errors_valid = snapshot.position_error is not None and snapshot.yaw_error is not None
if feedback.errors_valid:
feedback.position_error = float(snapshot.position_error)
feedback.orientation_error = float(snapshot.yaw_error)
seconds = min(snapshot.elapsed, 2147483647.0)
feedback.elapsed_time.sec = int(seconds)
feedback.elapsed_time.nanosec = int((seconds - int(seconds)) * 1e9)
feedback.message = snapshot.message
try:
assign_navigation_feedback(feedback, snapshot)
except GatewayError as exc:
session.request_cancel()
with self._guard:
self._stop_unknown = True
self._record = None
handle.abort()
future.set_result(self._unknown_result(str(exc), snapshot.error_code or 'NAV_GATEWAY_STOP_UNKNOWN'))
continue
handle.publish_feedback(feedback)
def destroy_node(self):
+38 -8
View File
@@ -9,6 +9,31 @@ def duration_seconds(value):
if not 0<seconds<=3600:raise ValueError('Duration must be in (0,3600] seconds')
return seconds
def translate_navigation_result(source, target, navigation_enum, execution_enum):
"""Preserve proof and diagnostics while explicitly translating different enums."""
mapping = {navigation_enum.SUCCEEDED: execution_enum.COMPLETED,
navigation_enum.CANCELED: execution_enum.CANCELED,
navigation_enum.TIMEOUT: execution_enum.TIMED_OUT,
navigation_enum.BLOCKED: execution_enum.FAILED,
navigation_enum.NOT_READY: execution_enum.REJECTED,
navigation_enum.FAILED: execution_enum.FAILED}
target.result.status = mapping[source.result.status]
target.result.stop_state = {navigation_enum.UNKNOWN: execution_enum.UNKNOWN,
navigation_enum.CONFIRMED: execution_enum.CONFIRMED}[source.result.stop_state]
for field in ('error_code', 'message', 'stopped_at', 'stop_evidence_ref'):
setattr(target.result, field, getattr(source.result, field))
if not target.result.error_code:
target.result.error_code = {navigation_enum.BLOCKED: 'NAV_BLOCKED',
navigation_enum.NOT_READY: 'NAV_NOT_READY'}.get(source.result.status, '')
target.final_pose = source.final_pose
target.pose_valid = source.final_pose_valid
target.errors_valid = (source.final_pose_valid and
all(math.isfinite(v) for v in (source.final_position_error, source.final_yaw_error)) and
source.final_position_error >= 0 and abs(source.final_yaw_error) <= math.pi)
target.final_position_error = source.final_position_error
target.final_orientation_error = source.final_yaw_error
def main():
import rclpy
from rclpy.node import Node
@@ -16,6 +41,8 @@ def main():
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from bt_skill_interfaces.action import Navigate,NavigateSemantic
from bt_skill_interfaces.msg import NavigationResult,ExecutionResult
from action_msgs.msg import GoalStatus
class Proxy(Node):
def __init__(self):
super().__init__('navigation_semantic_proxy');self.group=ReentrantCallbackGroup();self.lock=threading.Lock();self.reserved=False;self.faulted=False
@@ -38,7 +65,7 @@ def main():
try:
g=h.request;lookup=self.catalog.resolve(g.kind,g.reference,g.registry_version,shelf=g.shelf_id,side=g.side_id,column=g.column_id,tier=g.tier_id)
deadline=self.accepted_at+duration_seconds(g.timeout)
q=Navigate.Goal();q.trace=g.trace;q.position_tolerance=g.position_tolerance;q.orientation_tolerance=g.orientation_tolerance
q=Navigate.Goal();q.trace=g.trace;q.position_tolerance=g.position_tolerance;q.yaw_tolerance=g.orientation_tolerance
p=lookup['pose'];q.target_pose.header.frame_id=p['frame_id'];q.target_pose.header.stamp=self.get_clock().now().to_msg()
for k in ('x','y','z'):setattr(q.target_pose.pose.position,k,float(p[k]))
for k in ('x','y','z','w'):setattr(q.target_pose.pose.orientation,k,float(p['q'+k]))
@@ -46,7 +73,12 @@ def main():
remaining_ns=int((deadline-time.monotonic())*1e9)
if remaining_ns<=0 or h.is_cancel_requested:raise RuntimeError('EXPIRED_BEFORE_DISPATCH')
q.timeout.sec=remaining_ns//1_000_000_000;q.timeout.nanosec=remaining_ns%1_000_000_000
future=self.client.send_goal_async(q);acceptance_deadline=min(deadline,time.monotonic()+3.);sequence=0;last_feedback=0.
def forward_feedback(event):
source=event.feedback
f=NavigateSemantic.Feedback()
for field in ('stamp','sequence','phase','message'):setattr(f,field,getattr(source,field))
h.publish_feedback(f)
future=self.client.send_goal_async(q,feedback_callback=forward_feedback);acceptance_deadline=min(deadline,time.monotonic()+3.)
while not future.done():
if h.is_cancel_requested or time.monotonic()>acceptance_deadline:
def late(f):
@@ -63,20 +95,18 @@ def main():
now=time.monotonic()
if (h.is_cancel_requested or now>=deadline) and cancel_at is None:downstream.cancel_goal_async();cancel_at=now
if cancel_at is not None and now-cancel_at>5:raise RuntimeError('STOP_UNKNOWN')
if now-last_feedback>=.2:
sequence+=1;f=NavigateSemantic.Feedback();f.stamp=self.get_clock().now().to_msg();f.sequence=sequence;f.phase=2 if cancel_at else 1;f.message='awaiting downstream result';h.publish_feedback(f);last_feedback=now
time.sleep(.01)
wrapped=done.result();m=wrapped.result
for field in ('result','final_pose','pose_valid','final_position_error','final_orientation_error','errors_valid'):setattr(result,field,getattr(m,field))
if wrapped.status==4 and m.result.status==0:h.succeed()
elif wrapped.status==5 and h.is_cancel_requested:h.canceled()
translate_navigation_result(m,result,NavigationResult,ExecutionResult)
if wrapped.status==GoalStatus.STATUS_SUCCEEDED and m.result.status==NavigationResult.SUCCEEDED:h.succeed()
elif wrapped.status==GoalStatus.STATUS_CANCELED and m.result.status==NavigationResult.CANCELED and h.is_cancel_requested:h.canceled()
else:h.abort()
except Exception as ex:
self.faulted=True
if downstream is not None and downstream.accepted:
try:downstream.cancel_goal_async()
except Exception:pass
result.result.status=1;result.result.stop_state=0;result.result.error_code=str(ex);h.abort()
result.result.status=ExecutionResult.FAILED;result.result.stop_state=ExecutionResult.UNKNOWN;result.result.error_code=str(ex);h.abort()
finally:
with self.lock:self.reserved=False
return result
@@ -98,6 +98,7 @@ class RosDriver final : public robot_bt::GoalDriver {
bool fresh(robot_bt::RosTime observed, robot_bt::RosTime valid_until,
robot_bt::RosTime capture_after = 0) const;
robot_bt::ExecutionResult execution(const iface::msg::ExecutionResult&,const robot_bt::GoalRequest&) const;
robot_bt::ExecutionResult execution(const iface::msg::NavigationResult&,const robot_bt::GoalRequest&) const;
robot_bt::ExecutionResult readonly_result(rclcpp_action::ResultCode, bool valid,
robot_bt::SkillResponse) const;
robot_bt::SnapshotMeta meta(const robot_bt::GoalRequest&, robot_bt::RosTime,
@@ -137,13 +138,13 @@ class RosDriver final : public robot_bt::GoalDriver {
const std::shared_ptr<const typename Action::Feedback> feedback) {
if (!handle || !feedback || feedback->sequence == 0 || feedback->phase > max_phase) return;
if constexpr (std::is_same_v<Action, Navigate>) {
if(feedback->errors_valid&&(!std::isfinite(feedback->position_error)||feedback->position_error<0||
!std::isfinite(feedback->orientation_error)||std::abs(feedback->orientation_error)>std::acos(-1.0)))return;
if(feedback->pose_valid) {
if(feedback->error_valid&&(!std::isfinite(feedback->position_error)||feedback->position_error<0||
!std::isfinite(feedback->yaw_error)||std::abs(feedback->yaw_error)>std::acos(-1.0)))return;
if(feedback->current_pose_valid) {
const auto& p=feedback->current_pose;
robot_bt::Pose pose{p.header.frame_id,p.pose.position.x,p.pose.position.y,p.pose.position.z,
p.pose.orientation.x,p.pose.orientation.y,p.pose.orientation.z,p.pose.orientation.w};
if(!robot_bt::valid_pose(pose))return;
if(pose.frame_id!="map"||!robot_bt::valid_pose(pose))return;
}
}
if constexpr (std::is_same_v<Action, Manipulate>) {
@@ -168,9 +169,9 @@ class RosDriver final : public robot_bt::GoalDriver {
payload["progress_valid"]=feedback->progress_valid;payload["progress"]=feedback->progress;
}
if constexpr (std::is_same_v<Action, Navigate>) {
payload["pose_valid"]=feedback->pose_valid;payload["errors_valid"]=feedback->errors_valid;
payload["position_error"]=feedback->position_error;payload["orientation_error"]=feedback->orientation_error;
payload["blocked_valid"]=feedback->blocked_valid;payload["blocked"]=feedback->blocked;
payload["current_pose_valid"]=feedback->current_pose_valid;payload["error_valid"]=feedback->error_valid;
payload["position_error"]=feedback->position_error;payload["yaw_error"]=feedback->yaw_error;
payload["blocked"]=feedback->blocked;
}
event.feedback_snapshot=payload.dump();events_.push_back(event);
};
+25 -7
View File
@@ -170,6 +170,24 @@ ExecutionResult RosDriver::execution(const iface::msg::ExecutionResult& m,const
r.stop=StopState::CONFIRMED;
return r;
}
ExecutionResult RosDriver::execution(const iface::msg::NavigationResult& m,const GoalRequest& request)const {
// Navigation outcomes have different numeric values from other skill results.
iface::msg::ExecutionResult common;
common.error_code=m.error_code;common.message=m.message;
common.stop_state=m.stop_state;common.stopped_at=m.stopped_at;common.stop_evidence_ref=m.stop_evidence_ref;
using Nav=iface::msg::NavigationResult;
using Common=iface::msg::ExecutionResult;
switch(m.status) {
case Nav::SUCCEEDED:common.status=Common::COMPLETED;break;
case Nav::CANCELED:common.status=Common::CANCELED;break;
case Nav::TIMEOUT:common.status=Common::TIMED_OUT;break;
case Nav::BLOCKED:common.status=Common::FAILED;if(common.error_code.empty())common.error_code="NAV_BLOCKED";break;
case Nav::NOT_READY:common.status=Common::REJECTED;if(common.error_code.empty())common.error_code="NAV_NOT_READY";break;
case Nav::FAILED:common.status=Common::FAILED;break;
default:{ExecutionResult invalid;invalid.error_code="NAV_RESULT_PROTOCOL_ERROR";return invalid;}
}
return execution(common,request);
}
ExecutionResult RosDriver::readonly_result(rclcpp_action::ResultCode native_code,bool valid,SkillResponse response)const {
ExecutionResult r;r.response=std::move(response);r.response.valid=valid;
// These servers are contractually read-only. Their native terminal is enough
@@ -210,13 +228,13 @@ void RosDriver::send(const GoalRequest& r) {
});break;
}
Navigate::Goal g;g.trace=trace_msg(r.trace);g.target_pose=pose_msg(*r.registered_pose,now);
g.position_tolerance=r.position_tolerance_m;g.orientation_tolerance=r.orientation_tolerance_rad;g.timeout=timeout();
send_typed<Navigate>(navigate_,g,r,6,[this,r](const Navigate::Result& m,auto){
auto out=execution(m.result,r);out.response.valid=m.pose_valid&&m.errors_valid&&
std::isfinite(m.final_position_error)&&std::isfinite(m.final_orientation_error)&&
m.final_position_error>=0&&std::abs(m.final_orientation_error)<=std::acos(-1.0)&&
m.final_position_error<=r.position_tolerance_m&&std::abs(m.final_orientation_error)<=r.orientation_tolerance_rad;
if(m.pose_valid)out.response.final_pose=pose_core(m.final_pose);
g.position_tolerance=r.position_tolerance_m;g.yaw_tolerance=r.orientation_tolerance_rad;g.timeout=timeout();
send_typed<Navigate>(navigate_,g,r,Navigate::Feedback::STOPPING,[this,r](const Navigate::Result& m,auto){
auto out=execution(m.result,r);out.response.valid=m.final_pose_valid&&
std::isfinite(m.final_position_error)&&std::isfinite(m.final_yaw_error)&&
m.final_position_error>=0&&std::abs(m.final_yaw_error)<=std::acos(-1.0)&&
m.final_position_error<=r.position_tolerance_m&&std::abs(m.final_yaw_error)<=r.orientation_tolerance_rad;
if(m.final_pose_valid)out.response.final_pose=pose_core(m.final_pose);
out.response.base_stopped=out.stop==StopState::CONFIRMED;return out;});break;
}
case Skill::PICK:case Skill::PLACE: {
@@ -0,0 +1,69 @@
// Test-only live DDS probe. Build via tests/helpers/native_navigation_contract.py.
#include <bt_executor/ros_driver.hpp>
#include <filesystem>
#include <iostream>
#include <thread>
int main(int argc,char** argv) {
if(argc!=4) return 2;
const int scenario=std::stoi(argv[1]);
const std::string journal=argv[2], ns=argv[3];
if(ns.rfind("/sim/",0)!=0) return 3;
rclcpp::init(0,nullptr);
try {
auto node=std::make_shared<rclcpp::Node>("native_navigation_probe",ns);
bt_executor::RosDriver driver(*node,"robot_01",journal+"/uuids.jsonl",0.5,10000000000LL,robot_bt::Milliseconds(5000));
robot_bt::TaskConfig task; task.route="LEGACY";
driver.bind_task(task,robot_bt::SiteConfig{},1);
robot_bt::ActiveGoalRegistry registry(driver,journal+"/goals.jsonl");
auto until=robot_bt::SteadyClock::now()+std::chrono::seconds(12);
while(!driver.ready(robot_bt::Skill::NAVIGATE)&&robot_bt::SteadyClock::now()<until) {
rclcpp::spin_some(node); std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
if(!driver.ready(robot_bt::Skill::NAVIGATE)) throw std::runtime_error("Navigate discovery timed out");
robot_bt::GoalRequest request;
request.goal_id="probe-"+std::to_string(scenario);request.robot_id="robot_01";
request.trace.task_id=request.goal_id;request.trace.run_id=request.goal_id;request.trace.subtask_id="navigate";
request.skill=robot_bt::Skill::NAVIGATE;request.registered_pose=robot_bt::Pose{"map",double(scenario),0,0,0,0,0,1};
request.capture_after=node->now().nanoseconds();
const auto started=registry.start(request,robot_bt::SteadyClock::now());
if(!started) throw std::runtime_error("start refused");
const std::string active_id=*started;
bool canceled=false;
until=robot_bt::SteadyClock::now()+std::chrono::seconds(12);
while(robot_bt::SteadyClock::now()<until) {
rclcpp::spin_some(node);registry.pump(robot_bt::SteadyClock::now());
auto record=registry.find(active_id);
if(!record) throw std::runtime_error("registry lost active goal: "+active_id);
if((scenario==1||scenario==6)&&record->accepted&&!canceled) {
registry.request_cancel(active_id,robot_bt::SteadyClock::now());canceled=true;
}
if(record->result) break;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
// Pump beyond terminal delivery to expose accidental sends/retries in live transport.
until=robot_bt::SteadyClock::now()+std::chrono::milliseconds(350);
while(robot_bt::SteadyClock::now()<until) {
rclcpp::spin_some(node);registry.pump(robot_bt::SteadyClock::now());
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
auto record=registry.find(active_id);
if(!record||!record->result) throw std::runtime_error("result timeout");
const auto& result=*record->result;
bool blocked_redispatch=false;
if(scenario==6) {
auto next=request;next.goal_id+="-forbidden-retry";
next.trace.subtask_id="navigate-forbidden-retry";++next.trace.attempt;
blocked_redispatch=!registry.start(next,robot_bt::SteadyClock::now()).has_value();
}
nlohmann::json report={{"scenario",scenario},{"code",int(result.code)},{"stop",int(result.stop)},
{"state",int(record->state)},{"robot_locked",registry.robot_locked("robot_01")},
{"error_code",result.error_code},{"detail",result.detail},{"feedback_sequence",record->last_sequence},
{"feedback",record->feedback_snapshot},{"wire_request_type",record->request.wire_request_type},
{"wire_result_type",result.wire_result_type},{"wire_result_bytes",result.wire_result_snapshot.size()/2},
{"response_valid",result.response.valid},{"mapping_count",driver.mappings().size()},
{"unknown_stop_blocks_redispatch",blocked_redispatch}};
std::cout<<report.dump()<<std::endl;
rclcpp::shutdown();return 0;
}catch(const std::exception& e){std::cerr<<e.what()<<std::endl;rclcpp::shutdown();return 1;}
}
@@ -21,7 +21,7 @@ from bt_skill_interfaces.action import (
EvaluateProgress, ExecuteTask, LocalizeTarget3D, LocateShelfColumn,
Navigate, NavigateSemantic, PlanTask, VerifyState,
)
from bt_skill_interfaces.msg import (DenseProgress, ExecutionResult, RobotState, SafetyState,
from bt_skill_interfaces.msg import (DenseProgress, ExecutionResult, NavigationResult, RobotState, SafetyState,
VerificationEvidence, VisualObservation)
from bt_skill_interfaces.srv import GetRobotState, ReconcileGoal
from std_msgs.msg import String
@@ -43,8 +43,8 @@ ACTION_ENDPOINTS = {
}
MOTION = frozenset(("navigate", "navigate_semantic", "execute_manipulation", "execute_posture", "execute_task"))
SUCCESS_PHASES = {
"navigate": (Navigate.Feedback.CHECKING, Navigate.Feedback.PLANNING,
Navigate.Feedback.NAVIGATING, Navigate.Feedback.ARRIVING),
"navigate": (Navigate.Feedback.ACCEPTED, Navigate.Feedback.CHECKING,
Navigate.Feedback.NAVIGATING),
"execute_manipulation": (ExecuteManipulation.Feedback.PREPARING,
ExecuteManipulation.Feedback.WAITING_OBSERVATION,
ExecuteManipulation.Feedback.INFERRING,
@@ -68,11 +68,25 @@ def elapsed_message(seconds):
return Duration(sec=nanoseconds // 1000000000, nanosec=nanoseconds % 1000000000)
def normalized_navigation_pose(target_pose):
pose = copy.deepcopy(target_pose)
q = pose.pose.orientation
scale = max(abs(q.x), abs(q.y), abs(q.z), abs(q.w))
values = [value / scale for value in (q.x, q.y, q.z, q.w)]
norm = math.sqrt(sum(value * value for value in values))
q.x, q.y, q.z, q.w = (value / norm for value in values)
return pose
def lifecycle_phases(name, kind):
if name == "navigate" and kind == "obstacle_recovery":
return (Navigate.Feedback.CHECKING, Navigate.Feedback.PLANNING,
Navigate.Feedback.NAVIGATING, Navigate.Feedback.WAITING_OBSTACLE,
Navigate.Feedback.RECOVERING, Navigate.Feedback.ARRIVING)
return (Navigate.Feedback.ACCEPTED, Navigate.Feedback.CHECKING,
Navigate.Feedback.NAVIGATING, Navigate.Feedback.BLOCKED,
Navigate.Feedback.NAVIGATING)
if name == "navigate" and kind == "blocked":
return (*SUCCESS_PHASES[name], Navigate.Feedback.BLOCKED)
if name == "navigate" and kind == "not_ready":
return (Navigate.Feedback.ACCEPTED, Navigate.Feedback.CHECKING)
return SUCCESS_PHASES.get(name, (0,))
@@ -170,13 +184,13 @@ class MockSkills(Node):
raise ValueError("capture boundary is in the future")
if name == "navigate":
p, q = request.target_pose.pose.position, request.target_pose.pose.orientation
values = [p.x, p.y, p.z, q.x, q.y, q.z, q.w, request.position_tolerance, request.orientation_tolerance]
if not all(math.isfinite(v) for v in values) or not request.target_pose.header.frame_id:
values = [p.x, p.y, p.z, q.x, q.y, q.z, q.w, request.position_tolerance, request.yaw_tolerance]
if not all(math.isfinite(v) for v in values) or request.target_pose.header.frame_id != "map":
raise ValueError("navigation pose is invalid")
if request.position_tolerance <= 0 or not 0 < request.orientation_tolerance <= math.pi:
if request.position_tolerance <= 0 or request.yaw_tolerance <= 0:
raise ValueError("navigation tolerances are invalid")
if abs(q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w - 1.0) > 0.001:
raise ValueError("navigation quaternion must have unit norm")
if not any((q.x, q.y, q.z, q.w)):
raise ValueError("navigation quaternion must be nonzero")
elif name == "execute_manipulation":
if request.skill not in ("pick", "place") or not request.instruction.strip():
raise ValueError("manipulation skill/instruction is invalid")
@@ -310,7 +324,7 @@ class MockSkills(Node):
self._feedback(name, handle, sequence, now - started, phases[phase_index])
emitted_phase = phase_index
time.sleep(min(0.02, max(0, deadline - now)))
if kind == "failed" and outcome == ExecutionResult.COMPLETED:
if (kind == "failed" or name == "navigate" and kind in ("blocked", "not_ready")) and outcome == ExecutionResult.COMPLETED:
outcome = ExecutionResult.FAILED
if kind == "stop_unknown" and outcome == ExecutionResult.COMPLETED:
outcome, stop_state = ExecutionResult.FAILED, ExecutionResult.UNKNOWN
@@ -320,7 +334,7 @@ class MockSkills(Node):
# Unknown execution state stays reserved. A client cannot infer stop from this exception.
stop_state = ExecutionResult.UNKNOWN if name in MOTION else ExecutionResult.CONFIRMED
if hasattr(result, "result"):
result.result = self._execution_result(ExecutionResult.FAILED, stop_state, goal_id, "MOCK_EXCEPTION", str(exc))
result.result = self._execution_result(ExecutionResult.FAILED, stop_state, goal_id, "MOCK_EXCEPTION", str(exc), name=name)
elif hasattr(result, "evidence"):
result.evidence.status = VerificationEvidence.UNKNOWN
result.evidence.error_code = "MOCK_EXCEPTION"
@@ -371,13 +385,12 @@ class MockSkills(Node):
if hasattr(feedback, "elapsed_time"):
feedback.elapsed_time = elapsed_message(elapsed)
if name == "navigate":
feedback.pose_valid = True
feedback.current_pose = copy.deepcopy(handle.request.target_pose)
feedback.errors_valid = True
feedback.current_pose_valid = True
feedback.current_pose = normalized_navigation_pose(handle.request.target_pose)
feedback.error_valid = True
feedback.position_error = 0.0
feedback.orientation_error = 0.0
feedback.blocked_valid = True
feedback.blocked = False
feedback.yaw_error = 0.0
feedback.blocked = feedback.phase == Navigate.Feedback.BLOCKED
if name == "execute_manipulation":
feedback.progress_valid = False
handle.publish_feedback(feedback)
@@ -389,9 +402,18 @@ class MockSkills(Node):
if stopping is not None:
self._feedback(name, handle, sequence, elapsed, stopping)
def _execution_result(self, outcome, stop_state, goal_id, error="", message="SIMULATED execution only"):
result = ExecutionResult()
result.status, result.stop_state = outcome, stop_state
def _execution_result(self, outcome, stop_state, goal_id, error="", message="SIMULATED execution only", name="", kind="normal"):
result = NavigationResult() if name == "navigate" else ExecutionResult()
if name == "navigate":
status = {ExecutionResult.COMPLETED: NavigationResult.SUCCEEDED,
ExecutionResult.CANCELED: NavigationResult.CANCELED,
ExecutionResult.TIMED_OUT: NavigationResult.TIMEOUT,
ExecutionResult.FAILED: NavigationResult.FAILED}[outcome]
if outcome == ExecutionResult.FAILED and kind in ("blocked", "not_ready"):
status = NavigationResult.BLOCKED if kind == "blocked" else NavigationResult.NOT_READY
else:
status = outcome
result.status, result.stop_state = status, stop_state
result.error_code = error or ("" if outcome == ExecutionResult.COMPLETED else "MOCK_TERMINATED")
result.message = message
if stop_state == ExecutionResult.CONFIRMED:
@@ -408,14 +430,20 @@ class MockSkills(Node):
record = "sim://" + name + "/" + goal_id
ok = outcome == ExecutionResult.COMPLETED
if hasattr(result, "result"):
result.result = self._execution_result(outcome, stop_state, goal_id)
error = ""
if name == "navigate" and outcome == ExecutionResult.FAILED:
if kind == "blocked": error = "BLOCKED"
if kind == "not_ready": error = fixture.get("error_code", "INPUTS_UNHEALTHY")
result.result = self._execution_result(outcome, stop_state, goal_id, error, name=name, kind=kind)
if name in ("navigate", "navigate_semantic"):
if ok and stop_state == ExecutionResult.CONFIRMED:
with self.lock:
self.geometry_epoch += 1
if name == "navigate":
result.pose_valid = result.errors_valid = ok
result.final_pose = copy.deepcopy(request.target_pose)
result.final_pose_valid = ok
if ok:
result.final_pose = normalized_navigation_pose(request.target_pose)
result.final_position_error = result.final_yaw_error = 0.0
else:
pose = fixture.get("final_pose")
result.pose_valid = result.errors_valid = ok and pose is not None
@@ -17,7 +17,7 @@ KINDS = {
"unavailable", "invalid_pose", "emergency_stop", "protective_stop",
"model_estimate", "fused",
"cancel_stop_unknown", "timeout_stop_unknown",
"obstacle_recovery",
"obstacle_recovery", "blocked", "not_ready",
}
FIXTURE_FIELDS = {
"kind", "duration_seconds", "shelf_id", "side_id", "column_id", "tier_id",
@@ -26,12 +26,12 @@ FIXTURE_FIELDS = {
"observation_id", "image_path", "station_id", "registry_version",
"calibration_id", "geometry_epoch", "status_json",
"stop_delay_seconds",
"final_pose",
"final_pose", "error_code",
}
BASE_KINDS = {"normal", "failed", "timeout", "silence", "reject", "native_mismatch"}
ACTION_KINDS = {
"navigate": BASE_KINDS | {"stop_unknown", "cancel_stop_unknown", "timeout_stop_unknown", "obstacle_recovery"},
"navigate": BASE_KINDS | {"stop_unknown", "cancel_stop_unknown", "timeout_stop_unknown", "obstacle_recovery", "blocked", "not_ready"},
"navigate_semantic": BASE_KINDS | {"stop_unknown", "cancel_stop_unknown", "timeout_stop_unknown"},
"execute_manipulation": BASE_KINDS | {"stop_unknown", "cancel_stop_unknown", "timeout_stop_unknown"},
"execute_posture": BASE_KINDS | {"stop_unknown", "cancel_stop_unknown", "timeout_stop_unknown"},
@@ -86,6 +86,12 @@ def parse_scenarios(raw):
raise ValueError("unsupported fixture kind")
if fixture.get("kind", "normal") not in ACTION_KINDS[name]:
raise ValueError("fixture kind has no effect for " + name)
if "error_code" in fixture:
code = fixture["error_code"]
if (name != "navigate" or fixture.get("kind") != "not_ready" or
code not in {"INPUTS_UNHEALTHY", "ROBOT_STATE_UNAVAILABLE", "BACKEND_NOT_CONFIGURED",
"ROBOT_EMERGENCY_STOP", "ROBOT_PROTECTIVE_STOP", "ROBOT_MOTION_NOT_ALLOWED"}):
raise ValueError("error_code requires a supported navigation readiness reason")
delay = fixture.get("duration_seconds", 0.2)
if type(delay) not in (int, float) or not math.isfinite(delay) or not 0 <= delay <= 120:
raise ValueError("fixture duration must be finite in [0,120]")
+1
View File
@@ -10,6 +10,7 @@ rosidl_generate_interfaces(${PROJECT_NAME}
"msg/ObjectTarget.msg"
"msg/RegionTarget.msg"
"msg/ExecutionResult.msg"
"msg/NavigationResult.msg"
"msg/ObservationContext.msg"
"msg/RobotState.msg"
"msg/SafetyState.msg"
+13 -16
View File
@@ -1,33 +1,30 @@
# Preserved outer interface from BT DR pp13-14. Canonical ROS2 type: Navigate.
# Navigation contract: trace and stop evidence retained across all attempts.
# Enum values are explicit; both peers must build this same interface package.
bt_skill_interfaces/TaskTrace trace
geometry_msgs/PoseStamped target_pose
float64 position_tolerance
float64 orientation_tolerance
float64 yaw_tolerance
builtin_interfaces/Duration timeout
---
bt_skill_interfaces/ExecutionResult result
bool pose_valid
bt_skill_interfaces/NavigationResult result
bool final_pose_valid
geometry_msgs/PoseStamped final_pose
bool errors_valid
float64 final_position_error
float64 final_orientation_error
float64 final_yaw_error
---
uint8 CHECKING=0
uint8 PLANNING=1
uint8 ACCEPTED=0
uint8 CHECKING=1
uint8 NAVIGATING=2
uint8 WAITING_OBSTACLE=3
uint8 RECOVERING=4
uint8 ARRIVING=5
uint8 STOPPING=6
uint8 BLOCKED=3
uint8 STOPPING=4
builtin_interfaces/Time stamp
uint32 sequence
uint8 phase
bool pose_valid
bool current_pose_valid
geometry_msgs/PoseStamped current_pose
bool errors_valid
bool error_valid
float64 position_error
float64 orientation_error
bool blocked_valid
float64 yaw_error
bool blocked
builtin_interfaces/Duration elapsed_time
string message
@@ -0,0 +1,15 @@
# Navigation-specific outcomes; do not decode with ExecutionResult enum values.
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
+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0"?>
<package format="3">
<name>bt_skill_interfaces</name>
<version>1.2.0</version>
<description>Candidate v1 robot behavior-tree skill and evidence contracts.</description>
<version>2.0.0</version>
<description>Robot skill contracts with navigation-specific outcomes and stop evidence.</description>
<maintainer email="feiyuwang1998@gmail.com">wangfeiyu</maintainer>
<license>Proprietary</license>
<buildtool_depend>ament_cmake</buildtool_depend>
+1 -1
View File
@@ -32,7 +32,7 @@ def goal_for(name):
if name == "navigate":
goal.target_pose.header.frame_id = "map"
goal.target_pose.pose.orientation.w = 1.0
goal.position_tolerance = goal.orientation_tolerance = 0.1
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
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Generated Navigate 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
An external temporary CMake project compiles the existing production driver/core;
no ROS package CMake or installed executable is modified. All endpoints use a
unique /sim namespace. This is contract evidence, not physical navigation evidence.
"""
import argparse
from collections import Counter
from copy import deepcopy
import json
import os
from pathlib import Path
import subprocess
import tempfile
import threading
import time
def build_native(root, directory):
directory.mkdir(parents=True, exist_ok=True)
# Paths are CMake quoted so workspace names containing spaces remain valid.
q = lambda p: '"' + str(p).replace('\\', '/').replace('"', '\\"') + '"'
(directory / 'CMakeLists.txt').write_text('''cmake_minimum_required(VERSION 3.16)
project(native_navigation_probe LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(rclcpp_action REQUIRED)
find_package(bt_skill_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)
''' % 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',
'ros2/bt_executor/include', 'core/include')), encoding='utf-8')
subprocess.run(['cmake', '-S', str(directory), '-B', str(directory / 'build')], check=True)
subprocess.run(['cmake', '--build', str(directory / 'build'), '-j2'], check=True)
return directory / 'build/native_navigation_probe'
def wait(future, timeout=12):
until = time.monotonic() + timeout
while not future.done() and time.monotonic() < until:
time.sleep(.01)
assert future.done(), 'ROS future timed out'
return future.result()
def run(binary, output):
import rclpy
from rclpy.node import Node
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 action_msgs.msg import GoalStatus
assert [getattr(NavigationResult, 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))
assert set(Navigate.Goal.get_fields_and_field_types()) == {
'trace', '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'}
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'}
namespace = '/sim/navigation_contract_' + str(os.getpid())
rclpy.init()
node = Node('navigation_contract_fixture', namespace=namespace)
counts = Counter()
failures = []
lock = threading.Lock()
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]
with lock:
counts[(goal.trace.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
except AssertionError:
failures.append('Goal contract mismatch: ' + str(goal))
for phase in range(5):
feedback = Navigate.Feedback()
feedback.stamp = node.get_clock().now().to_msg()
feedback.sequence = 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 = 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-'):
# A later malformed sample must not poison the accepted sequence.
wrong_frame = deepcopy(feedback)
wrong_frame.sequence = 100
wrong_frame.current_pose.header.frame_id = 'odom'
handle.publish_feedback(wrong_frame)
wrong_phase = deepcopy(feedback)
wrong_phase.sequence = 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.final_pose = goal.target_pose
result.final_position_error = .02
result.final_yaw_error = -.03
if status == 1:
deadline = time.monotonic() + 6
while not handle.is_cancel_requested and time.monotonic() < deadline:
time.sleep(.01)
if not handle.is_cancel_requested:
failures.append('Canceled fixture never received cancellation')
handle.abort()
else:
handle.canceled()
elif status == 0:
handle.succeed()
else:
handle.abort()
return result
server = ActionServer(node, Navigate, 'skills/navigate', execute_callback=execute,
cancel_callback=lambda _: CancelResponse.ACCEPT,
callback_group=ReentrantCallbackGroup())
client = ActionClient(node, Navigate, 'skills/navigate', callback_group=ReentrantCallbackGroup())
executor = MultiThreadedExecutor(num_threads=6)
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',
'namespace': namespace, 'direct': [], 'native': []}
try:
assert client.wait_for_server(timeout_sec=10)
for scenario in range(9):
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.target_pose.header.frame_id = 'map'
goal.target_pose.pose.position.x = float(scenario)
goal.target_pose.pose.orientation.w = 1.
goal.position_tolerance = .05
goal.yaw_tolerance = .1
goal.timeout.sec = 5
feedback = []
handle = wait(client.send_goal_async(goal, feedback_callback=lambda value: feedback.append(value.feedback)))
assert handle.accepted
if scenario in (1, 6):
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
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.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 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
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)):
state = output / ('state-' + str(scenario))
state.mkdir(exist_ok=True)
completed = subprocess.run([str(binary), str(scenario), str(state), namespace],
text=True, capture_output=True, timeout=25)
(output / ('native-' + str(scenario) + '.log')).write_text(completed.stdout + completed.stderr)
if completed.returncode != 0:
report['passed'] = False
report['failure'] = {'scenario': scenario, 'returncode': completed.returncode,
'stdout': completed.stdout, 'stderr': completed.stderr,
'log': str(output / ('native-' + str(scenario) + '.log'))}
(output / 'navigation-contract-report.json').write_text(json.dumps(report, indent=2) + '\n')
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['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
report['native'].append(result)
assert not failures, failures
assert len(counts) == 18 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')
print(json.dumps(report, indent=2))
finally:
executor.shutdown()
thread.join(timeout=3)
client.destroy()
server.destroy()
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, default=Path(__file__).resolve().parents[2])
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--native-probe', type=Path)
parser.add_argument('--build-native', action='store_true')
args = parser.parse_args()
args.output = args.output.resolve()
args.output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='native-navigation-build-') as temporary:
binary = build_native(args.root.resolve(), Path(temporary)) if args.build_native else args.native_probe
if binary is None:
parser.error('Supply --build-native or --native-probe')
run(binary.resolve(), args.output)
+66 -8
View File
@@ -33,7 +33,7 @@ class MockRuntimeTests(unittest.TestCase):
if name == "navigate":
goal.target_pose.header.frame_id = "map"
goal.target_pose.pose.orientation.w = 1.0
goal.position_tolerance = goal.orientation_tolerance = 0.1
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
@@ -89,7 +89,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.COMPLETED)
self.assertEqual(result.result.status, result.result.SUCCEEDED if name == "navigate" else result.result.COMPLETED)
self.assertEqual(result.result.stop_state, result.result.CONFIRMED)
elif hasattr(result, "status"):
self.assertNotEqual(result.status, result.FAILED)
@@ -118,7 +118,7 @@ class MockRuntimeTests(unittest.TestCase):
def test_success_feedback_follows_normal_lifecycle_and_populates_fields(self):
cases = {
"navigate": [0, 1, 2, 5],
"navigate": [0, 1, 2],
"execute_manipulation": [0, 1, 2, 3, 4],
"execute_posture": [0, 1, 2],
}
@@ -131,7 +131,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.pose_valid and nav.errors_valid and nav.blocked_valid)
self.assertTrue(nav.current_pose_valid and nav.error_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,7 +139,7 @@ class MockRuntimeTests(unittest.TestCase):
def test_each_action_feedback_lifecycle_and_navigation_recovery_execute(self):
expected = {
"navigate": [0, 1, 2, 5], "execute_manipulation": [0, 1, 2, 3, 4],
"navigate": [0, 1, 2], "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],
@@ -153,7 +153,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, 4, 5])
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.node.counts["execute_task"] = 0
task, _ = self.execute("execute_task", {"kind": "normal", "duration_seconds": 0.05})
self.assertTrue(task.feedback[0].stage)
@@ -181,7 +182,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)
timeout_result = self.node._execute("navigate", timeout_handle)
self.assertEqual((timeout_handle.native, timeout_result.result.status), ("aborted", timeout_result.result.TIMED_OUT))
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.node.motion_reserved = False; self.node.counts["navigate"] = 0
unknown_handle, unknown = self.execute("navigate", {"kind": "stop_unknown", "duration_seconds": 0})
@@ -205,6 +206,63 @@ class MockRuntimeTests(unittest.TestCase):
self.assertEqual(timed_out.result.stop_state, timed_out.result.UNKNOWN)
self.assertTrue(self.node.motion_reserved)
def test_navigation_status_mapping_and_readiness_reasons(self):
cases = [("normal", 0, "succeeded", ""), ("canceled", 1, "canceled", "MOCK_TERMINATED"),
("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",
"ROBOT_EMERGENCY_STOP", "ROBOT_PROTECTIVE_STOP", "ROBOT_MOTION_NOT_ALLOWED")]
for kind, status, native, code in cases:
self.node.counts["navigate"] = 0
fixture = {"kind": "normal" if kind == "canceled" else kind, "duration_seconds": 0}
if kind == "not_ready": fixture["error_code"] = code
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.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, ""))
def test_navigation_goal_requires_map_and_finite_nonzero_quaternion(self):
mutations = (
lambda g: setattr(g.target_pose.header, "frame_id", "odom"),
lambda g: setattr(g.target_pose.pose.orientation, "w", 0.0),
lambda g: setattr(g.target_pose.pose.orientation, "w", float("nan")),
lambda g: setattr(g.target_pose.pose.position, "x", float("nan")),
lambda g: setattr(g, "yaw_tolerance", float("nan")),
)
for mutate in mutations:
self.node.inflight, self.node.motion_reserved = 0, False
goal = self.goal("navigate"); mutate(goal)
with self.subTest(mutate=mutate):
self.assertEqual(self.node._goal("navigate", goal), self.module.GoalResponse.REJECT)
def test_navigation_accepts_large_yaw_tolerance_and_normalizes_quaternion(self):
for magnitude in (2.0, 1e308, 1e-308):
goal = self.goal("navigate")
goal.yaw_tolerance = 4.0
goal.target_pose.pose.orientation.w = magnitude
self.node.scenarios["navigate"] = [{"kind": "normal", "duration_seconds": 0.1}]
with self.subTest(magnitude=magnitude):
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.assertTrue(result.final_pose_valid)
self.assertEqual(result.final_pose.pose.orientation.w, 1.0)
self.assertTrue(handle.feedback)
self.assertTrue(all(item.current_pose.pose.orientation.w == 1.0 for item in handle.feedback))
self.assertEqual(goal.target_pose.pose.orientation.w, magnitude)
def test_action_specific_malformed_goals_are_rejected(self):
mutations = {
"navigate": lambda g: setattr(g.target_pose.header, "frame_id", ""),
@@ -387,7 +445,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.COMPLETED)
self.assertEqual(result.result.status, result.result.SUCCEEDED)
def test_topic_and_result_fixture_values_are_finite_and_bounded(self):
for raw in (
+32
View File
@@ -8,3 +8,35 @@ class CatalogTests(unittest.TestCase):
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)
+154
View File
@@ -489,4 +489,158 @@ class ProxyTests(unittest.TestCase):
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()
+16 -8
View File
@@ -79,21 +79,29 @@ class RosContractTests(unittest.TestCase):
goal, result, feedback = sections("Navigate")
self.assertEqual(goal, "\n".join([
"bt_skill_interfaces/TaskTrace trace", "geometry_msgs/PoseStamped target_pose",
"float64 position_tolerance", "float64 orientation_tolerance", "builtin_interfaces/Duration timeout",
"float64 position_tolerance", "float64 yaw_tolerance", "builtin_interfaces/Duration timeout",
]))
self.assertEqual(result, "\n".join([
"bt_skill_interfaces/ExecutionResult result", "bool pose_valid", "geometry_msgs/PoseStamped final_pose",
"bool errors_valid", "float64 final_position_error", "float64 final_orientation_error",
"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 CHECKING=0", "uint8 PLANNING=1", "uint8 NAVIGATING=2", "uint8 WAITING_OBSTACLE=3",
"uint8 RECOVERING=4", "uint8 ARRIVING=5", "uint8 STOPPING=6", "builtin_interfaces/Time stamp",
"uint32 sequence", "uint8 phase", "bool pose_valid", "geometry_msgs/PoseStamped current_pose",
"bool errors_valid", "float64 position_error", "float64 orientation_error", "bool blocked_valid",
"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_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_manipulation_exact_source_outer_contract(self):
goal, result, feedback = sections("ExecuteManipulation")
self.assertEqual(goal, "\n".join([
@@ -156,7 +164,7 @@ class RosContractTests(unittest.TestCase):
def test_all_idl_files_are_registered_in_build(self):
cmake = (INTERFACES / "CMakeLists.txt").read_text()
declarations = re.findall(r'"((?:msg|srv|action)/[^"\n]+)"', cmake)
actual = sorted(str(p.relative_to(INTERFACES)) for p in INTERFACES.rglob("*") if p.suffix in (".msg", ".srv", ".action"))
actual = sorted(p.relative_to(INTERFACES).as_posix() for p in INTERFACES.rglob("*") if p.suffix in (".msg", ".srv", ".action"))
self.assertEqual(sorted(declarations), actual)
self.assertEqual(len(declarations), len(set(declarations)))
manifest = ET.parse(INTERFACES / "package.xml").getroot()