factory_search_point_adapter.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. #!/usr/bin/env python3
  2. """Adapt a nominal factory-search pose into obstacle-aware move_base candidates."""
  3. from __future__ import annotations
  4. import math
  5. import queue
  6. import threading
  7. import time
  8. import actionlib
  9. import rospy
  10. import tf2_ros
  11. from actionlib_msgs.msg import GoalStatus
  12. from geometry_msgs.msg import Pose, PoseArray, PoseStamped, Quaternion
  13. from move_base_msgs.msg import MoveBaseAction, MoveBaseGoal
  14. from nav_msgs.msg import OccupancyGrid
  15. from nav_msgs.srv import GetPlan, GetPlanRequest
  16. from std_msgs.msg import String
  17. from std_srvs.srv import Trigger, TriggerResponse
  18. class _PlanTask:
  19. """One make_plan request handled by the sole background RPC worker."""
  20. def __init__(self, request):
  21. self.request = request
  22. self.response = None
  23. self.error = None
  24. self.finished = threading.Event()
  25. class FactorySearchPointAdapter:
  26. """Send only safe observation candidates; never publish cmd_vel."""
  27. _NAV_FAILURE_STATES = {
  28. GoalStatus.PREEMPTED, GoalStatus.ABORTED, GoalStatus.REJECTED,
  29. GoalStatus.RECALLED, GoalStatus.LOST,
  30. }
  31. _SEARCH_RETRY_PREFIXES = (
  32. "FACTORY_NOT_FOUND_AFTER_360_DEG_SCAN",
  33. "ALIGNMENT_TIMEOUT",
  34. "WALL_NORMAL_UNAVAILABLE",
  35. "WALL_RAY_NO_HIT",
  36. "WALL_GOAL_INVALID_STANDOFF",
  37. "WALL_GOAL_OUTSIDE_MAP",
  38. "WALL_GOAL_OCCUPIED",
  39. "WALL_GOAL_MOVE_BASE_UNAVAILABLE",
  40. "WALL_APPROACH_FAILED",
  41. "FACTORY_OCR_FAILED",
  42. "FACTORY_ENTRY_FAILED",
  43. )
  44. def __init__(self):
  45. rospy.init_node("factory_search_point_adapter")
  46. self._frame_id = rospy.get_param("~frame_id", "map")
  47. self._base_frame = rospy.get_param("~base_frame", "base_link")
  48. self._nominal_topic = rospy.get_param(
  49. "~nominal_goal_topic", "/factory_search_adapter/nominal_goal"
  50. )
  51. self._static_map_topic = rospy.get_param("~static_map_topic", "/map")
  52. self._local_costmap_topic = rospy.get_param(
  53. "~local_costmap_topic", "/move_base/local_costmap/costmap"
  54. )
  55. self._alignment_status_topic = rospy.get_param(
  56. "~alignment_status_topic", "/factory_alignment_demo/status"
  57. )
  58. self._plan_service_name = rospy.get_param("~make_plan_service", "/move_base/make_plan")
  59. self._ring_radii = [float(value) for value in rospy.get_param("~ring_radii", [0.4, 0.6])]
  60. self._ring_directions = int(rospy.get_param("~ring_directions", 8))
  61. self._footprint_length = float(rospy.get_param("~checked_footprint_length", 0.395))
  62. self._footprint_width = float(rospy.get_param("~checked_footprint_width", 0.316))
  63. self._static_occupied_threshold = int(rospy.get_param("~static_occupied_threshold", 65))
  64. self._local_cost_threshold = int(rospy.get_param("~local_cost_threshold", 80))
  65. self._local_costmap_timeout = float(rospy.get_param("~local_costmap_timeout_seconds", 2.0))
  66. self._goal_timeout = float(rospy.get_param("~candidate_goal_timeout_seconds", 45.0))
  67. self._search_timeout = float(rospy.get_param("~candidate_search_timeout_seconds", 80.0))
  68. self._server_timeout = float(rospy.get_param("~move_base_server_timeout_seconds", 2.0))
  69. self._plan_response_timeout = float(rospy.get_param("~make_plan_response_timeout_seconds", 0.4))
  70. self._timer_period = float(rospy.get_param("~timer_period_seconds", 0.2))
  71. self._validate_config()
  72. self._static_map = None
  73. self._local_costmap = None
  74. self._local_costmap_monotonic = 0.0
  75. self._nominal_pose = None
  76. self._candidates = []
  77. self._candidate_index = 0
  78. self._state = "IDLE"
  79. self._active_sequence = 0
  80. self._active_deadline = None
  81. self._search_deadline = None
  82. self._plan_timeout_this_round = False
  83. self._plan_fallback_announced = False
  84. self._last_status = None
  85. self._completion_locked = False
  86. self._client = actionlib.SimpleActionClient("move_base", MoveBaseAction)
  87. self._plan_client = rospy.ServiceProxy(self._plan_service_name, GetPlan)
  88. self._plan_request_queue = queue.Queue(maxsize=1)
  89. self._plan_worker_thread = threading.Thread(
  90. target=self._plan_worker, name="factory_make_plan", daemon=True
  91. )
  92. self._plan_worker_thread.start()
  93. self._tf_buffer = tf2_ros.Buffer()
  94. self._tf_listener = tf2_ros.TransformListener(self._tf_buffer)
  95. self._status_pub = rospy.Publisher(
  96. "/factory_search_adapter/status", String, queue_size=10, latch=True
  97. )
  98. self._candidate_pub = rospy.Publisher(
  99. "/factory_search_adapter/candidates", PoseArray, queue_size=1, latch=True
  100. )
  101. self._selected_goal_pub = rospy.Publisher(
  102. "/factory_search_adapter/selected_goal", PoseStamped, queue_size=1, latch=True
  103. )
  104. self._reset_service = rospy.Service("/factory_search_adapter/reset", Trigger, self._reset_callback)
  105. rospy.Subscriber(self._nominal_topic, PoseStamped, self._nominal_callback, queue_size=1)
  106. rospy.Subscriber(self._static_map_topic, OccupancyGrid, self._static_map_callback, queue_size=1)
  107. rospy.Subscriber(self._local_costmap_topic, OccupancyGrid, self._local_costmap_callback, queue_size=1)
  108. rospy.Subscriber(self._alignment_status_topic, String, self._alignment_status_callback, queue_size=10)
  109. rospy.Timer(rospy.Duration(self._timer_period), self._timer_callback)
  110. rospy.on_shutdown(self._cancel_own_goal)
  111. self._publish_status("WAITING_FOR_NOMINAL_GOAL")
  112. def _validate_config(self):
  113. if self._frame_id != "map":
  114. raise ValueError("factory search candidates must use the map frame")
  115. if self._ring_directions < 4:
  116. raise ValueError("ring_directions must be at least 4")
  117. if any(radius <= 0.0 for radius in self._ring_radii):
  118. raise ValueError("ring radii must be positive")
  119. if self._footprint_length <= 0.0 or self._footprint_width <= 0.0:
  120. raise ValueError("checked footprint dimensions must be positive")
  121. if not 0 <= self._static_occupied_threshold <= 100:
  122. raise ValueError("static_occupied_threshold must be in [0, 100]")
  123. if not 1 <= self._local_cost_threshold <= 100:
  124. raise ValueError("local_cost_threshold must be in [1, 100]")
  125. if min(self._local_costmap_timeout, self._goal_timeout, self._search_timeout,
  126. self._server_timeout, self._plan_response_timeout,
  127. self._timer_period) <= 0.0:
  128. raise ValueError("search adapter timeouts must be positive")
  129. @staticmethod
  130. def _yaw_from_quaternion(orientation):
  131. return math.atan2(
  132. 2.0 * (orientation.w * orientation.z + orientation.x * orientation.y),
  133. 1.0 - 2.0 * (orientation.y * orientation.y + orientation.z * orientation.z),
  134. )
  135. @staticmethod
  136. def _quaternion_from_yaw(yaw):
  137. return Quaternion(z=math.sin(yaw / 2.0), w=math.cos(yaw / 2.0))
  138. def _publish_status(self, status):
  139. if status == self._last_status:
  140. return
  141. self._last_status = status
  142. rospy.loginfo("factory search adapter: %s", status)
  143. self._status_pub.publish(String(data=status))
  144. def _static_map_callback(self, message):
  145. if message.header.frame_id.lstrip("/") != self._frame_id:
  146. rospy.logwarn_throttle(5.0, "factory search ignored static map frame %s", message.header.frame_id)
  147. return
  148. self._static_map = message
  149. def _local_costmap_callback(self, message):
  150. if message.header.frame_id.lstrip("/") != self._frame_id:
  151. rospy.logwarn_throttle(
  152. 5.0, "factory search ignored local costmap frame %s (expected %s)",
  153. message.header.frame_id, self._frame_id,
  154. )
  155. return
  156. self._local_costmap = message
  157. self._local_costmap_monotonic = time.monotonic()
  158. def _reset_callback(self, _request):
  159. self._cancel_own_goal()
  160. self._active_sequence += 1
  161. self._nominal_pose = None
  162. self._candidates = []
  163. self._candidate_index = 0
  164. self._active_deadline = None
  165. self._search_deadline = None
  166. self._plan_timeout_this_round = False
  167. self._plan_fallback_announced = False
  168. self._completion_locked = False
  169. self._state = "IDLE"
  170. self._publish_status("RESET_READY_FOR_NEXT_OBSERVATION")
  171. return TriggerResponse(success=True, message="factory search adapter reset")
  172. def _nominal_callback(self, message):
  173. if self._completion_locked:
  174. self._publish_status("NOMINAL_GOAL_IGNORED_COMPLETE_LOCKED")
  175. return
  176. if message.header.frame_id.lstrip("/") != self._frame_id:
  177. self._publish_status("NOMINAL_GOAL_WRONG_FRAME")
  178. return
  179. yaw = self._yaw_from_quaternion(message.pose.orientation)
  180. values = (message.pose.position.x, message.pose.position.y, yaw)
  181. if not all(math.isfinite(value) for value in values):
  182. self._publish_status("NOMINAL_GOAL_INVALID")
  183. return
  184. self._cancel_own_goal()
  185. self._active_sequence += 1
  186. self._nominal_pose = values
  187. self._candidates = []
  188. self._candidate_index = 0
  189. self._active_deadline = None
  190. self._search_deadline = None
  191. self._plan_timeout_this_round = False
  192. self._plan_fallback_announced = False
  193. self._state = "PREPARING"
  194. self._publish_status("PREPARING_CANDIDATES x=%.3f y=%.3f yaw=%.3f" % values)
  195. def _cancel_own_goal(self):
  196. if hasattr(self, "_client") and self._state == "NAVIGATING":
  197. self._client.cancel_goal()
  198. @staticmethod
  199. def _grid_origin_yaw(grid):
  200. return FactorySearchPointAdapter._yaw_from_quaternion(grid.info.origin.orientation)
  201. def _map_to_grid(self, grid, x, y):
  202. resolution = grid.info.resolution
  203. if resolution <= 0.0:
  204. return None
  205. origin = grid.info.origin.position
  206. yaw = self._grid_origin_yaw(grid)
  207. dx, dy = x - origin.x, y - origin.y
  208. local_x = math.cos(yaw) * dx + math.sin(yaw) * dy
  209. local_y = -math.sin(yaw) * dx + math.cos(yaw) * dy
  210. column, row = int(math.floor(local_x / resolution)), int(math.floor(local_y / resolution))
  211. if row < 0 or column < 0 or row >= grid.info.height or column >= grid.info.width:
  212. return None
  213. return row, column
  214. def _grid_to_map(self, grid, row, column):
  215. resolution = grid.info.resolution
  216. origin = grid.info.origin.position
  217. yaw = self._grid_origin_yaw(grid)
  218. local_x, local_y = (column + 0.5) * resolution, (row + 0.5) * resolution
  219. return (
  220. origin.x + math.cos(yaw) * local_x - math.sin(yaw) * local_y,
  221. origin.y + math.sin(yaw) * local_x + math.cos(yaw) * local_y,
  222. )
  223. @staticmethod
  224. def _cost_at(grid, row, column):
  225. return grid.data[row * grid.info.width + column]
  226. def _footprint_cost(self, grid, x, y, yaw, threshold):
  227. """Return max cell cost, or None if footprint reaches unknown/outside/lethal cells."""
  228. resolution = grid.info.resolution
  229. if resolution <= 0.0:
  230. return None
  231. radius = math.hypot(self._footprint_length / 2.0, self._footprint_width / 2.0)
  232. centre = self._map_to_grid(grid, x, y)
  233. if centre is None:
  234. return None
  235. radius_cells = int(math.ceil(radius / resolution)) + 1
  236. max_cost = 0
  237. found = False
  238. for row in range(centre[0] - radius_cells, centre[0] + radius_cells + 1):
  239. for column in range(centre[1] - radius_cells, centre[1] + radius_cells + 1):
  240. if row < 0 or column < 0 or row >= grid.info.height or column >= grid.info.width:
  241. return None
  242. cell_x, cell_y = self._grid_to_map(grid, row, column)
  243. dx, dy = cell_x - x, cell_y - y
  244. longitudinal = math.cos(yaw) * dx + math.sin(yaw) * dy
  245. lateral = -math.sin(yaw) * dx + math.cos(yaw) * dy
  246. if (abs(longitudinal) > self._footprint_length / 2.0
  247. or abs(lateral) > self._footprint_width / 2.0):
  248. continue
  249. found = True
  250. cost = self._cost_at(grid, row, column)
  251. if cost < 0 or cost >= threshold:
  252. return None
  253. max_cost = max(max_cost, cost)
  254. return max_cost if found else None
  255. def _footprint_is_inside_grid(self, grid, x, y, yaw):
  256. """Whether the complete checked rectangle is covered by this grid.
  257. The local costmap is a rolling window. A candidate farther than that
  258. window is not evidence of an obstacle; it simply cannot yet be checked
  259. against live cone observations. Static-map and global-plan checks
  260. still apply in that case, and TEB will receive the current local map
  261. while driving there.
  262. """
  263. half_length = self._footprint_length / 2.0
  264. half_width = self._footprint_width / 2.0
  265. for longitudinal in (-half_length, half_length):
  266. for lateral in (-half_width, half_width):
  267. corner_x = x + math.cos(yaw) * longitudinal - math.sin(yaw) * lateral
  268. corner_y = y + math.sin(yaw) * longitudinal + math.cos(yaw) * lateral
  269. if self._map_to_grid(grid, corner_x, corner_y) is None:
  270. return False
  271. return True
  272. def _fresh_local_costmap(self):
  273. return (self._local_costmap is not None
  274. and time.monotonic() - self._local_costmap_monotonic <= self._local_costmap_timeout)
  275. def _current_pose(self):
  276. try:
  277. transform = self._tf_buffer.lookup_transform(
  278. self._frame_id, self._base_frame, rospy.Time(0), rospy.Duration(0.2)
  279. )
  280. except (tf2_ros.LookupException, tf2_ros.ConnectivityException,
  281. tf2_ros.ExtrapolationException, tf2_ros.TimeoutException):
  282. return None
  283. translation = transform.transform.translation
  284. return translation.x, translation.y, self._yaw_from_quaternion(transform.transform.rotation)
  285. def _pose_stamped(self, x, y, yaw):
  286. pose = PoseStamped()
  287. pose.header.frame_id = self._frame_id
  288. pose.header.stamp = rospy.Time.now()
  289. pose.pose.position.x = x
  290. pose.pose.position.y = y
  291. pose.pose.orientation = self._quaternion_from_yaw(yaw)
  292. return pose
  293. def _plan_worker(self):
  294. """Serialize potentially stuck service calls in one daemon worker."""
  295. while True:
  296. task = self._plan_request_queue.get()
  297. try:
  298. task.response = self._plan_client(task.request)
  299. except Exception as error: # rospy may expose several service exceptions.
  300. task.error = error
  301. finally:
  302. task.finished.set()
  303. def _announce_plan_fallback(self):
  304. if self._plan_fallback_announced:
  305. return
  306. self._plan_fallback_announced = True
  307. self._publish_status("MAKE_PLAN_TIMEOUT_FALLBACK_TO_MOVE_BASE")
  308. def _plan_length(self, x, y, yaw):
  309. """Return reachable, length, and whether a live plan was obtained.
  310. The timer thread never calls the service directly. A stalled RPC can
  311. leave one daemon worker blocked, but this search round immediately
  312. falls back and no additional make_plan calls are queued for its other
  313. candidates.
  314. """
  315. if self._plan_timeout_this_round:
  316. return True, float("inf"), False
  317. # Do not probe the service from the timer callback. In the field a
  318. # registered service can still stall during a transport handshake.
  319. # The only RPC is therefore made by _plan_worker below.
  320. start = self._current_pose()
  321. if start is None:
  322. self._plan_timeout_this_round = True
  323. self._announce_plan_fallback()
  324. return True, float("inf"), False
  325. request = GetPlanRequest()
  326. request.start = self._pose_stamped(*start)
  327. request.goal = self._pose_stamped(x, y, yaw)
  328. request.tolerance = 0.0
  329. task = _PlanTask(request)
  330. try:
  331. self._plan_request_queue.put_nowait(task)
  332. except queue.Full:
  333. self._plan_timeout_this_round = True
  334. self._announce_plan_fallback()
  335. return True, float("inf"), False
  336. if not task.finished.wait(self._plan_response_timeout):
  337. self._plan_timeout_this_round = True
  338. self._announce_plan_fallback()
  339. return True, float("inf"), False
  340. if task.error is not None or task.response is None:
  341. rospy.logwarn_throttle(2.0, "factory search make_plan failed: %s", task.error)
  342. self._plan_timeout_this_round = True
  343. self._announce_plan_fallback()
  344. return True, float("inf"), False
  345. poses = task.response.plan.poses
  346. if len(poses) < 2:
  347. return False, None, True
  348. length = 0.0
  349. for first, second in zip(poses, poses[1:]):
  350. dx = second.pose.position.x - first.pose.position.x
  351. dy = second.pose.position.y - first.pose.position.y
  352. length += math.hypot(dx, dy)
  353. return True, length, True
  354. def _candidate_valid(self, candidate, with_plan):
  355. if self._static_map is None:
  356. return False, "STATIC_MAP_UNAVAILABLE", None
  357. static_cost = self._footprint_cost(
  358. self._static_map, candidate["x"], candidate["y"], candidate["yaw"],
  359. self._static_occupied_threshold,
  360. )
  361. if static_cost is None:
  362. return False, "STATIC_FOOTPRINT_BLOCKED", None
  363. local_costmap_checked = (
  364. self._fresh_local_costmap()
  365. and self._footprint_is_inside_grid(
  366. self._local_costmap, candidate["x"], candidate["y"], candidate["yaw"])
  367. )
  368. if local_costmap_checked:
  369. local_cost = self._footprint_cost(
  370. self._local_costmap, candidate["x"], candidate["y"], candidate["yaw"],
  371. self._local_cost_threshold,
  372. )
  373. if local_cost is None:
  374. return False, "LOCAL_FOOTPRINT_BLOCKED", None
  375. else:
  376. # A rolling local costmap may be absent or not cover a distant
  377. # candidate. That is not a cone collision: retain the static-map
  378. # and global-plan checks, then let TEB use live obstacles in motion.
  379. local_cost = self._local_cost_threshold - 1
  380. plan_length = candidate.get("plan_length", float("inf"))
  381. make_plan_checked = candidate.get("make_plan_checked", False)
  382. if with_plan:
  383. reachable, plan_length, make_plan_checked = self._plan_length(
  384. candidate["x"], candidate["y"], candidate["yaw"]
  385. )
  386. if reachable is None:
  387. return False, "MAP_BASE_TF_UNAVAILABLE", None
  388. if not reachable:
  389. return False, "GLOBAL_PLAN_UNREACHABLE", None
  390. candidate["local_cost"] = local_cost
  391. candidate["local_costmap_checked"] = local_costmap_checked
  392. candidate["make_plan_checked"] = make_plan_checked
  393. candidate["plan_length"] = plan_length
  394. return True, None, candidate
  395. def _generate_raw_candidates(self):
  396. x, y, yaw = self._nominal_pose
  397. generated = [{"x": x, "y": y, "yaw": yaw, "radius": 0.0}]
  398. seen = {(round(x, 4), round(y, 4))}
  399. for radius in self._ring_radii:
  400. for index in range(self._ring_directions):
  401. angle = 2.0 * math.pi * index / self._ring_directions
  402. cx, cy = x + radius * math.cos(angle), y + radius * math.sin(angle)
  403. key = round(cx, 4), round(cy, 4)
  404. if key not in seen:
  405. seen.add(key)
  406. generated.append({"x": cx, "y": cy, "yaw": yaw, "radius": radius})
  407. return generated
  408. def _publish_candidates(self):
  409. message = PoseArray()
  410. message.header.frame_id = self._frame_id
  411. message.header.stamp = rospy.Time.now()
  412. for candidate in self._candidates:
  413. pose = Pose()
  414. pose.position.x, pose.position.y = candidate["x"], candidate["y"]
  415. pose.orientation = self._quaternion_from_yaw(candidate["yaw"])
  416. message.poses.append(pose)
  417. self._candidate_pub.publish(message)
  418. def _prepare_candidates(self):
  419. if self._static_map is None:
  420. self._publish_status("WAITING_FOR_STATIC_MAP")
  421. return
  422. if not self._fresh_local_costmap():
  423. self._publish_status("LOCAL_COSTMAP_UNAVAILABLE_STATIC_PLAN_FALLBACK")
  424. accepted = []
  425. for candidate in self._generate_raw_candidates():
  426. valid, _reason, value = self._candidate_valid(candidate, with_plan=True)
  427. if valid:
  428. accepted.append(value)
  429. if not accepted:
  430. self._state = "FAILED"
  431. self._publish_status("NO_SAFE_SEARCH_CANDIDATES")
  432. return
  433. accepted.sort(key=lambda item: (item["radius"], item["local_cost"], item["plan_length"]))
  434. self._candidates = accepted
  435. self._candidate_index = 0
  436. self._publish_candidates()
  437. self._publish_status("CANDIDATES_READY count=%d" % len(self._candidates))
  438. self._send_next_candidate("INITIAL")
  439. def _send_next_candidate(self, reason):
  440. while self._candidate_index < len(self._candidates):
  441. candidate = self._candidates[self._candidate_index]
  442. self._candidate_index += 1
  443. valid, invalid_reason, candidate = self._candidate_valid(candidate, with_plan=True)
  444. if not valid:
  445. self._publish_status("CANDIDATE_SKIPPED reason=%s" % invalid_reason)
  446. continue
  447. if not self._client.wait_for_server(rospy.Duration(self._server_timeout)):
  448. self._state = "FAILED"
  449. self._publish_status("MOVE_BASE_UNAVAILABLE")
  450. return
  451. goal = MoveBaseGoal()
  452. goal.target_pose = self._pose_stamped(candidate["x"], candidate["y"], candidate["yaw"])
  453. self._active_sequence += 1
  454. sequence = self._active_sequence
  455. self._state = "NAVIGATING"
  456. self._active_deadline = time.monotonic() + self._goal_timeout
  457. self._selected_goal_pub.publish(goal.target_pose)
  458. self._client.send_goal(
  459. goal,
  460. done_cb=lambda state, result, seq=sequence, item=candidate: self._goal_done(seq, item, state, result),
  461. )
  462. self._publish_status(
  463. "CANDIDATE_SENT %d/%d reason=%s local_costmap=%s make_plan=%s x=%.3f y=%.3f yaw=%.3f" % (
  464. self._candidate_index, len(self._candidates), reason,
  465. "CHECKED" if candidate["local_costmap_checked"] else "FALLBACK",
  466. "CHECKED" if candidate["make_plan_checked"] else "FALLBACK",
  467. candidate["x"], candidate["y"], candidate["yaw"],
  468. )
  469. )
  470. return
  471. self._state = "FAILED"
  472. self._active_deadline = None
  473. self._publish_status("ALL_SEARCH_CANDIDATES_EXHAUSTED")
  474. def _goal_done(self, sequence, candidate, state, _result):
  475. if sequence != self._active_sequence or self._state != "NAVIGATING":
  476. return
  477. self._active_deadline = None
  478. if state == GoalStatus.SUCCEEDED:
  479. self._state = "WAITING_FOR_SEARCH"
  480. self._search_deadline = time.monotonic() + self._search_timeout
  481. self._publish_status(
  482. "CANDIDATE_REACHED %d/%d; WAITING_FOR_FACTORY_SEARCH" % (
  483. self._candidate_index, len(self._candidates)
  484. )
  485. )
  486. return
  487. if state in self._NAV_FAILURE_STATES:
  488. self._publish_status("CANDIDATE_NAV_FAILED state=%d; TRYING_NEXT" % state)
  489. else:
  490. self._publish_status("CANDIDATE_NAV_UNEXPECTED state=%d; TRYING_NEXT" % state)
  491. self._send_next_candidate("NAVIGATION_FAILURE")
  492. def _alignment_status_callback(self, message):
  493. if self._state != "WAITING_FOR_SEARCH":
  494. return
  495. status = message.data
  496. if status.startswith("FACTORY_ENTRY_COMPLETE"):
  497. self._state = "COMPLETE"
  498. self._completion_locked = True
  499. self._search_deadline = None
  500. self._publish_status("FACTORY_ENTRY_COMPLETE; REMAINING_CANDIDATES_CANCELLED_LOCKED")
  501. return
  502. if status.startswith("FACTORY_MISMATCH"):
  503. self._state = "FAILED"
  504. self._search_deadline = None
  505. self._publish_status("OBSERVATION_MISMATCH")
  506. return
  507. entry_goal_failed = (
  508. status.startswith("ENTRY_GOAL_")
  509. and not status.startswith("ENTRY_GOAL_SENT")
  510. )
  511. if status.startswith(self._SEARCH_RETRY_PREFIXES) or entry_goal_failed:
  512. self._search_deadline = None
  513. self._publish_status("FACTORY_SEARCH_FAILED status=%s; TRYING_NEXT" % status)
  514. self._send_next_candidate("SEARCH_FAILURE")
  515. def _timer_callback(self, _event):
  516. try:
  517. self._timer_tick()
  518. except Exception as error:
  519. rospy.logerr("factory search timer error: %s", error)
  520. self._publish_status("ADAPTER_TIMER_ERROR")
  521. def _timer_tick(self):
  522. if self._state == "PREPARING":
  523. self._prepare_candidates()
  524. return
  525. active_deadline = self._active_deadline
  526. if (self._state == "NAVIGATING"
  527. and active_deadline is not None
  528. and time.monotonic() > active_deadline):
  529. self._client.cancel_goal()
  530. self._active_sequence += 1
  531. self._active_deadline = None
  532. self._publish_status("CANDIDATE_NAV_TIMEOUT; TRYING_NEXT")
  533. self._send_next_candidate("NAVIGATION_TIMEOUT")
  534. return
  535. search_deadline = self._search_deadline
  536. if (self._state == "WAITING_FOR_SEARCH"
  537. and search_deadline is not None
  538. and time.monotonic() > search_deadline):
  539. self._search_deadline = None
  540. self._publish_status("FACTORY_SEARCH_TIMEOUT; TRYING_NEXT")
  541. self._send_next_candidate("SEARCH_TIMEOUT")
  542. def main():
  543. try:
  544. FactorySearchPointAdapter()
  545. rospy.spin()
  546. except (ValueError, rospy.ROSException) as error:
  547. rospy.logfatal("factory search adapter did not start: %s", error)
  548. raise
  549. if __name__ == "__main__":
  550. main()