Skip to content

Commit 356c71f

Browse files
kaiaaiclaude
andcommitted
Add odom_slip diagnostic to oomwoo_sim_support
Pairs /odom (wheel) and /odom_truth by header stamp and publishes the wheel-vs-truth slip for Foxglove: ~/slip_rate_dps (~= omega_wheel - omega_truth, ~0 rolling true, spikes on wheelspin / inertial coast) and de-trended ~/slip_deg. The raw yaw difference is dominated by a constant frame offset, so the rate/accumulation is the real signal. Used to confirm the residual one-frame "stop flick" after the slam_toolbox tuning is inertia-driven wheel slip, not a localizer bug. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 493a993 commit 356c71f

3 files changed

Lines changed: 182 additions & 0 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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()

src/oomwoo_sim_support/package.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<exec_depend>nav_msgs</exec_depend>
1515
<exec_depend>geometry_msgs</exec_depend>
1616
<exec_depend>std_msgs</exec_depend>
17+
<exec_depend>message_filters</exec_depend>
1718
<exec_depend>tf2_msgs</exec_depend>
1819
<exec_depend>tf2_ros</exec_depend>
1920
<exec_depend>std_srvs</exec_depend>

src/oomwoo_sim_support/setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
'initialpose_pub = oomwoo_sim_support.initialpose_pub_node:main',
4949
'localization_error = '
5050
'oomwoo_sim_support.localization_error_node:main',
51+
'odom_slip = oomwoo_sim_support.odom_slip_node:main',
5152
'relocalize_on_lost = '
5253
'oomwoo_sim_support.relocalize_on_lost_node:main',
5354
'reloc_regression_runner = oomwoo_sim_support.reloc_regression_runner:main',

0 commit comments

Comments
 (0)