Skip to content

Commit e2fddb2

Browse files
authored
Support multiple topics via ros2 topic hz. (#929)
Signed-off-by: Tomoya Fujita <[email protected]> Signed-off-by: Chen Lihui <[email protected]>
1 parent 303f0b1 commit e2fddb2

File tree

2 files changed

+129
-35
lines changed

2 files changed

+129
-35
lines changed

ros2topic/ros2topic/verb/hz.py

Lines changed: 100 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@
3434
import functools
3535
import math
3636
import threading
37+
import time
3738

3839
import rclpy
3940

4041
from rclpy.clock import Clock
4142
from rclpy.clock import ClockType
43+
from rclpy.executors import ExternalShutdownException
4244
from rclpy.qos import qos_profile_sensor_data
4345
from ros2cli.node.direct import add_arguments as add_direct_node_arguments
4446
from ros2cli.node.direct import DirectNode
@@ -56,7 +58,8 @@ class HzVerb(VerbExtension):
5658
def add_arguments(self, parser, cli_name):
5759
arg = parser.add_argument(
5860
'topic_name',
59-
help="Name of the ROS topic to listen to (e.g. '/chatter')")
61+
nargs='+',
62+
help="Names of the ROS topic to listen to (e.g. '/chatter')")
6063
arg.completer = TopicNameCompleter(
6164
include_hidden_topics_key='include_hidden_topics')
6265
parser.add_argument(
@@ -80,7 +83,7 @@ def main(self, *, args):
8083

8184

8285
def main(args):
83-
topic = args.topic_name
86+
topics = args.topic_name
8487
if args.filter_expr:
8588
def expr_eval(expr):
8689
def eval_fn(m):
@@ -91,7 +94,7 @@ def eval_fn(m):
9194
filter_expr = None
9295

9396
with DirectNode(args) as node:
94-
_rostopic_hz(node.node, topic, window_size=args.window_size, filter_expr=filter_expr,
97+
_rostopic_hz(node.node, topics, window_size=args.window_size, filter_expr=filter_expr,
9598
use_wtime=args.use_wtime)
9699

97100

@@ -202,13 +205,10 @@ def get_hz(self, topic=None):
202205
"""
203206
if not self.get_times(topic=topic):
204207
return
205-
elif self.get_last_printed_tn(topic=topic) == 0:
206-
self.set_last_printed_tn(self.get_msg_tn(topic=topic), topic=topic)
207-
return
208-
elif self.get_msg_tn(topic=topic) < self.get_last_printed_tn(topic=topic) + 1e9:
208+
elif self.get_msg_tn(topic=topic) == self.get_last_printed_tn(topic=topic):
209209
return
210210
with self.lock:
211-
# Get frequency every one minute
211+
# Get frequency every one second
212212
times = self.get_times(topic=topic)
213213
n = len(times)
214214
mean = sum(times) / n
@@ -225,43 +225,108 @@ def get_hz(self, topic=None):
225225

226226
return rate, min_delta, max_delta, std_dev, n
227227

228-
def print_hz(self, topic=None):
228+
def print_hz(self, topics=None):
229229
"""Print the average publishing rate to screen."""
230-
ret = self.get_hz(topic)
231-
if ret is None:
232-
return
233-
rate, min_delta, max_delta, std_dev, window = ret
234-
print('average rate: %.3f\n\tmin: %.3fs max: %.3fs std dev: %.5fs window: %s'
235-
% (rate * 1e9, min_delta * 1e-9, max_delta * 1e-9, std_dev * 1e-9, window))
236-
return
237230

231+
def get_format_hz(stat):
232+
return stat[0] * 1e9, stat[1] * 1e-9, stat[2] * 1e-9, stat[3] * 1e-9, stat[4]
233+
234+
if len(topics) == 1:
235+
ret = self.get_hz(topics[0])
236+
if ret is None:
237+
return
238+
rate, min_delta, max_delta, std_dev, window = get_format_hz(ret)
239+
print('average rate: %.3f\n\tmin: %.3fs max: %.3fs std dev: %.5fs window: %s'
240+
% (rate, min_delta, max_delta, std_dev, window))
241+
return
238242

239-
def _rostopic_hz(node, topic, window_size=DEFAULT_WINDOW_SIZE, filter_expr=None, use_wtime=False):
243+
# monitoring multiple topics' hz
244+
header = ['topic', 'rate', 'min_delta', 'max_delta', 'std_dev', 'window']
245+
stats = {h: [] for h in header}
246+
for topic in topics:
247+
hz_stat = self.get_hz(topic)
248+
if hz_stat is None:
249+
continue
250+
rate, min_delta, max_delta, std_dev, window = get_format_hz(hz_stat)
251+
stats['topic'].append(topic)
252+
stats['rate'].append('{:.3f}'.format(rate))
253+
stats['min_delta'].append('{:.3f}'.format(min_delta))
254+
stats['max_delta'].append('{:.3f}'.format(max_delta))
255+
stats['std_dev'].append('{:.5f}'.format(std_dev))
256+
stats['window'].append(str(window))
257+
if not stats['topic']:
258+
return
259+
print(_get_ascii_table(header, stats))
260+
261+
262+
def _get_ascii_table(header, cols):
263+
# compose table with left alignment
264+
header_aligned = []
265+
col_widths = []
266+
for h in header:
267+
col_width = max(len(h), max(len(el) for el in cols[h]))
268+
col_widths.append(col_width)
269+
header_aligned.append(h.center(col_width))
270+
for i, el in enumerate(cols[h]):
271+
cols[h][i] = str(cols[h][i]).ljust(col_width)
272+
# sum of col and each 3 spaces width
273+
table_width = sum(col_widths) + 3 * (len(header) - 1)
274+
n_rows = len(cols[header[0]])
275+
body = '\n'.join(' '.join(cols[h][i] for h in header) for i in range(n_rows))
276+
table = '{header}\n{hline}\n{body}\n'.format(
277+
header=' '.join(header_aligned), hline='=' * table_width, body=body)
278+
return table
279+
280+
281+
def _rostopic_hz(node, topics, window_size=DEFAULT_WINDOW_SIZE, filter_expr=None, use_wtime=False):
240282
"""
241283
Periodically print the publishing rate of a topic to console until shutdown.
242284
243-
:param topic: topic name, ``list`` of ``str``
285+
:param topics: list of topic names, ``list`` of ``str``
244286
:param window_size: number of messages to average over, -1 for infinite, ``int``
245287
:param filter_expr: Python filter expression that is called with m, the message instance
246288
"""
247289
# pause hz until topic is published
248-
msg_class = get_msg_class(
249-
node, topic, blocking=True, include_hidden_topics=True)
250-
251-
if msg_class is None:
290+
rt = ROSTopicHz(node, window_size, filter_expr=filter_expr, use_wtime=use_wtime)
291+
topics_len = len(topics)
292+
topics_to_be_removed = []
293+
for topic in topics:
294+
msg_class = get_msg_class(
295+
node, topic, blocking=True, include_hidden_topics=True)
296+
297+
if msg_class is None:
298+
topics_to_be_removed.append(topic)
299+
print('WARNING: failed to find message type for topic [%s]' % topic)
300+
continue
301+
302+
node.create_subscription(
303+
msg_class,
304+
topic,
305+
functools.partial(rt.callback_hz, topic=topic),
306+
qos_profile_sensor_data)
307+
if topics_len > 1:
308+
print('Subscribed to [%s]' % topic)
309+
310+
# remove the topics from the list if failed to find message type
311+
while (topic in topics_to_be_removed):
312+
topics.remove(topic)
313+
if len(topics) == 0:
252314
node.destroy_node()
315+
rclpy.try_shutdown()
253316
return
254317

255-
rt = ROSTopicHz(node, window_size, filter_expr=filter_expr, use_wtime=use_wtime)
256-
node.create_subscription(
257-
msg_class,
258-
topic,
259-
functools.partial(rt.callback_hz, topic=topic),
260-
qos_profile_sensor_data)
261-
262-
while rclpy.ok():
263-
rclpy.spin_once(node)
264-
rt.print_hz(topic)
265-
266-
node.destroy_node()
267-
rclpy.shutdown()
318+
try:
319+
def thread_func():
320+
while rclpy.ok():
321+
rt.print_hz(topics)
322+
time.sleep(1.0)
323+
324+
print_thread = threading.Thread(target=thread_func)
325+
print_thread.start()
326+
rclpy.spin(node)
327+
except (KeyboardInterrupt, ExternalShutdownException):
328+
pass
329+
finally:
330+
node.destroy_node()
331+
rclpy.try_shutdown()
332+
print_thread.join()

ros2topic/test/test_cli.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,35 @@ def test_topic_hz(self):
814814
average_rate = float(average_rate_line_pattern.match(head_line).group(1))
815815
assert math.isclose(average_rate, 1., rel_tol=1e-2)
816816

817+
@launch_testing.markers.retry_on_failure(times=5, delay=1)
818+
def test_multiple_topics_hz(self):
819+
header_pattern = re.compile(r'\s+topic\s+rate\s+min_delta\s+max_delta\s+std_dev\s+window')
820+
hline_pattern = re.compile(r'=+')
821+
chatter_line_pattern = re.compile(
822+
r'/chatter\s+(\d+.\d{3})\s+\d+.\d{3}\s+\d+.\d{3}\s+\d+.\d{5}\s+\d+\s+')
823+
hidden_chatter_line_pattern = re.compile(
824+
r'/_hidden_chatter\s+(\d+.\d{3})\s+\d+.\d{3}\s+\d+.\d{3}\s+\d+.\d{5}\s+\d+\s+')
825+
with self.launch_topic_command(
826+
arguments=['hz', '/chatter', '/_hidden_chatter']
827+
) as topic_command:
828+
assert topic_command.wait_for_output(functools.partial(
829+
launch_testing.tools.expect_output, expected_lines=[
830+
'Subscribed to [/chatter]',
831+
'Subscribed to [/_hidden_chatter]',
832+
header_pattern, hline_pattern,
833+
chatter_line_pattern, hidden_chatter_line_pattern
834+
], strict=True
835+
), timeout=10)
836+
assert topic_command.wait_for_shutdown(timeout=10)
837+
838+
chatter_line = topic_command.output.splitlines()[4]
839+
chatter_average_rate = float(chatter_line_pattern.match(chatter_line).group(1))
840+
assert math.isclose(chatter_average_rate, 1., rel_tol=1e-2)
841+
hidden_chatter_line = topic_command.output.splitlines()[5]
842+
hidden_chatter_average_rate = float(hidden_chatter_line_pattern.match(
843+
hidden_chatter_line).group(1))
844+
assert math.isclose(hidden_chatter_average_rate, 1., rel_tol=1e-2)
845+
817846
@launch_testing.markers.retry_on_failure(times=5, delay=1)
818847
def test_filtered_topic_hz(self):
819848
average_rate_line_pattern = re.compile(r'average rate: (\d+.\d{3})')

0 commit comments

Comments
 (0)