实现行为树执行器、任务协调和技能接口

This commit is contained in:
2026-09-20 12:18:52 +08:00
commit 492676344a
143 changed files with 13010 additions and 0 deletions
@@ -0,0 +1 @@
"""ROS2 simulation fixtures; never connect these servers to a robot."""
@@ -0,0 +1,747 @@
"""Bounded rclpy action fixtures scoped to /sim/<robot>; never issue robot commands.
VLA completion and verification are separate fixtures. Default verification is
UNKNOWN, so a default successful VLA result cannot count a delivered item.
"""
import copy
import json
import math
import threading
import time
import rclpy
from builtin_interfaces.msg import Duration, Time
from rclpy.action import ActionServer, CancelResponse, GoalResponse
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from bt_skill_interfaces.action import (
AssessGrasp, CheckFreeSpace, ExecuteManipulation, ExecutePosture,
EvaluateProgress, ExecuteTask, LocalizeTarget3D, LocateShelfColumn,
Navigate, NavigateSemantic, PlanTask, VerifyState,
)
from bt_skill_interfaces.msg import (DenseProgress, ExecutionResult, RobotState, SafetyState,
VerificationEvidence, VisualObservation)
from bt_skill_interfaces.srv import GetRobotState, ReconcileGoal
from std_msgs.msg import String
from .scenarios import duration_seconds, fixture_at, fixed_plan, parse_scenarios, strict_json, validate_trace
ACTION_TYPES = {
"navigate": Navigate, "execute_manipulation": ExecuteManipulation,
"plan_task": PlanTask, "locate_shelf_column": LocateShelfColumn,
"localize_target_3d": LocalizeTarget3D, "check_free_space": CheckFreeSpace,
"assess_grasp": AssessGrasp, "execute_posture": ExecutePosture, "verify_state": VerifyState,
"navigate_semantic": NavigateSemantic, "evaluate_progress": EvaluateProgress,
"execute_task": ExecuteTask,
}
ACTION_ENDPOINTS = {
**{name: "skills/" + name for name in ACTION_TYPES},
"plan_task": "tasks/plan",
"execute_task": "tasks/execute",
"evaluate_progress": "monitor/evaluate_progress",
}
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),
"execute_manipulation": (ExecuteManipulation.Feedback.PREPARING,
ExecuteManipulation.Feedback.WAITING_OBSERVATION,
ExecuteManipulation.Feedback.INFERRING,
ExecuteManipulation.Feedback.EXECUTING,
ExecuteManipulation.Feedback.COMPLETING),
"plan_task": tuple(range(3)), "locate_shelf_column": (0, 1),
"localize_target_3d": (0, 1), "check_free_space": (0, 1),
"assess_grasp": (0,), "execute_posture": (ExecutePosture.Feedback.CHECKING,
ExecutePosture.Feedback.MOVING,
ExecutePosture.Feedback.SETTLING),
"verify_state": (0,), "navigate_semantic": (0,), "evaluate_progress": (0,),
}
def ros_time(nanoseconds):
return Time(sec=max(0, nanoseconds) // 1000000000, nanosec=max(0, nanoseconds) % 1000000000)
def elapsed_message(seconds):
nanoseconds = max(0, int(seconds * 1e9))
return Duration(sec=nanoseconds // 1000000000, nanosec=nanoseconds % 1000000000)
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 SUCCESS_PHASES.get(name, (0,))
class MockSkills(Node):
def __init__(self):
super().__init__("bt_mock_skills", namespace="/sim/robot_01")
namespace = self.get_namespace()
if not namespace.startswith("/sim/") or len(namespace.split("/")) != 3:
raise RuntimeError("Mock servers require the dedicated /sim/<robot> namespace")
self.robot_id = namespace.rsplit("/", 1)[1]
self.declare_parameter("scenarios_json", "{}")
self.declare_parameter("max_goal_seconds", 30.0)
self.declare_parameter("allowed_postures", ["pregrasp", "transport", "home"])
self.declare_parameter("initial_holding_state", "UNKNOWN")
self.declare_parameter("enabled_actions", list(ACTION_TYPES))
self.declare_parameter("enabled_topics", ["robot_state", "safety_state", "visual_observation",
"dense_progress", "goal_registry"])
self.scenarios = parse_scenarios(self.get_parameter("scenarios_json").value)
self.max_seconds = self.get_parameter("max_goal_seconds").value
if not math.isfinite(self.max_seconds) or not 0 < self.max_seconds <= 120:
raise ValueError("max_goal_seconds must be finite in (0,120]")
self.allowed_postures = frozenset(self.get_parameter("allowed_postures").value)
if not self.allowed_postures or any(not isinstance(p, str) or not p for p in self.allowed_postures):
raise ValueError("allowed_postures must contain nonempty whitelist keys")
self.enabled_actions = tuple(self.get_parameter("enabled_actions").value)
self.enabled_topics = frozenset(self.get_parameter("enabled_topics").value)
if len(set(self.enabled_actions)) != len(self.enabled_actions) or set(self.enabled_actions) - set(ACTION_TYPES):
raise ValueError("enabled_actions contains an unknown or duplicate action")
topic_names = {"robot_state", "safety_state", "visual_observation", "dense_progress", "goal_registry"}
if set(self.enabled_topics) - topic_names:
raise ValueError("enabled_topics contains an unknown topic")
self.lock = threading.Lock()
self.motion_reserved = False
self.inflight = 0
self.counts = {name: 0 for name in ACTION_TYPES}
self.topic_counts = {name: 0 for name in
("robot_state", "safety_state", "visual_observation", "dense_progress", "goal_registry")}
self.accepted = {}
self.unresolved_motion = {}
self.motion_owner = None
self.geometry_epoch = 1
initial_holding = self.get_parameter("initial_holding_state").value
if initial_holding not in ("UNKNOWN", "EMPTY"):
raise ValueError("initial_holding_state must be an explicit UNKNOWN or EMPTY fixture")
self.holding_state = RobotState.EMPTY if initial_holding == "EMPTY" else RobotState.HOLDING_UNKNOWN
self.held_target = ""
self.posture_id = "home"
self.group = ReentrantCallbackGroup()
self.servers = []
for name in self.enabled_actions:
action = ACTION_TYPES[name]
endpoint = ACTION_ENDPOINTS[name]
self._check_endpoint(endpoint)
self.servers.append(ActionServer(
self, action, endpoint, callback_group=self.group,
goal_callback=lambda request, n=name: self._goal(n, request),
cancel_callback=lambda _: CancelResponse.ACCEPT,
handle_accepted_callback=lambda handle, n=name: self._accepted(n, handle),
execute_callback=lambda handle, n=name: self._execute(n, handle),
))
endpoints = {"robot_state": "robot_state", "safety_state": "safety_state",
"visual_observation": "observations/scene", "dense_progress": "monitor/dense_progress",
"goal_registry": "goal_registry"}
for name in [endpoints[item] for item in self.enabled_topics] + ["get_robot_state", "reconcile_goal"]:
self._check_endpoint(name)
if "robot_state" in self.enabled_topics: self.state_pub = self.create_publisher(RobotState, "robot_state", 10)
if "safety_state" in self.enabled_topics: self.safety_pub = self.create_publisher(SafetyState, "safety_state", 10)
if "visual_observation" in self.enabled_topics: self.observation_pub = self.create_publisher(VisualObservation, "observations/scene", 10)
if "dense_progress" in self.enabled_topics: self.progress_pub = self.create_publisher(DenseProgress, "monitor/dense_progress", 10)
if "goal_registry" in self.enabled_topics: self.registry_pub = self.create_publisher(String, "goal_registry", 10)
self.state_service = self.create_service(GetRobotState, "get_robot_state", self._get_state, callback_group=self.group)
self.reconcile_service = self.create_service(ReconcileGoal, "reconcile_goal", self._reconcile, callback_group=self.group)
self.timer = self.create_timer(0.2, self._publish_states, callback_group=self.group)
self.get_logger().warning("SIMULATION ONLY: no hardware commands; verification defaults to UNKNOWN")
def _check_endpoint(self, name):
resolved = self.resolve_topic_name(name)
if not resolved.startswith(self.get_namespace() + "/"):
raise RuntimeError("Mock endpoint remappings must remain in this simulation namespace")
def _goal(self, name, request):
try:
if duration_seconds(request.timeout) > self.max_seconds:
raise ValueError("requested timeout exceeds mock bound")
if hasattr(request, "trace"):
validate_trace(request.trace)
elif not request.task_id or (hasattr(request, "subtask_id") and not request.subtask_id):
raise ValueError("task/subtask is required")
if hasattr(request, "capture_after"):
capture = request.capture_after
if capture.sec < 0 or not 0 <= capture.nanosec < 1000000000:
raise ValueError("capture boundary is invalid")
capture_ns = capture.sec * 1000000000 + capture.nanosec
if capture_ns > self.get_clock().now().nanoseconds:
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:
raise ValueError("navigation pose is invalid")
if request.position_tolerance <= 0 or not 0 < request.orientation_tolerance <= math.pi:
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")
elif name == "execute_manipulation":
if request.skill not in ("pick", "place") or not request.instruction.strip():
raise ValueError("manipulation skill/instruction is invalid")
if not request.target.object_ref or not request.target.description:
raise ValueError("manipulation target is required")
destination = (bool(request.destination.region_ref), bool(request.destination.description))
if destination != ((False, False) if request.skill == "pick" else (True, True)):
raise ValueError("destination must be empty for pick and complete for place")
elif name == "navigate_semantic":
if request.kind not in ("LOCATION", "OBJECT", "CELL") or not request.reference or not request.registry_version:
raise ValueError("semantic navigation binding is invalid")
if request.kind == "CELL" and any(
not isinstance(value, str) or not value or "/" in value
for value in (request.shelf_id, request.side_id, request.column_id, request.tier_id)):
raise ValueError("semantic cell binding is incomplete")
if request.position_tolerance <= 0 or not 0 < request.orientation_tolerance <= math.pi:
raise ValueError("semantic navigation tolerances are invalid")
elif name == "execute_posture":
if request.posture_id not in self.allowed_postures or not request.expected_geometry_epoch:
raise ValueError("posture or geometry epoch is invalid")
elif name == "plan_task":
if not request.instruction or not request.task_revision or not request.planning_generation:
raise ValueError("planning identity/instruction is invalid")
for raw in (request.known_info_json, request.context_snapshot_json, request.constraints_json):
if not isinstance(strict_json(raw), dict):
raise ValueError("planning JSON must be an object")
elif name == "verify_state" and request.check not in range(5):
raise ValueError("unsupported verification check")
elif name == "verify_state":
if not request.expected_geometry_epoch or not request.target.object_ref or not request.target.description:
raise ValueError("verification target and geometry epoch are required")
if request.check in (request.PICK, request.TRANSPORT, request.PLACE) and not request.source_goal_id:
raise ValueError("verification source goal is required")
if request.check == request.PLACE and (not request.destination.region_ref or not request.destination.description):
raise ValueError("place verification destination is required")
elif name == "locate_shelf_column":
if not all((request.target_ref, request.target_description, request.source_region_ref,
request.observation_station_id)) or not request.station_registry_version:
raise ValueError("shelf localization binding is incomplete")
elif name == "localize_target_3d":
if not all((request.target_ref, request.target_description, request.shelf_id,
request.column_id, request.station_binding_ref)) or not request.expected_geometry_epoch:
raise ValueError("3D localization binding is incomplete")
elif name == "check_free_space":
if not all((request.destination_ref, request.destination_description,
request.object_ref, request.object_description)):
raise ValueError("free-space target is incomplete")
if not isinstance(strict_json(request.placement_constraints_json), dict):
raise ValueError("placement constraints must be an object")
elif name == "assess_grasp":
context = request.target_binding.context
valid_until_ns = (request.robot_state.valid_until.sec * 1000000000 +
request.robot_state.valid_until.nanosec)
if (not request.target_binding.target.object_ref or not request.target_binding.target.description or
not context.schema_version or not context.geometry_epoch or
not request.robot_state.robot_id or not request.allowed_posture_ids or
valid_until_ns <= self.get_clock().now().nanoseconds or
any(p not in self.allowed_postures for p in request.allowed_posture_ids)):
raise ValueError("grasp assessment inputs are incomplete")
elif name == "evaluate_progress":
window = strict_json(request.window_json)
if not request.task_description.strip() or not request.sequence or not isinstance(window, list) or not window:
raise ValueError("progress window is invalid")
elif name == "execute_task":
plan, context = strict_json(request.approved_plan_json), strict_json(request.context_json)
if not isinstance(plan, dict) or not isinstance(plan.get("subtasks"), list) or not isinstance(context, dict):
raise ValueError("task boundary JSON is invalid")
with self.lock:
scenario = fixture_at(self.scenarios, name, self.counts[name])
if scenario.get("kind") == "reject":
self.counts[name] += 1
return GoalResponse.REJECT
# Four active work callbacks leave threads for cancellation and state.
if self.inflight >= 4 or (name in MOTION and self.motion_reserved):
return GoalResponse.REJECT
self.inflight += 1
if name in MOTION:
self.motion_reserved = True
return GoalResponse.ACCEPT
except (ValueError, TypeError, OverflowError) as exc:
self.get_logger().warning("Rejecting " + name + ": " + str(exc))
return GoalResponse.REJECT
def _accepted(self, name, handle):
goal_id = bytes(handle.goal_id.uuid).hex()
with self.lock:
fixture = fixture_at(self.scenarios, name, self.counts[name])
self.counts[name] += 1
self.accepted[goal_id] = (time.monotonic(), fixture)
if name in MOTION:
self.motion_owner = (goal_id, name, copy.deepcopy(handle.request.trace))
handle.execute()
def _execute(self, name, handle):
goal_id = bytes(handle.goal_id.uuid).hex()
with self.lock:
started, fixture = self.accepted.pop(goal_id)
kind = fixture.get("kind", "normal")
budget = duration_seconds(handle.request.timeout)
deadline = started + budget
finish = started + fixture.get("duration_seconds", 0.2)
outcome, stop_state = ExecutionResult.COMPLETED, ExecutionResult.CONFIRMED
sequence = 0
emitted_phase = -1
result = ACTION_TYPES[name].Result()
try:
while True:
now = time.monotonic()
if handle.is_cancel_requested:
outcome = ExecutionResult.CANCELED
self._stopping_feedback(name, handle, sequence + 1, now - started)
if kind == "cancel_stop_unknown":
stop_state = ExecutionResult.UNKNOWN
else:
time.sleep(fixture.get("stop_delay_seconds", 0.0))
break
if now >= deadline or not rclpy.ok():
outcome = ExecutionResult.TIMED_OUT
self._stopping_feedback(name, handle, sequence + 1, now - started)
if kind == "timeout_stop_unknown":
stop_state = ExecutionResult.UNKNOWN
break
if now >= finish and kind not in ("timeout", "timeout_stop_unknown", "silence"):
break
if kind != "silence":
phases = lifecycle_phases(name, kind)
duration = max(0.001, finish - started)
phase_index = min(len(phases) - 1, int((now - started) / duration * len(phases)))
if phase_index > emitted_phase:
sequence += 1
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:
outcome = ExecutionResult.FAILED
if kind == "stop_unknown" and outcome == ExecutionResult.COMPLETED:
outcome, stop_state = ExecutionResult.FAILED, ExecutionResult.UNKNOWN
self._fill_result(name, handle.request, result, fixture, goal_id, outcome, stop_state)
self._finish_native(handle, outcome, kind)
except Exception as exc:
# 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))
elif hasattr(result, "evidence"):
result.evidence.status = VerificationEvidence.UNKNOWN
result.evidence.error_code = "MOCK_EXCEPTION"
elif hasattr(result, "status"):
result.status = result.FAILED
result.error_code = "MOCK_EXCEPTION"
result.message = str(exc)
elif hasattr(result, "decision"):
result.decision = result.UNKNOWN
result.error_code = "MOCK_EXCEPTION"
if handle.is_active:
handle.abort()
self.get_logger().error("Mock execution exception: " + str(exc))
finally:
with self.lock:
self.inflight -= 1
if name in MOTION and stop_state == ExecutionResult.CONFIRMED:
self.motion_reserved = False
self.unresolved_motion.pop(goal_id, None)
if self.motion_owner and self.motion_owner[0] == goal_id:
self.motion_owner = None
elif name in MOTION:
self.unresolved_motion[goal_id] = (name, copy.deepcopy(handle.request.trace))
return result
@staticmethod
def _finish_native(handle, outcome, kind):
if kind == "native_mismatch":
handle.abort() # Intentional protocol-negative fixture: payload remains COMPLETED.
elif outcome == ExecutionResult.CANCELED:
handle.canceled()
elif outcome == ExecutionResult.COMPLETED:
handle.succeed()
else:
handle.abort()
def _feedback(self, name, handle, sequence, elapsed, phase=None):
feedback = ACTION_TYPES[name].Feedback()
feedback.stamp = self.get_clock().now().to_msg()
feedback.sequence = sequence
if hasattr(feedback, "phase"):
feedback.phase = SUCCESS_PHASES[name][0] if phase is None else phase
if hasattr(feedback, "message"):
feedback.message = "SIMULATED " + name
if name == "execute_task":
feedback.stage = "SIMULATED_STAGE_" + str(sequence)
feedback.status_json = json.dumps({"sequence": sequence}, allow_nan=False)
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.position_error = 0.0
feedback.orientation_error = 0.0
feedback.blocked_valid = True
feedback.blocked = False
if name == "execute_manipulation":
feedback.progress_valid = False
handle.publish_feedback(feedback)
def _stopping_feedback(self, name, handle, sequence, elapsed):
stopping = {"navigate": Navigate.Feedback.STOPPING,
"execute_manipulation": ExecuteManipulation.Feedback.STOPPING,
"execute_posture": ExecutePosture.Feedback.STOPPING}.get(name)
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
result.error_code = error or ("" if outcome == ExecutionResult.COMPLETED else "MOCK_TERMINATED")
result.message = message
if stop_state == ExecutionResult.CONFIRMED:
result.stopped_at = self.get_clock().now().to_msg()
result.stop_evidence_ref = "sim://stop/" + goal_id
return result
def _fill_result(self, name, request, result, fixture, goal_id, outcome, stop_state):
kind = fixture.get("kind", "normal")
stamp = self.get_clock().now().nanoseconds
if kind == "stale_observation":
stamp = max(0, stamp - 60000000000)
observed = ros_time(stamp)
record = "sim://" + name + "/" + goal_id
ok = outcome == ExecutionResult.COMPLETED
if hasattr(result, "result"):
result.result = self._execution_result(outcome, stop_state, goal_id)
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)
else:
pose = fixture.get("final_pose")
result.pose_valid = result.errors_valid = ok and pose is not None
if pose is not None:
result.final_pose.header.frame_id = pose["frame_id"]
result.final_pose.pose.position.x = pose["x"]
result.final_pose.pose.position.y = pose["y"]
result.final_pose.pose.position.z = pose["z"]
result.final_pose.pose.orientation.x = pose["qx"]
result.final_pose.pose.orientation.y = pose["qy"]
result.final_pose.pose.orientation.z = pose["qz"]
result.final_pose.pose.orientation.w = pose["qw"]
result.final_pose.header.stamp = observed
elif name == "execute_manipulation":
result.execution_record_ref = record
# Deliberately no holding/verification state mutation here.
elif name == "plan_task":
plan = fixture.get("plan") or fixed_plan(request.instruction, strict_json(request.known_info_json), fixture.get("plan_version", 1))
result.status = result.FAILED if not ok else (result.NEEDS_CLARIFICATION if plan.get("missing_information") else result.PLAN_READY)
result.task_plan_json = json.dumps(plan, ensure_ascii=False, allow_nan=False)
result.planning_record_ref = record
if result.status == result.NEEDS_CLARIFICATION:
result.error_code, result.message = "MOCK_MISSING_INFORMATION", "SIMULATED plan needs clarification"
elif name == "locate_shelf_column":
result.status = result.SUCCEEDED if ok else result.FAILED
if kind == "not_found": result.status = result.NOT_FOUND
if kind == "ambiguous": result.status = result.AMBIGUOUS
result.shelf_id = fixture.get("shelf_id", "shelf_A")
result.side_id = fixture.get("side_id", "FRONT")
result.column_id = fixture.get("column_id", "1")
result.tier_id = fixture.get("tier_id", "1")
result.confidence = 0.99 if result.status == result.SUCCEEDED else 0.0
result.observation_id, result.observed_at, result.record_ref = record, observed, record
if result.status != result.SUCCEEDED:
label = ("NOT_FOUND" if result.status == result.NOT_FOUND else
"AMBIGUOUS" if result.status == result.AMBIGUOUS else "FAILED")
result.error_code = "MOCK_" + label
result.message = "SIMULATED shelf localization " + label.lower()
elif name == "localize_target_3d":
result.status = result.SUCCEEDED if ok else result.FAILED
if kind == "not_found": result.status = result.NOT_FOUND
if kind == "ambiguous": result.status = result.AMBIGUOUS
result.target_ref = "wrong_object" if kind == "wrong_object" else request.target_ref
result.grasp_region_ref = "sim_grasp:" + result.target_ref
result.target_point.header.frame_id = "base_link"
result.target_point.header.stamp = observed
result.target_point.point.x, result.target_point.point.z = 0.5, 0.8
result.grasp_point = copy.deepcopy(result.target_point)
result.grasp_point_valid = True
result.measurement_source = (result.MODEL_ESTIMATE if kind == "model_estimate" else
result.FUSED if kind == "fused" else result.RGBD)
result.geometry_valid = result.status == result.SUCCEEDED
result.quality_code = "OK" if result.geometry_valid else "MOCK_INVALID"
result.position_error_bound, result.position_error_bound_valid = 0.005, True
result.observation_id = record
result.rgb_stamp = result.depth_stamp = observed
result.calibration_id = "sim_calibration_v1"
result.geometry_epoch = request.expected_geometry_epoch
result.record_ref = record
if result.status != result.SUCCEEDED:
result.error_code = "MOCK_" + ("NOT_FOUND" if result.status == result.NOT_FOUND else
"AMBIGUOUS" if result.status == result.AMBIGUOUS else "FAILED")
result.message = "SIMULATED 3D localization did not produce a usable target"
elif name == "check_free_space":
result.status = result.SUCCEEDED if ok else result.FAILED
if kind == "no_free_space": result.status = result.NO_FREE_SPACE
if kind == "ambiguous": result.status = result.AMBIGUOUS
result.destination_ref = "wrong_container" if kind == "wrong_destination" else request.destination_ref
result.placement_region_ref = "sim_place:" + result.destination_ref
result.placement_point.header.frame_id = "base_link"
result.placement_point.header.stamp = observed
result.placement_point.point.x, result.placement_point.point.z = 0.5, 0.6
result.placement_point_valid = True
result.placement_pose_valid = False
result.geometry_valid = result.status == result.SUCCEEDED
result.confidence = 0.99 if result.geometry_valid else 0.0
result.quality_code = "OK" if result.geometry_valid else "MOCK_INVALID"
result.observation_id, result.observed_at, result.valid_until = record, observed, ros_time(stamp + 5000000000)
result.record_ref = record
if result.status != result.SUCCEEDED:
result.error_code = "MOCK_" + ("NO_FREE_SPACE" if result.status == result.NO_FREE_SPACE else
"AMBIGUOUS" if result.status == result.AMBIGUOUS else "FAILED")
result.message = "SIMULATED placement search did not produce usable free space"
elif name == "assess_grasp":
result.decision = result.DIRECT if ok else result.UNKNOWN
if kind == "unknown": result.decision = result.UNKNOWN
if kind == "not_reachable": result.decision = result.NOT_REACHABLE
if kind == "adjust_posture":
result.decision = result.ADJUST_POSTURE
result.posture_id = fixture.get("posture_id", "pregrasp")
if result.posture_id not in request.allowed_posture_ids:
result.decision, result.posture_id = result.UNKNOWN, ""
result.geometry_epoch = request.target_binding.context.geometry_epoch
result.evidence_ref = record
if result.decision != result.DIRECT:
labels = {result.ADJUST_POSTURE: "ADJUST_POSTURE", result.NOT_REACHABLE: "NOT_REACHABLE",
result.UNKNOWN: "UNKNOWN"}
label = labels[result.decision]
result.error_code = "MOCK_" + label
result.message = "SIMULATED grasp assessment " + label.lower()
elif name == "execute_posture":
if ok and stop_state == ExecutionResult.CONFIRMED:
with self.lock:
self.geometry_epoch = max(self.geometry_epoch, request.expected_geometry_epoch) + 1
self.posture_id = request.posture_id
result.geometry_epoch = self.geometry_epoch
result.robot_state = self._robot_state()
if ok and stop_state == ExecutionResult.CONFIRMED:
# Completion snapshot is stopped; registry ownership is released only after native terminal.
result.robot_state.base_stopped_valid = result.robot_state.base_stopped = True
result.robot_state.posture_settled_valid = result.robot_state.posture_settled = True
result.robot_state.evidence_ref = "sim://posture_settled/" + goal_id
elif name == "verify_state":
self._verify(request, result.evidence, fixture, observed, stamp, record, ok)
elif name == "evaluate_progress":
result.feedback_state.trace = copy.deepcopy(request.trace)
result.feedback_state.observed_at = observed
result.feedback_state.sequence = request.sequence
result.feedback_state.state = fixture.get("state", "IN_PROGRESS") if ok else "UNKNOWN"
result.feedback_state.progress = float(fixture.get("progress", 0.5)) if ok else 0.0
result.feedback_state.progress_valid = ok
result.feedback_state.hop_json = fixture.get("hop_json", "{}")
result.feedback_state.record_ref = record
result.error_code = "" if ok else "MOCK_TERMINATED"
elif name == "execute_task":
result.completed_quantity = int(fixture.get("completed_quantity", 1 if ok else 0))
result.evidence_json = fixture.get("evidence_json", "{}")
if hasattr(result, "error_code") and not ok:
result.error_code = "MOCK_TERMINATED"
result.message = "SIMULATED non-completion"
def _verify(self, request, evidence, fixture, observed, stamp, record, ok):
kind = fixture.get("kind", "unknown")
evidence.context.schema_version = 1
evidence.context.trace = copy.deepcopy(request.trace)
evidence.context.source_goal_id = request.source_goal_id
evidence.context.geometry_epoch = request.expected_geometry_epoch
evidence.context.observed_at = observed
evidence.context.valid_until = ros_time(stamp + 5000000000)
evidence.context.writer = "bt_mock_verifier"
evidence.context.observation_id = record
evidence.source, evidence.evidence_ref = "SIMULATOR_INDEPENDENT_FIXTURE", record
evidence.status, evidence.holding_state = evidence.UNKNOWN, evidence.HOLDING_UNKNOWN
evidence.target_ref, evidence.destination_ref = request.target.object_ref, request.destination.region_ref
if not ok or kind not in ("passed", "wrong_object", "wrong_destination", "stale_observation"):
evidence.error_code = "MOCK_VERIFICATION_UNKNOWN"
evidence.message = "SIMULATED verification is unknown"
return
evidence.status = evidence.PASSED
evidence.stopped_valid = evidence.stopped = True
evidence.target_match_valid = True
evidence.target_match = kind != "wrong_object"
if kind == "wrong_object": evidence.target_ref = "wrong_object"
if request.check in (request.PRECHECK, request.PLACE):
evidence.holding_state = evidence.EMPTY
evidence.hand_empty_valid = evidence.hand_empty = True
elif request.check in (request.PICK, request.TRANSPORT):
evidence.holding_state = evidence.HOLDING_OTHER if kind == "wrong_object" else evidence.HOLDING_TARGET
evidence.grasp_stable_valid = evidence.grasp_stable = True
if request.check == request.PLACE:
evidence.target_in_destination_valid = True
evidence.target_in_destination = kind != "wrong_destination"
if kind == "wrong_destination": evidence.destination_ref = "wrong_container"
if kind in ("wrong_object", "wrong_destination"):
evidence.status = evidence.FAILED
evidence.error_code = "MOCK_VERIFICATION_FAILED"
evidence.message = "SIMULATED verification contradicted the requested state"
with self.lock:
self.holding_state = evidence.holding_state
self.held_target = evidence.target_ref if evidence.holding_state == evidence.HOLDING_TARGET else ""
def _robot_state(self):
state = RobotState()
now = self.get_clock().now().nanoseconds
state.robot_id = self.robot_id
state.stamp, state.valid_until = ros_time(now), ros_time(now + 1000000000)
with self.lock:
state.geometry_epoch, state.holding_state = self.geometry_epoch, self.holding_state
state.held_target_ref, state.posture_id = self.held_target, self.posture_id
state.base_stopped_valid = state.posture_settled_valid = not self.motion_reserved
state.base_stopped = state.posture_settled = not self.motion_reserved
state.pose.header.frame_id = "map"
state.pose.header.stamp = state.stamp
state.pose.pose.orientation.w = 1.0
state.pose_valid = True
state.evidence_ref = "sim://state/" + self.robot_id
return state
def _safety_state(self):
state = SafetyState()
now = self.get_clock().now().nanoseconds
state.robot_id = self.robot_id
state.stamp, state.valid_until = ros_time(now), ros_time(now + 1000000000)
state.safety_valid = state.motion_allowed = True
state.evidence_ref = "sim://safety/" + self.robot_id
return state
def _publish_states(self):
state_fixture = self._next_topic_fixture("robot_state")
safety_fixture = self._next_topic_fixture("safety_state")
state, safety = self._robot_state(), self._safety_state()
if state_fixture.get("kind") == "stale_observation":
state.valid_until = ros_time(1)
elif state_fixture.get("kind") == "invalid_pose":
state.pose_valid = False
elif state_fixture.get("kind") == "stop_unknown":
state.base_stopped_valid = state.posture_settled_valid = False
if safety_fixture.get("kind") == "unavailable":
safety.safety_valid = safety.motion_allowed = False
safety.error_code = "MOCK_SAFETY_UNAVAILABLE"
elif safety_fixture.get("kind") == "emergency_stop":
safety.emergency_stop_active, safety.motion_allowed = True, False
elif safety_fixture.get("kind") == "protective_stop":
safety.protective_stop_active, safety.motion_allowed = True, False
if "robot_state" in self.enabled_topics: self.state_pub.publish(state)
if "safety_state" in self.enabled_topics: self.safety_pub.publish(safety)
if "visual_observation" in self.enabled_topics:
self.observation_pub.publish(self._visual_observation(self._next_topic_fixture("visual_observation")))
if "dense_progress" in self.enabled_topics:
self.progress_pub.publish(self._dense_progress(self._next_topic_fixture("dense_progress")))
if "goal_registry" in self.enabled_topics:
registry = self._next_topic_fixture("goal_registry")
default_registry = json.dumps(
{"robot_id": self.robot_id, "motion_reserved": self.motion_reserved}, allow_nan=False)
self.registry_pub.publish(String(data=registry.get("status_json", default_registry)))
def _next_topic_fixture(self, name):
with self.lock:
fixture = fixture_at(self.scenarios, name, self.topic_counts[name])
self.topic_counts[name] += 1
return fixture
def _visual_observation(self, fixture):
message = VisualObservation()
now = self.get_clock().now().nanoseconds
message.header.stamp = ros_time(now)
message.header.frame_id = "camera_link"
message.observation_id = fixture.get("observation_id", "sim_observation")
message.image_path = fixture.get("image_path", "/tmp/bt_mock_scene.png")
message.station_id = fixture.get("station_id", "station_A")
message.registry_version = int(fixture.get("registry_version", 1))
message.shelf_id = fixture.get("shelf_id", "shelf_A")
message.calibration_id = fixture.get("calibration_id", "sim_calibration_v1")
message.geometry_epoch = int(fixture.get("geometry_epoch", self.geometry_epoch))
if fixture.get("kind") == "stale_observation":
message.header.stamp = ros_time(max(0, now - 60000000000))
return message
def _dense_progress(self, fixture):
message = DenseProgress()
message.observed_at = self.get_clock().now().to_msg()
message.sequence = self.topic_counts["dense_progress"]
message.state = fixture.get("state", "UNKNOWN")
message.progress = float(fixture.get("progress", 0.0))
message.progress_valid = "progress" in fixture and fixture.get("kind") != "unknown"
message.hop_json = fixture.get("hop_json", "{}")
message.record_ref = "sim://dense_progress/" + str(message.sequence)
return message
def _get_state(self, request, response):
response.available = request.robot_id == self.robot_id
if response.available:
response.robot_state, response.safety_state = self._robot_state(), self._safety_state()
else:
response.error_code = "UNKNOWN_ROBOT"
return response
def _reconcile(self, request, response):
"""Simulation-only release requires fresh evidence bound to this exact goal and trace."""
try:
validate_trace(request.trace)
evidence = request.evidence
same_trace = all(getattr(evidence.context.trace, field) == getattr(request.trace, field)
for field in ("task_id", "subtask_id", "attempt", "task_revision",
"plan_version", "run_id", "execution_generation"))
now = self.get_clock().now().nanoseconds
observed_ns = evidence.context.observed_at.sec * 1000000000 + evidence.context.observed_at.nanosec
valid_until_ns = evidence.context.valid_until.sec * 1000000000 + evidence.context.valid_until.nanosec
fresh = 0 < observed_ns <= now < valid_until_ns
with self.lock:
owned = self.unresolved_motion.get(request.goal_id)
owned_trace_matches = owned is not None and all(
getattr(owned[1], field) == getattr(request.trace, field)
for field in ("task_id", "subtask_id", "attempt", "task_revision",
"plan_version", "run_id", "execution_generation"))
owner_matches = self.motion_owner is not None and self.motion_owner[0] == request.goal_id
bound = (owner_matches and owned_trace_matches and evidence.context.source_goal_id == request.goal_id and
same_trace and evidence.status == evidence.PASSED and
evidence.stopped_valid and evidence.stopped and evidence.evidence_ref and
evidence.context.writer and evidence.source == "SIMULATOR_INDEPENDENT_FIXTURE" and fresh and
request.operator_id and request.reason)
if not bound:
raise ValueError("reconciliation evidence is not bound and stopped")
with self.lock:
if request.goal_id not in self.unresolved_motion:
raise ValueError("goal reservation is no longer unresolved")
self.unresolved_motion.pop(request.goal_id)
self.motion_reserved = False
self.motion_owner = None
response.accepted = True
response.message = "SIMULATED evidence-bound reconciliation"
except (ValueError, TypeError):
response.accepted = False
response.error_code = "UNBOUND_EVIDENCE"
response.message = "Reconciliation requires exact goal/trace and stopped evidence"
return response
def destroy_node(self):
for server in self.servers:
server.destroy()
super().destroy_node()
def main(args=None):
rclpy.init(args=args)
node = None
executor = MultiThreadedExecutor(num_threads=8)
try:
node = MockSkills()
executor.add_node(node)
executor.spin()
except KeyboardInterrupt:
pass
finally:
if rclpy.ok():
rclpy.shutdown()
executor.shutdown(timeout_sec=2.0)
if node is not None:
node.destroy_node()
@@ -0,0 +1,160 @@
"""Pure-Python, bounded fixture parsing; no ROS or hardware dependency."""
import json
import math
ACTION_NAMES = (
"navigate", "execute_manipulation", "plan_task", "locate_shelf_column",
"localize_target_3d", "check_free_space", "assess_grasp", "execute_posture",
"verify_state",
"navigate_semantic", "evaluate_progress", "execute_task",
"robot_state", "safety_state", "visual_observation", "dense_progress", "goal_registry",
)
KINDS = {
"normal", "failed", "timeout", "silence", "stop_unknown", "reject",
"stale_observation", "wrong_object", "wrong_destination", "unknown",
"passed", "not_found", "ambiguous", "no_free_space", "adjust_posture",
"not_reachable", "native_mismatch",
"unavailable", "invalid_pose", "emergency_stop", "protective_stop",
"model_estimate", "fused",
"cancel_stop_unknown", "timeout_stop_unknown",
"obstacle_recovery",
}
FIXTURE_FIELDS = {
"kind", "duration_seconds", "shelf_id", "side_id", "column_id", "tier_id",
"posture_id", "plan", "plan_version", "target_ref", "destination_ref",
"completed_quantity", "progress", "state", "hop_json", "evidence_json",
"observation_id", "image_path", "station_id", "registry_version",
"calibration_id", "geometry_epoch", "status_json",
"stop_delay_seconds",
"final_pose",
}
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_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"},
"execute_task": BASE_KINDS | {"stop_unknown", "cancel_stop_unknown", "timeout_stop_unknown"},
"plan_task": BASE_KINDS,
"locate_shelf_column": BASE_KINDS | {"not_found", "ambiguous", "stale_observation"},
"localize_target_3d": BASE_KINDS | {"not_found", "ambiguous", "wrong_object", "stale_observation", "model_estimate", "fused"},
"check_free_space": BASE_KINDS | {"no_free_space", "ambiguous", "wrong_destination", "stale_observation"},
"assess_grasp": BASE_KINDS | {"adjust_posture", "not_reachable", "unknown"},
"verify_state": BASE_KINDS | {"passed", "unknown", "wrong_object", "wrong_destination", "stale_observation"},
"evaluate_progress": BASE_KINDS | {"unknown", "stale_observation"},
"robot_state": {"normal", "stale_observation", "invalid_pose", "stop_unknown"},
"safety_state": {"normal", "unavailable", "emergency_stop", "protective_stop"},
"visual_observation": {"normal", "stale_observation"},
"dense_progress": {"normal", "unknown", "stale_observation"},
"goal_registry": {"normal"},
}
def strict_json(raw):
if not isinstance(raw, str) or len(raw.encode("utf-8")) > 262144:
raise ValueError("JSON fixture must be at most 256 KiB")
def pairs(items):
result = {}
for key, value in items:
if key in result:
raise ValueError("duplicate JSON key")
result[key] = value
return result
return json.loads(raw, object_pairs_hook=pairs, parse_constant=lambda _: invalid())
def invalid():
raise ValueError("nonfinite JSON number")
def parse_scenarios(raw):
data = strict_json(raw)
if not isinstance(data, dict) or set(data) - set(ACTION_NAMES):
raise ValueError("scenario keys must name a supported mock action")
result = {}
for name, value in data.items():
entries = value if isinstance(value, list) else [value]
if not entries or len(entries) > 100:
raise ValueError("each action requires 1..100 fixtures")
for fixture in entries:
if not isinstance(fixture, dict) or set(fixture) - FIXTURE_FIELDS:
raise ValueError("invalid fixture fields")
if fixture.get("kind", "normal") not in KINDS:
raise ValueError("unsupported fixture kind")
if fixture.get("kind", "normal") not in ACTION_KINDS[name]:
raise ValueError("fixture kind has no effect for " + name)
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]")
stop_delay = fixture.get("stop_delay_seconds", 0.0)
if type(stop_delay) not in (int, float) or not math.isfinite(stop_delay) or not 0 <= stop_delay <= 5:
raise ValueError("stop delay must be finite in [0,5]")
if "progress" in fixture:
progress = fixture["progress"]
if type(progress) not in (int, float) or not math.isfinite(progress) or not 0 <= progress <= 1:
raise ValueError("fixture progress must be finite in [0,1]")
if "completed_quantity" in fixture:
quantity = fixture["completed_quantity"]
if type(quantity) is not int or not 0 <= quantity <= 1000000:
raise ValueError("completed quantity must be an integer in [0,1000000]")
for field in ("hop_json", "evidence_json", "status_json"):
if field in fixture and not isinstance(strict_json(fixture[field]), dict):
raise ValueError(field + " must encode a JSON object")
for field in ("image_path", "observation_id", "station_id", "calibration_id", "state"):
if field in fixture and (not isinstance(fixture[field], str) or not fixture[field]):
raise ValueError(field + " must be a nonempty string")
if "final_pose" in fixture:
pose = fixture["final_pose"]
required = {"frame_id", "x", "y", "z", "qx", "qy", "qz", "qw"}
if not isinstance(pose, dict) or set(pose) != required or not isinstance(pose["frame_id"], str) or not pose["frame_id"]:
raise ValueError("final_pose requires an exact frame and pose")
values = [pose[key] for key in ("x", "y", "z", "qx", "qy", "qz", "qw")]
if any(type(value) not in (int, float) or not math.isfinite(value) for value in values):
raise ValueError("final_pose values must be finite")
if abs(sum(pose[key] ** 2 for key in ("qx", "qy", "qz", "qw")) - 1.0) > 0.001:
raise ValueError("final_pose quaternion must have unit norm")
result[name] = entries
return result
def fixture_at(scenarios, name, index):
# Exhausted arrays hold their last scenario, making repeated calls deterministic.
fixtures = scenarios.get(name, [{"kind": "unknown" if name == "verify_state" else "normal"}])
return dict(fixtures[min(index, len(fixtures) - 1)])
def duration_seconds(value):
if value.sec < 0 or not 0 <= value.nanosec < 1000000000:
raise ValueError("invalid ROS Duration")
seconds = value.sec + value.nanosec / 1e9
if seconds <= 0:
raise ValueError("timeout must be positive")
return seconds
def validate_trace(trace):
if not all((trace.task_id, trace.subtask_id, trace.run_id)):
raise ValueError("trace identifiers are required")
if not all((trace.attempt, trace.task_revision, trace.plan_version, trace.execution_generation)):
raise ValueError("trace counters begin at one")
def fixed_plan(instruction, slots, version=1):
required = ("target_name", "source_location", "destination")
missing = [name for name in required if not slots.get(name)]
plan = {"schema_version": 1, "plan_version": version, "task_type": "pick_transport_place",
"goal": instruction, "slots": dict(slots), "missing_information": missing, "subtasks": []}
if missing:
return plan
plan["slots"].setdefault("quantity", 1)
target, source, dest = (slots[name] for name in required)
steps = [("NAVIGATE", {"destination": source}), ("GROUND_TARGET", {"target": target}),
("PICK", {"target": target}), ("NAVIGATE", {"destination": dest}),
("CHECK_FREE_SPACE", {"destination": dest}), ("PLACE", {"target": target, "destination": dest})]
for index, (skill, arguments) in enumerate(steps):
plan["subtasks"].append({"id": "s" + str(index + 1), "skill": skill,
"arguments": arguments, "depends_on": [] if index == 0 else ["s" + str(index)]})
return plan
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.2.0"?>
<package format="3">
<name>bt_mock_servers</name>
<version>1.2.0</version>
<description>Explicit simulator-only bounded robot skill action servers.</description>
<maintainer email="feiyuwang1998@gmail.com">wangfeiyu</maintainer>
<license>Proprietary</license>
<buildtool_depend>ament_python</buildtool_depend>
<exec_depend>rclpy</exec_depend>
<exec_depend>bt_skill_interfaces</exec_depend>
<exec_depend>builtin_interfaces</exec_depend>
<exec_depend>std_msgs</exec_depend>
<export><build_type>ament_python</build_type></export>
</package>
+4
View File
@@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/bt_mock_servers
[install]
install_scripts=$base/lib/bt_mock_servers
+18
View File
@@ -0,0 +1,18 @@
from setuptools import find_packages, setup
setup(
name="bt_mock_servers",
version="1.2.0",
packages=find_packages(exclude=["test"]),
data_files=[
("share/ament_index/resource_index/packages", ["resource/bt_mock_servers"]),
("share/bt_mock_servers", ["package.xml"]),
],
install_requires=["setuptools"],
zip_safe=True,
maintainer="wangfeiyu",
maintainer_email="feiyuwang1998@gmail.com",
description="Simulator-only bounded ROS2 robot skill servers",
license="Apache-2.0",
entry_points={"console_scripts": ["mock_skills = bt_mock_servers.mock_skills:main"]},
)