line_follow_control.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. #!/usr/bin/env python3
  2. """Pure-pursuit controller for the metric IPM lane centreline."""
  3. import math
  4. import threading
  5. import rospy
  6. from geometry_msgs.msg import PointStamped, Twist
  7. from std_msgs.msg import Bool, String
  8. from std_srvs.srv import SetBool, SetBoolResponse
  9. class LineFollowControl:
  10. """Publish /cmd_vel only while the explicit enabled safety gate is true."""
  11. def __init__(self):
  12. rospy.init_node("line_follow_control")
  13. self.lock = threading.RLock()
  14. self.lane_valid_topic = rospy.get_param(
  15. "~lane_valid_topic", "/line_follow_debug/lane_valid"
  16. )
  17. self.lookahead_target_topic = rospy.get_param(
  18. "~lookahead_target_topic", "/line_follow_debug/lookahead_target"
  19. )
  20. self.cmd_vel_topic = rospy.get_param("~cmd_vel_topic", "/cmd_vel")
  21. self.control_rate = max(1.0, float(rospy.get_param("~control_rate", 20.0)))
  22. self.linear_speed = max(0.0, float(rospy.get_param("~linear_speed", 0.05)))
  23. self.normal_linear_speed = max(
  24. 0.0,
  25. float(rospy.get_param("~normal_linear_speed", self.linear_speed)),
  26. )
  27. self.after_second_linear_speed = max(
  28. 0.0,
  29. float(
  30. rospy.get_param(
  31. "~after_second_linear_speed", self.normal_linear_speed
  32. )
  33. ),
  34. )
  35. self.task_state_topic = str(
  36. rospy.get_param("~task_state_topic", "/traffic_line_task/state")
  37. )
  38. self.linear_speed = self.normal_linear_speed
  39. self.curvature_gain = max(
  40. 0.0, float(rospy.get_param("~curvature_gain", 1.0))
  41. )
  42. self.steering_sign = float(rospy.get_param("~steering_sign", 1.0))
  43. self.max_angular_speed = max(
  44. 0.0, float(rospy.get_param("~max_angular_speed", 0.35))
  45. )
  46. self.target_alpha = min(
  47. 1.0, max(0.01, float(rospy.get_param("~target_alpha", 0.45)))
  48. )
  49. self.lateral_deadband_m = max(
  50. 0.0, float(rospy.get_param("~lateral_deadband_m", 0.01))
  51. )
  52. self.min_target_forward_m = max(
  53. 0.01, float(rospy.get_param("~min_target_forward_m", 0.10))
  54. )
  55. self.max_target_distance_m = max(
  56. self.min_target_forward_m,
  57. float(rospy.get_param("~max_target_distance_m", 1.50)),
  58. )
  59. self.target_timeout = max(
  60. 0.05, float(rospy.get_param("~target_timeout", 0.50))
  61. )
  62. self.enabled = self.parameter_is_true(rospy.get_param("~enabled", False))
  63. self.lane_valid = False
  64. self.target_forward = None
  65. self.target_left = None
  66. self.last_target_time = None
  67. self.was_active = False
  68. self.cmd_pub = rospy.Publisher(self.cmd_vel_topic, Twist, queue_size=1)
  69. self.valid_sub = rospy.Subscriber(
  70. self.lane_valid_topic, Bool, self.valid_callback, queue_size=1
  71. )
  72. self.target_sub = rospy.Subscriber(
  73. self.lookahead_target_topic,
  74. PointStamped,
  75. self.target_callback,
  76. queue_size=1,
  77. )
  78. self.task_state_sub = rospy.Subscriber(
  79. self.task_state_topic, String, self.task_state_callback, queue_size=1
  80. )
  81. self.enable_service = rospy.Service(
  82. "~set_enabled", SetBool, self.set_enabled_callback
  83. )
  84. self.timer = rospy.Timer(
  85. rospy.Duration(1.0 / self.control_rate), self.control_callback
  86. )
  87. rospy.on_shutdown(self.shutdown)
  88. rospy.loginfo(
  89. "Pure-pursuit line controller ready; enabled=%s target=%s.",
  90. self.enabled,
  91. self.lookahead_target_topic,
  92. )
  93. @staticmethod
  94. def parameter_is_true(value):
  95. """Avoid treating the string 'false' as truthy."""
  96. if isinstance(value, str):
  97. return value.strip().lower() in ("1", "true", "yes", "on")
  98. return bool(value)
  99. def valid_callback(self, message):
  100. with self.lock:
  101. self.lane_valid = bool(message.data)
  102. def task_state_callback(self, message):
  103. requested = (
  104. self.after_second_linear_speed
  105. if message.data == "FOLLOW_AFTER_SECOND"
  106. else self.normal_linear_speed
  107. )
  108. with self.lock:
  109. previous = self.linear_speed
  110. self.linear_speed = requested
  111. if abs(previous - requested) > 1e-6:
  112. rospy.loginfo(
  113. "Line-follow speed changed to %.2f m/s for task state %s.",
  114. requested,
  115. message.data,
  116. )
  117. def set_enabled_callback(self, request):
  118. with self.lock:
  119. self.enabled = bool(request.data)
  120. rospy.set_param("~enabled", self.enabled)
  121. if not self.enabled:
  122. self.publish_stop()
  123. self.was_active = False
  124. self.reset_target()
  125. rospy.loginfo("Line controller disabled through service.")
  126. else:
  127. rospy.loginfo("Line controller enabled through service.")
  128. return SetBoolResponse(
  129. success=True,
  130. message="line controller %s"
  131. % ("enabled" if self.enabled else "disabled"),
  132. )
  133. def target_callback(self, message):
  134. with self.lock:
  135. forward = float(message.point.x)
  136. left = float(message.point.y)
  137. distance = math.hypot(forward, left)
  138. if (
  139. not math.isfinite(forward)
  140. or not math.isfinite(left)
  141. or forward < self.min_target_forward_m
  142. or distance > self.max_target_distance_m
  143. ):
  144. rospy.logwarn_throttle(
  145. 1.0,
  146. "Rejected lookahead target: forward=%.3f m left=%+.3f m.",
  147. forward,
  148. left,
  149. )
  150. return
  151. if self.target_forward is None:
  152. self.target_forward = forward
  153. self.target_left = left
  154. else:
  155. alpha = self.target_alpha
  156. self.target_forward = (
  157. alpha * forward + (1.0 - alpha) * self.target_forward
  158. )
  159. self.target_left = alpha * left + (1.0 - alpha) * self.target_left
  160. self.last_target_time = rospy.get_time()
  161. def reset_target(self):
  162. self.target_forward = None
  163. self.target_left = None
  164. self.last_target_time = None
  165. def publish_stop(self):
  166. self.cmd_pub.publish(Twist())
  167. @staticmethod
  168. def pure_pursuit_curvature(forward, left):
  169. """Return signed path curvature for a target in base_link."""
  170. distance_squared = forward * forward + left * left
  171. if distance_squared <= 1e-6:
  172. return None
  173. return 2.0 * left / distance_squared
  174. def shutdown(self):
  175. with self.lock:
  176. self.publish_stop()
  177. def control_callback(self, _event):
  178. with self.lock:
  179. self._control_locked()
  180. def _control_locked(self):
  181. # Keep the old rosparam workflow working for manual tests while the
  182. # task state machine uses the SetBool service.
  183. parameter_enabled = self.parameter_is_true(
  184. rospy.get_param("~enabled", self.enabled)
  185. )
  186. if parameter_enabled != self.enabled:
  187. self.enabled = parameter_enabled
  188. if not self.enabled:
  189. if self.was_active:
  190. self.publish_stop()
  191. rospy.loginfo(
  192. "Line controller disabled; published a zero-velocity command."
  193. )
  194. self.was_active = False
  195. self.reset_target()
  196. return
  197. now = rospy.get_time()
  198. target_fresh = (
  199. self.last_target_time is not None
  200. and (now - self.last_target_time) <= self.target_timeout
  201. )
  202. if not self.lane_valid or not target_fresh:
  203. self.publish_stop()
  204. rospy.logwarn_throttle(
  205. 1.0,
  206. "Pure-pursuit safety stop: lane_valid=%s target_fresh=%s.",
  207. self.lane_valid,
  208. target_fresh,
  209. )
  210. self.was_active = False
  211. return
  212. forward = self.target_forward
  213. left = self.target_left
  214. if abs(left) <= self.lateral_deadband_m:
  215. left = 0.0
  216. curvature = self.pure_pursuit_curvature(forward, left)
  217. if curvature is None:
  218. self.publish_stop()
  219. rospy.logerr("Pure-pursuit safety stop: target distance is zero.")
  220. self.was_active = False
  221. return
  222. # In base_link, +x is forward and +y is left. Pure pursuit for a
  223. # unicycle gives curvature=2*y/L^2 and angular velocity=v*curvature.
  224. angular = (
  225. self.steering_sign
  226. * self.curvature_gain
  227. * self.linear_speed
  228. * curvature
  229. )
  230. angular = max(
  231. -self.max_angular_speed, min(self.max_angular_speed, angular)
  232. )
  233. if not math.isfinite(angular):
  234. self.publish_stop()
  235. rospy.logerr("Pure-pursuit safety stop: non-finite angular velocity.")
  236. self.was_active = False
  237. return
  238. command = Twist()
  239. command.linear.x = self.linear_speed
  240. command.angular.z = angular
  241. self.cmd_pub.publish(command)
  242. self.was_active = True
  243. rospy.loginfo_throttle(
  244. 1.0,
  245. "Pure pursuit: target=(%.3f m,%+.3f m left) curvature=%+.3f 1/m angular=%+.3f rad/s linear=%.2f m/s.",
  246. forward,
  247. left,
  248. curvature,
  249. angular,
  250. self.linear_speed,
  251. )
  252. if __name__ == "__main__":
  253. try:
  254. LineFollowControl()
  255. rospy.spin()
  256. except rospy.ROSInterruptException:
  257. pass