|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# Copyright 2026 Jayadev Rana |
| 3 | +# |
| 4 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +# you may not use this file except in compliance with the License. |
| 6 | +# You may obtain a copy of the License at |
| 7 | +# |
| 8 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +# |
| 10 | +# Unless required by applicable law or agreed to in writing, software |
| 11 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +# See the License for the specific language governing permissions and |
| 14 | +# limitations under the License. |
| 15 | +""" |
| 16 | +Diff wheel odometry against ground truth to see wheel slip live. |
| 17 | +
|
| 18 | +Pairs two ``nav_msgs/Odometry`` streams by header stamp -- in ``robot_wheels`` |
| 19 | +mode ``/odom`` is the drifting wheel odometry and ``/odom_truth`` is the sim's |
| 20 | +noise-free pose -- converts each quaternion to a yaw angle, and reports how the |
| 21 | +wheel odometry diverges from the true rotation. Slip appears when the wheels |
| 22 | +turn differently than the body: the wheels OVER-read on accelerating wheelspin |
| 23 | +and UNDER-read as the body coasts past a stopped wheel on an abrupt stop. That |
| 24 | +per-cycle net over-read is what accumulates into raw wheel-odom heading drift, |
| 25 | +the drift slam_toolbox quietly corrects against the map every scan. |
| 26 | +
|
| 27 | +``/odom`` and ``/odom_truth`` are NOT the same frame, so the raw yaw difference |
| 28 | +is dominated by a constant frame offset (plus accumulated drift). The slip is in |
| 29 | +the *changes*, so the useful signals are de-trended from per-sample wrapped |
| 30 | +deltas, which cancel the constant offset:: |
| 31 | +
|
| 32 | + slip_rate = d(wheel_yaw - truth_yaw)/dt ~= omega_wheel - omega_truth |
| 33 | +
|
| 34 | +It reads ~0 while the wheels roll true, spikes + on wheelspin, and spikes - on |
| 35 | +the inertial coast at a stop. |
| 36 | +
|
| 37 | +Published (``std_msgs/Float32``, full rate -- plot these in Foxglove/rqt): |
| 38 | +
|
| 39 | + pub ~/slip_rate_dps the slip detector: deg/s, ~0 when rolling true |
| 40 | + pub ~/slip_deg accumulated slip since start (de-trended), 0-based |
| 41 | + pub ~/yaw_error_deg raw wheel_yaw - truth_yaw (frame-offset dominated) |
| 42 | + pub ~/pos_error_mm ||wheel_xy - truth_xy|| (also frame-offset dominated) |
| 43 | + pub ~/wheel_yaw_deg overlay these two to SEE the wheels lag the body |
| 44 | + pub ~/truth_yaw_deg |
| 45 | +
|
| 46 | +Run (with odom_source:=robot_wheels, so /odom is the wheel odometry):: |
| 47 | +
|
| 48 | + ros2 run oomwoo_sim_support odom_slip --ros-args -p use_sim_time:=true |
| 49 | +
|
| 50 | +In ground_truth mode the wheel odometry is on /odom_wheel, so pass |
| 51 | +``-p wheel_topic:=/odom_wheel``. |
| 52 | +""" |
| 53 | + |
| 54 | +import math |
| 55 | + |
| 56 | +from message_filters import ApproximateTimeSynchronizer, Subscriber |
| 57 | + |
| 58 | +from nav_msgs.msg import Odometry |
| 59 | + |
| 60 | +import rclpy |
| 61 | +from rclpy.node import Node |
| 62 | + |
| 63 | +from std_msgs.msg import Float32 |
| 64 | + |
| 65 | + |
| 66 | +def yaw_from_quat(q): |
| 67 | + """Return the yaw (rad) of a geometry_msgs/Quaternion, planar-safe.""" |
| 68 | + siny = 2.0 * (q.w * q.z + q.x * q.y) |
| 69 | + cosy = 1.0 - 2.0 * (q.y * q.y + q.z * q.z) |
| 70 | + return math.atan2(siny, cosy) |
| 71 | + |
| 72 | + |
| 73 | +def wrap180(deg): |
| 74 | + """Wrap an angle already in degrees to (-180, 180].""" |
| 75 | + return (deg + 180.0) % 360.0 - 180.0 |
| 76 | + |
| 77 | + |
| 78 | +class OdomSlip(Node): |
| 79 | + """Pair wheel/truth odom by stamp and report the slip between them.""" |
| 80 | + |
| 81 | + def __init__(self): |
| 82 | + super().__init__('odom_slip') |
| 83 | + wheel = self.declare_parameter('wheel_topic', '/odom').value |
| 84 | + truth = self.declare_parameter('truth_topic', '/odom_truth').value |
| 85 | + # flag a console line when the slip RATE exceeds this (deg/s); the raw |
| 86 | + # yaw difference is swamped by the frame offset, so rate is the signal. |
| 87 | + self.flag_dps = self.declare_parameter('slip_rate_flag_dps', 20.0).value |
| 88 | + self.print_period = self.declare_parameter('print_period_s', 0.5).value |
| 89 | + |
| 90 | + sub_w = Subscriber(self, Odometry, wheel) |
| 91 | + sub_t = Subscriber(self, Odometry, truth) |
| 92 | + # slop 20 ms: tight enough to compare the same instant, loose enough to |
| 93 | + # pair a ~25 Hz truth with a faster wheel stream. |
| 94 | + self.sync = ApproximateTimeSynchronizer([sub_w, sub_t], queue_size=50, |
| 95 | + slop=0.02) |
| 96 | + self.sync.registerCallback(self.on_pair) |
| 97 | + |
| 98 | + self.pub_rate = self.create_publisher(Float32, '~/slip_rate_dps', 10) |
| 99 | + self.pub_slip = self.create_publisher(Float32, '~/slip_deg', 10) |
| 100 | + self.pub_dyaw = self.create_publisher(Float32, '~/yaw_error_deg', 10) |
| 101 | + self.pub_dpos = self.create_publisher(Float32, '~/pos_error_mm', 10) |
| 102 | + self.pub_wyaw = self.create_publisher(Float32, '~/wheel_yaw_deg', 10) |
| 103 | + self.pub_tyaw = self.create_publisher(Float32, '~/truth_yaw_deg', 10) |
| 104 | + |
| 105 | + self.prev = None # (t, wheel_yaw_deg, truth_yaw_deg) |
| 106 | + self.accum = 0.0 # de-trended accumulated slip, deg |
| 107 | + self.peak_rate = 0.0 |
| 108 | + self.peak_at = 0.0 |
| 109 | + self.last_print = None |
| 110 | + self.get_logger().info( |
| 111 | + f'slip: {wheel} (wheel) vs {truth} (truth); ' |
| 112 | + f'flagging |slip_rate| >= {self.flag_dps} deg/s') |
| 113 | + |
| 114 | + def on_pair(self, wheel, truth): |
| 115 | + """Publish and log the slip for one time-matched odom pair.""" |
| 116 | + t = wheel.header.stamp.sec + wheel.header.stamp.nanosec * 1e-9 |
| 117 | + yw = math.degrees(yaw_from_quat(wheel.pose.pose.orientation)) |
| 118 | + yt = math.degrees(yaw_from_quat(truth.pose.pose.orientation)) |
| 119 | + dyaw = wrap180(yw - yt) |
| 120 | + dx = wheel.pose.pose.position.x - truth.pose.pose.position.x |
| 121 | + dy = wheel.pose.pose.position.y - truth.pose.pose.position.y |
| 122 | + dpos = math.hypot(dx, dy) |
| 123 | + |
| 124 | + # Slip rate from per-sample wrapped deltas: (dwheel - dtruth)/dt. The |
| 125 | + # constant frame offset cancels, so this is 0 while rolling true and |
| 126 | + # spikes on wheelspin / inertial coast. Accumulate it (de-trended slip). |
| 127 | + rate = 0.0 |
| 128 | + if self.prev is not None: |
| 129 | + pt, pyw, pyt = self.prev |
| 130 | + dt = t - pt |
| 131 | + if dt > 1e-6: |
| 132 | + dslip = wrap180(yw - pyw) - wrap180(yt - pyt) |
| 133 | + self.accum += dslip |
| 134 | + rate = dslip / dt |
| 135 | + self.prev = (t, yw, yt) |
| 136 | + |
| 137 | + # full rate, so the trace is smooth and catches the 1-frame stop flick |
| 138 | + self.pub_rate.publish(Float32(data=float(rate))) |
| 139 | + self.pub_slip.publish(Float32(data=float(self.accum))) |
| 140 | + self.pub_dyaw.publish(Float32(data=float(dyaw))) |
| 141 | + self.pub_dpos.publish(Float32(data=float(dpos * 1000.0))) |
| 142 | + self.pub_wyaw.publish(Float32(data=float(yw))) |
| 143 | + self.pub_tyaw.publish(Float32(data=float(yt))) |
| 144 | + |
| 145 | + if abs(rate) > self.peak_rate: |
| 146 | + self.peak_rate = abs(rate) |
| 147 | + self.peak_at = t |
| 148 | + |
| 149 | + flag = abs(rate) >= self.flag_dps |
| 150 | + due = (self.last_print is None |
| 151 | + or t - self.last_print >= self.print_period) |
| 152 | + if flag or due: |
| 153 | + self.last_print = t |
| 154 | + tag = ' <-- SLIP' if flag else '' |
| 155 | + self.get_logger().info( |
| 156 | + f't={t:9.3f} wheel={yw:7.2f} truth={yt:7.2f} ' |
| 157 | + f'slip_rate={rate:+7.1f} deg/s accum={self.accum:+6.2f} deg ' |
| 158 | + f'dpos={dpos * 1000:5.1f} mm{tag}') |
| 159 | + |
| 160 | + |
| 161 | +def main(): |
| 162 | + """Spin the slip meter until interrupted, then print a summary.""" |
| 163 | + rclpy.init() |
| 164 | + node = OdomSlip() |
| 165 | + try: |
| 166 | + rclpy.spin(node) |
| 167 | + except KeyboardInterrupt: |
| 168 | + pass |
| 169 | + finally: |
| 170 | + node.get_logger().info( |
| 171 | + f'peak |slip_rate| = {node.peak_rate:.1f} deg/s ' |
| 172 | + f'at t={node.peak_at:.3f}; ' |
| 173 | + f'net accumulated slip = {node.accum:+.2f} deg') |
| 174 | + node.destroy_node() |
| 175 | + if rclpy.ok(): |
| 176 | + rclpy.shutdown() |
| 177 | + |
| 178 | + |
| 179 | +if __name__ == '__main__': |
| 180 | + main() |
0 commit comments