Files
yiliao2026/src/planner/scripts/goal_reach_debug_collector.py

718 lines
28 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Collect data needed to diagnose why the robot does not reach a goal.
This node is read-only. It subscribes to planner/tracker topics, queries key
parameters, samples odom/command state, and writes one JSON report.
"""
import json
import math
from pathlib import Path
import re
import time
import rclpy
from geometry_msgs.msg import PoseStamped, Twist
from nav_msgs.msg import Odometry, Path as NavPath
from rcl_interfaces.msg import ParameterType
from rcl_interfaces.srv import GetParameters
from rclpy.duration import Duration
from rclpy.node import Node
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy
from rclpy.time import Time
from std_msgs.msg import String
from tf2_ros import Buffer, TransformException, TransformListener
try:
from ackermann_msgs.msg import AckermannDriveStamped
except ImportError: # pragma: no cover - ackermann_msgs is a runtime dependency.
AckermannDriveStamped = None
PLAN_OK_RE = re.compile(
r"plan_ok.*points=(?P<points>\d+).*iterations=(?P<iterations>\d+)"
r".*planning_ms=(?P<planning_ms>[-+]?\d+(?:\.\d+)?)"
r".*goal_error=(?P<goal_error>[-+]?\d+(?:\.\d+)?)"
r".*yaw_error=(?P<yaw_error>[-+]?\d+(?:\.\d+)?)"
)
def yaw_from_quaternion(q):
return math.atan2(
2.0 * (q.w * q.z + q.x * q.y),
1.0 - 2.0 * (q.y * q.y + q.z * q.z),
)
def normalize_angle(angle):
return math.atan2(math.sin(angle), math.cos(angle))
def finite_or_none(value):
if value is None:
return None
if isinstance(value, float) and not math.isfinite(value):
return None
return value
def endpoint_to_dict(endpoint):
return {
"node_name": getattr(endpoint, "node_name", ""),
"node_namespace": getattr(endpoint, "node_namespace", ""),
"topic_type": getattr(endpoint, "topic_type", ""),
}
def parameter_value_to_python_compatible(value):
"""Convert rcl_interfaces/msg/ParameterValue on ROS 2 Humble and later."""
if value.type == ParameterType.PARAMETER_BOOL:
return bool(value.bool_value)
if value.type == ParameterType.PARAMETER_INTEGER:
return int(value.integer_value)
if value.type == ParameterType.PARAMETER_DOUBLE:
return float(value.double_value)
if value.type == ParameterType.PARAMETER_STRING:
return str(value.string_value)
if value.type == ParameterType.PARAMETER_BYTE_ARRAY:
return list(value.byte_array_value)
if value.type == ParameterType.PARAMETER_BOOL_ARRAY:
return list(value.bool_array_value)
if value.type == ParameterType.PARAMETER_INTEGER_ARRAY:
return list(value.integer_array_value)
if value.type == ParameterType.PARAMETER_DOUBLE_ARRAY:
return list(value.double_array_value)
if value.type == ParameterType.PARAMETER_STRING_ARRAY:
return list(value.string_array_value)
return None
class GoalReachDebugCollector(Node):
def __init__(self):
super().__init__("goal_reach_debug_collector")
self.declare_parameter("duration", 30.0)
self.declare_parameter("sample_rate", 10.0)
self.declare_parameter("output_dir", "~/yiliao_ws/test_results")
self.declare_parameter("test_name", "goal_reach_debug")
self.declare_parameter("map_frame", "map")
self.declare_parameter("odom_topic", "/odom")
self.declare_parameter("goal_pose_topic", "/goal_pose")
self.declare_parameter("plan_topic", "/plan")
self.declare_parameter("cmd_vel_topic", "/planner_cmd_vel")
self.declare_parameter("ackermann_cmd_topic", "/ackermann_cmd")
self.declare_parameter("planner_status_topic", "/hybrid_astar_status")
self.declare_parameter("tracker_status_topic", "/pure_pursuit_status")
self.declare_parameter("planner_node", "/grid_astar_theta_planner")
self.declare_parameter("tracker_node", "/topology_pure_pursuit")
self.declare_parameter("tf_timeout", 0.10)
self.declare_parameter("max_odom_age", 2.0)
self.declare_parameter("include_plan_points", True)
self.duration = max(1.0, float(self.get_parameter("duration").value))
self.sample_rate = max(1.0, float(self.get_parameter("sample_rate").value))
self.map_frame = str(self.get_parameter("map_frame").value)
self.odom_topic = str(self.get_parameter("odom_topic").value)
self.goal_pose_topic = str(self.get_parameter("goal_pose_topic").value)
self.plan_topic = str(self.get_parameter("plan_topic").value)
self.cmd_vel_topic = str(self.get_parameter("cmd_vel_topic").value)
self.ackermann_cmd_topic = str(self.get_parameter("ackermann_cmd_topic").value)
self.planner_status_topic = str(
self.get_parameter("planner_status_topic").value
)
self.tracker_status_topic = str(
self.get_parameter("tracker_status_topic").value
)
self.planner_node = str(self.get_parameter("planner_node").value)
self.tracker_node = str(self.get_parameter("tracker_node").value)
self.tf_timeout = float(self.get_parameter("tf_timeout").value)
self.max_odom_age = float(self.get_parameter("max_odom_age").value)
self.include_plan_points = bool(
self.get_parameter("include_plan_points").value
)
output_dir = Path(str(self.get_parameter("output_dir").value)).expanduser()
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = time.strftime("%Y%m%d_%H%M%S")
test_name = str(self.get_parameter("test_name").value)
self.output_path = output_dir / f"{test_name}_{timestamp}.json"
self.started_wall = time.time()
self.started_mono = time.monotonic()
self.finished = False
self.latest_odom = None
self.latest_goal = None
self.latest_plan_points = []
self.latest_cmd = None
self.latest_ackermann = None
self.latest_planner_status = ""
self.latest_tracker_status = ""
self.last_plan_ok = None
self.remote_parameters = {}
self.samples = []
self.planner_status_events = []
self.tracker_status_events = []
self.plan_events = []
self.goal_events = []
self.min_distance_to_goal = None
self.min_distance_to_plan_end = None
self.max_abs_cmd_linear = 0.0
self.max_abs_cmd_angular = 0.0
self.max_abs_ackermann_speed = 0.0
self.max_abs_steering_angle = 0.0
self.tf_failures = 0
self.tf_buffer = Buffer()
self.tf_listener = TransformListener(self.tf_buffer, self)
goal_qos = QoSProfile(
depth=10,
reliability=ReliabilityPolicy.RELIABLE,
durability=DurabilityPolicy.TRANSIENT_LOCAL,
)
self.subscriptions_keepalive = [
self.create_subscription(Odometry, self.odom_topic, self._odom_cb, 50),
self.create_subscription(
PoseStamped, self.goal_pose_topic, self._goal_cb, goal_qos
),
self.create_subscription(NavPath, self.plan_topic, self._plan_cb, 10),
self.create_subscription(Twist, self.cmd_vel_topic, self._cmd_cb, 50),
self.create_subscription(
String, self.planner_status_topic, self._planner_status_cb, 50
),
self.create_subscription(
String, self.tracker_status_topic, self._tracker_status_cb, 50
),
]
if AckermannDriveStamped is not None:
self.subscriptions_keepalive.append(
self.create_subscription(
AckermannDriveStamped,
self.ackermann_cmd_topic,
self._ackermann_cb,
50,
)
)
self.sample_timer = self.create_timer(1.0 / self.sample_rate, self._sample)
self.done_timer = self.create_timer(0.20, self._maybe_finish)
self.get_logger().info(
f"collecting goal reach debug data for {self.duration:.1f}s -> {self.output_path}"
)
def elapsed(self):
return max(0.0, time.monotonic() - self.started_mono)
def _odom_cb(self, msg):
self.latest_odom = msg
def _goal_cb(self, msg):
goal = {
"elapsed_sec": self.elapsed(),
"frame_id": msg.header.frame_id or self.map_frame,
"x": msg.pose.position.x,
"y": msg.pose.position.y,
"yaw": yaw_from_quaternion(msg.pose.orientation),
}
self.latest_goal = goal
self.goal_events.append(goal)
def _plan_cb(self, msg):
points = [
{
"x": pose.pose.position.x,
"y": pose.pose.position.y,
"yaw": yaw_from_quaternion(pose.pose.orientation),
}
for pose in msg.poses
]
self.latest_plan_points = points
summary = self._plan_summary(points, msg.header.frame_id or self.map_frame)
summary["elapsed_sec"] = self.elapsed()
self.plan_events.append(summary)
def _cmd_cb(self, msg):
self.latest_cmd = {
"linear_x": msg.linear.x,
"angular_z": msg.angular.z,
}
self.max_abs_cmd_linear = max(self.max_abs_cmd_linear, abs(msg.linear.x))
self.max_abs_cmd_angular = max(self.max_abs_cmd_angular, abs(msg.angular.z))
def _ackermann_cb(self, msg):
self.latest_ackermann = {
"speed": msg.drive.speed,
"steering_angle": msg.drive.steering_angle,
}
self.max_abs_ackermann_speed = max(
self.max_abs_ackermann_speed, abs(msg.drive.speed)
)
self.max_abs_steering_angle = max(
self.max_abs_steering_angle, abs(msg.drive.steering_angle)
)
def _planner_status_cb(self, msg):
self.latest_planner_status = msg.data
event = {"elapsed_sec": self.elapsed(), "data": msg.data}
parsed = self._parse_plan_ok(msg.data)
if parsed is not None:
event["parsed_plan_ok"] = parsed
self.last_plan_ok = parsed
self.planner_status_events.append(event)
def _tracker_status_cb(self, msg):
self.latest_tracker_status = msg.data
self.tracker_status_events.append(
{"elapsed_sec": self.elapsed(), "data": msg.data}
)
def _parse_plan_ok(self, text):
match = PLAN_OK_RE.search(text)
if not match:
return None
parsed = {}
for key, value in match.groupdict().items():
parsed[key] = int(value) if key in {"points", "iterations"} else float(value)
return parsed
def _sample(self):
pose = self._current_pose_map()
pose_available = self._pose_available(pose)
goal = self.latest_goal
plan_end = self.latest_plan_points[-1] if self.latest_plan_points else None
distance_to_goal = None
yaw_error_to_goal = None
if pose_available and goal is not None:
distance_to_goal = math.hypot(goal["x"] - pose["x"], goal["y"] - pose["y"])
yaw_error_to_goal = abs(normalize_angle(goal["yaw"] - pose["yaw"]))
self.min_distance_to_goal = self._min_or_value(
self.min_distance_to_goal, distance_to_goal
)
distance_to_plan_end = None
yaw_error_to_plan_end = None
if pose_available and plan_end is not None:
distance_to_plan_end = math.hypot(
plan_end["x"] - pose["x"], plan_end["y"] - pose["y"]
)
yaw_error_to_plan_end = abs(normalize_angle(plan_end["yaw"] - pose["yaw"]))
self.min_distance_to_plan_end = self._min_or_value(
self.min_distance_to_plan_end, distance_to_plan_end
)
plan_end_to_goal = None
plan_end_yaw_to_goal = None
if plan_end is not None and goal is not None:
plan_end_to_goal = math.hypot(plan_end["x"] - goal["x"], plan_end["y"] - goal["y"])
plan_end_yaw_to_goal = abs(normalize_angle(plan_end["yaw"] - goal["yaw"]))
self.samples.append({
"elapsed_sec": self.elapsed(),
"pose_map": pose,
"goal": goal,
"plan_end": plan_end,
"distance_to_goal": finite_or_none(distance_to_goal),
"yaw_error_to_goal": finite_or_none(yaw_error_to_goal),
"distance_to_plan_end": finite_or_none(distance_to_plan_end),
"yaw_error_to_plan_end": finite_or_none(yaw_error_to_plan_end),
"plan_end_to_goal": finite_or_none(plan_end_to_goal),
"plan_end_yaw_to_goal": finite_or_none(plan_end_yaw_to_goal),
"cmd_vel": self.latest_cmd,
"ackermann_cmd": self.latest_ackermann,
"planner_status": self.latest_planner_status,
"tracker_status": self.latest_tracker_status,
})
def _min_or_value(self, old, new):
if new is None:
return old
if old is None:
return new
return min(old, new)
def _pose_available(self, pose):
return (
isinstance(pose, dict)
and pose.get("available") is True
and "x" in pose
and "y" in pose
and "yaw" in pose
)
def _current_pose_map(self):
if self.latest_odom is None:
return None
msg = self.latest_odom
stamp = Time.from_msg(msg.header.stamp)
if msg.header.stamp.sec == 0 and msg.header.stamp.nanosec == 0:
age = 0.0
else:
age = (self.get_clock().now() - stamp).nanoseconds * 1e-9
if age < 0.0:
age = 0.0
if age > self.max_odom_age:
return {
"available": False,
"reason": "odom_too_old",
"age_sec": age,
}
source_frame = msg.header.frame_id or self.map_frame
x = msg.pose.pose.position.x
y = msg.pose.pose.position.y
yaw = yaw_from_quaternion(msg.pose.pose.orientation)
transformed = self._transform_pose_2d(x, y, yaw, source_frame)
if transformed is None:
return {
"available": False,
"reason": "tf_unavailable",
"source_frame": source_frame,
}
x, y, yaw = transformed
return {
"available": True,
"frame_id": self.map_frame,
"source_frame": source_frame,
"x": x,
"y": y,
"yaw": yaw,
"odom_age_sec": age,
"odom_linear_x": msg.twist.twist.linear.x,
"odom_angular_z": msg.twist.twist.angular.z,
}
def _transform_pose_2d(self, x, y, yaw, source_frame):
if source_frame == self.map_frame:
return x, y, yaw
try:
transform = self.tf_buffer.lookup_transform(
self.map_frame,
source_frame,
Time(),
timeout=Duration(seconds=self.tf_timeout),
)
except TransformException:
self.tf_failures += 1
return None
t = transform.transform.translation
transform_yaw = yaw_from_quaternion(transform.transform.rotation)
cos_yaw = math.cos(transform_yaw)
sin_yaw = math.sin(transform_yaw)
target_x = t.x + cos_yaw * x - sin_yaw * y
target_y = t.y + sin_yaw * x + cos_yaw * y
target_yaw = normalize_angle(transform_yaw + yaw)
return target_x, target_y, target_yaw
def _plan_summary(self, points, frame_id):
if not points:
return {
"frame_id": frame_id,
"points": 0,
"length": 0.0,
"start": None,
"end": None,
}
length = 0.0
reverse_segments = 0
for a, b in zip(points, points[1:]):
length += math.hypot(b["x"] - a["x"], b["y"] - a["y"])
heading = math.atan2(b["y"] - a["y"], b["x"] - a["x"])
if math.cos(normalize_angle(a["yaw"] - heading)) < 0.0:
reverse_segments += 1
return {
"frame_id": frame_id,
"points": len(points),
"length": length,
"start": points[0],
"end": points[-1],
"reverse_segment_fraction": (
reverse_segments / max(1, len(points) - 1)
),
}
def _maybe_finish(self):
if self.elapsed() >= self.duration:
self.finished = True
def collect_remote_parameters(self):
parameter_targets = {
"planner": (
self.planner_node,
[
"goal_xy_tolerance",
"goal_yaw_tolerance",
"require_goal_yaw",
"resolution",
"yaw_bins",
"primitive_length",
"min_turning_radius",
"heuristic_weight",
"goal_yaw_heuristic_weight",
"planning_timeout_ms",
"max_iterations",
],
),
"tracker": (
self.tracker_node,
[
"goal_tolerance",
"goal_yaw_tolerance",
"align_goal_yaw",
"publish_cmd_vel",
"cmd_vel_topic",
"lookahead_distance",
"linear_speed",
"min_linear_speed",
"reverse_speed",
"slowdown_distance",
"curvature_slowdown_gain",
"min_turning_radius",
"max_angular_speed",
],
),
}
for label, (node_name, names) in parameter_targets.items():
normalized_node_name = node_name if node_name.startswith("/") else f"/{node_name}"
service_name = f"{normalized_node_name}/get_parameters"
client = self.create_client(GetParameters, service_name)
if not client.wait_for_service(timeout_sec=1.0):
self.remote_parameters[label] = {
"node": node_name,
"service": service_name,
"error": "parameter_service_unavailable",
}
continue
request = GetParameters.Request()
request.names = list(names)
future = client.call_async(request)
rclpy.spin_until_future_complete(self, future, timeout_sec=2.0)
if not future.done() or future.result() is None:
self.remote_parameters[label] = {
"node": node_name,
"service": service_name,
"error": "parameter_query_timeout",
}
continue
response = future.result()
self.remote_parameters[label] = {
"node": node_name,
"service": service_name,
"values": {
name: parameter_value_to_python_compatible(value)
for name, value in zip(names, response.values)
},
}
def write_report(self):
report = {
"metadata": {
"started_wall_time": self.started_wall,
"duration_sec": self.elapsed(),
"output_path": str(self.output_path),
},
"topics": {
"odom": self.odom_topic,
"goal_pose": self.goal_pose_topic,
"plan": self.plan_topic,
"cmd_vel": self.cmd_vel_topic,
"ackermann_cmd": self.ackermann_cmd_topic,
"planner_status": self.planner_status_topic,
"tracker_status": self.tracker_status_topic,
},
"topic_endpoints": self._topic_endpoints_report(),
"remote_parameters": self.remote_parameters,
"latest": {
"goal": self.latest_goal,
"plan": self._plan_summary(self.latest_plan_points, self.map_frame),
"pose_map": self._current_pose_map(),
"cmd_vel": self.latest_cmd,
"ackermann_cmd": self.latest_ackermann,
"planner_status": self.latest_planner_status,
"tracker_status": self.latest_tracker_status,
"last_plan_ok": self.last_plan_ok,
},
"metrics": self._metrics(),
"diagnostic_hints": self._diagnostic_hints(),
"events": {
"goals": self.goal_events,
"plans": self.plan_events,
"planner_status": self.planner_status_events,
"tracker_status": self.tracker_status_events,
},
"samples": self.samples,
}
if self.include_plan_points:
report["latest"]["plan_points"] = self.latest_plan_points
self.output_path.write_text(
json.dumps(report, indent=2, ensure_ascii=False),
encoding="utf-8",
)
self.get_logger().info(f"wrote goal reach debug report: {self.output_path}")
for hint in report["diagnostic_hints"]:
self.get_logger().warn(hint)
def _topic_endpoints_report(self):
topics = [
self.odom_topic,
self.goal_pose_topic,
self.plan_topic,
self.cmd_vel_topic,
self.ackermann_cmd_topic,
self.planner_status_topic,
self.tracker_status_topic,
]
report = {}
for topic in topics:
publishers = self.get_publishers_info_by_topic(topic)
subscribers = self.get_subscriptions_info_by_topic(topic)
report[topic] = {
"publisher_count": len(publishers),
"subscriber_count": len(subscribers),
"publishers": [endpoint_to_dict(endpoint) for endpoint in publishers],
"subscribers": [endpoint_to_dict(endpoint) for endpoint in subscribers],
}
return report
def _metrics(self):
final = self.samples[-1] if self.samples else {}
planner_texts = [event["data"] for event in self.planner_status_events]
tracker_texts = [event["data"] for event in self.tracker_status_events]
return {
"sample_count": len(self.samples),
"goal_count": len(self.goal_events),
"plan_count": len(self.plan_events),
"planner_status_count": len(self.planner_status_events),
"tracker_status_count": len(self.tracker_status_events),
"min_distance_to_goal": finite_or_none(self.min_distance_to_goal),
"min_distance_to_plan_end": finite_or_none(self.min_distance_to_plan_end),
"final_distance_to_goal": final.get("distance_to_goal"),
"final_distance_to_plan_end": final.get("distance_to_plan_end"),
"final_plan_end_to_goal": final.get("plan_end_to_goal"),
"final_plan_end_yaw_to_goal": final.get("plan_end_yaw_to_goal"),
"max_abs_cmd_linear": self.max_abs_cmd_linear,
"max_abs_cmd_angular": self.max_abs_cmd_angular,
"max_abs_ackermann_speed": self.max_abs_ackermann_speed,
"max_abs_steering_angle": self.max_abs_steering_angle,
"tf_failures": self.tf_failures,
"saw_plan_ok": any("plan_ok" in text for text in planner_texts),
"saw_plan_failed": any("plan_failed" in text for text in planner_texts),
"saw_goal_reached": any("goal_reached" in text for text in tracker_texts),
"saw_align_goal_yaw": any("aligning_goal_yaw" in text for text in tracker_texts),
"saw_no_recent_odom": any("no_recent_odom" in text for text in tracker_texts + planner_texts),
}
def _diagnostic_hints(self):
hints = []
params = self.remote_parameters
planner = params.get("planner", {}).get("values", {})
tracker = params.get("tracker", {}).get("values", {})
metrics = self._metrics()
if not self.latest_goal:
hints.append("没有采集到 /goal_pose请先运行采集脚本再发送目标。")
if not self.latest_plan_points:
hints.append("没有采集到 /planplanner 可能未规划成功或采集脚本启动太晚。")
if self.latest_odom is None:
hints.append(f"没有采集到 {self.odom_topic}tracker 无法闭环到点。")
if metrics["tf_failures"] > 0:
hints.append(
f"TF 查询失败 {metrics['tf_failures']} 次;需要检查 map->odom 和 odom frame。"
)
planner_xy = planner.get("goal_xy_tolerance")
tracker_xy = tracker.get("goal_tolerance")
if isinstance(planner_xy, (int, float)) and isinstance(tracker_xy, (int, float)):
if planner_xy > tracker_xy:
hints.append(
"planner 的 goal_xy_tolerance 大于 tracker 的 goal_tolerance"
"planner 可能发布一个离原始目标较远的路径终点。"
)
planner_yaw = planner.get("goal_yaw_tolerance")
tracker_yaw = tracker.get("goal_yaw_tolerance")
planner_requires_yaw = bool(planner.get("require_goal_yaw"))
tracker_aligns_yaw = bool(tracker.get("align_goal_yaw"))
if (
planner_requires_yaw
and tracker_aligns_yaw
and isinstance(planner_yaw, (int, float))
and isinstance(tracker_yaw, (int, float))
):
if planner_yaw > tracker_yaw:
hints.append(
"planner 的 goal_yaw_tolerance 大于 tracker 的 goal_yaw_tolerance"
"planner 认为合格的终点 yawtracker 可能继续调整。"
)
min_speed = tracker.get("min_linear_speed")
if isinstance(min_speed, (int, float)) and isinstance(tracker_xy, (int, float)):
if min_speed > tracker_xy:
hints.append(
"tracker 的 min_linear_speed 数值大于 goal_tolerance"
"接近终点时可能仍以较高速度越过目标。"
)
plan_goal_error = metrics.get("final_plan_end_to_goal")
if isinstance(plan_goal_error, (int, float)) and isinstance(tracker_xy, (int, float)):
if plan_goal_error > tracker_xy:
hints.append(
"最新 /plan 的终点到 /goal_pose 的距离大于 tracker 到点半径;"
"tracker 跟随 /plan 时不会到达你原始输入的目标点。"
)
if metrics["saw_align_goal_yaw"] and not metrics["saw_goal_reached"]:
hints.append(
"采集期间出现 aligning_goal_yaw 但没有 goal_reached"
"终点 yaw 对齐可能把车辆带离目标圆。"
)
if (
not metrics["saw_goal_reached"]
and metrics["max_abs_cmd_linear"] <= 1e-6
and metrics["max_abs_cmd_angular"] <= 1e-6
):
hints.append(
"采集期间没有观察到速度命令;如果正在做实车到点测试,"
"请确认 launch 使用 enable_motion:=true且采集的是 tracker 实际输出话题。"
)
ack_info = self._topic_endpoints_report().get(self.ackermann_cmd_topic, {})
if ack_info.get("publisher_count", 0) == 0:
hints.append(
f"{self.ackermann_cmd_topic} 没有发布者;阿克曼桥可能未启动,"
"通常是 enable_motion:=false。"
)
if not hints:
hints.append("未发现明显配置矛盾;请把 JSON 报告发给我继续分析。")
return hints
def main(args=None):
rclpy.init(args=args)
node = GoalReachDebugCollector()
try:
node.collect_remote_parameters()
while rclpy.ok() and not node.finished:
rclpy.spin_once(node, timeout_sec=0.10)
node.write_report()
except KeyboardInterrupt:
node.get_logger().warn("interrupted; writing partial report")
node.write_report()
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
return 0
if __name__ == "__main__":
raise SystemExit(main())