Skip to content

Commit af3d694

Browse files
authored
Show completion time as metrics on the dashboard (#16)
* Add completion time to metrics * Fix setting up metrics with artefacts * Fix dora-rs handling test faliure as an error * Detect whe robot gets stuck and fail the test early
1 parent 2f5adb5 commit af3d694

8 files changed

Lines changed: 150 additions & 11 deletions

File tree

artefacts.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ jobs:
3737
- name: pose_based_waypoint_mission_test
3838
params:
3939
policy:
40-
- stumbling
4140
- baseline
41+
- stumbling
42+
metrics: metrics.json
4243
run: "uv run dataflow --test-waypoint-poses"
4344

dataflow/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies = [
1414
"dora-rs-cli>=0.3.13",
1515
"typer>=0.20.0",
1616
"artefacts-toolkit>=0.7.1",
17+
"pytest-custom-exit-code>=0.3.0",
1718
]
1819

1920
[project.scripts]

dataflow/src/dataflow/dataflow.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,21 @@ def run_dataflow(
163163
junit_xml_path = (
164164
output_path / ".." / "tests_junit.xml"
165165
) # Save junit in the root outputs folder
166+
166167
tester = dataflow.add_node(
167168
id="tester",
168169
path="pytest",
169-
args=f"{nodes_path / 'tester/tester' / test} -s --junit-xml={str(junit_xml_path)}",
170+
args=" ".join(
171+
[
172+
f"{nodes_path / 'tester/tester' / test} -s",
173+
f"--junit-xml={str(junit_xml_path)}",
174+
# Avoid failing the dataflow on test failures
175+
# https://docs.pytest.org/en/stable/reference/exit-codes.html
176+
"--suppress-tests-failed-exit-code",
177+
]
178+
),
170179
)
180+
171181
tester.add_input("waypoints", "simulation/waypoints")
172182
tester.add_input("scene_info", "simulation/scene_info")
173183
tester.add_input("robot_pose", "simulation/robot_pose")

msgs/src/msgs/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ def now(cls) -> "Timestamp":
3232
def float_seconds(self) -> float:
3333
return self.seconds + self.nanoseconds / 1e9
3434

35+
@property
36+
def float_milliseconds(self) -> float:
37+
return self.float_seconds * 1e3
38+
3539

3640
@dataclass
3741
class Transform(ArrowMessage):

nodes/tester/tester/conftest.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# noqa: D100
2+
import os
3+
from pathlib import Path
4+
25
import msgs
36
import pyarrow as pa
47
import pytest
@@ -74,3 +77,27 @@ def node(request, session_node: TestNode):
7477
session_node.set_timeout(clock_timeout.args[0])
7578
yield session_node
7679
session_node.reset_timeout()
80+
81+
82+
@pytest.fixture(scope="session")
83+
def metrics():
84+
"""Collect test metrics during the test session."""
85+
metrics = {}
86+
87+
yield metrics
88+
89+
workspace_path = Path(__file__).parent.parent.parent.parent
90+
metrics_path = (
91+
Path(
92+
os.getenv(
93+
"ARTEFACTS_SCENARIO_UPLOAD_DIR", workspace_path / "outputs/artefacts"
94+
)
95+
)
96+
/ ".."
97+
/ "metrics.json"
98+
)
99+
metrics_path.parent.mkdir(parents=True, exist_ok=True)
100+
with open(metrics_path, "w") as f:
101+
import json
102+
103+
json.dump(metrics, f, indent=2)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Helper class to detect if the robot is stuck."""
2+
3+
import msgs
4+
5+
6+
class StuckDetector:
7+
"""Helper class to detect if the robot is stuck."""
8+
9+
def __init__(self, threshold: float = 0.1, max_no_progress_time: float = 3.0):
10+
"""Initialize the stuck detector.
11+
12+
Args:
13+
threshold: Distance threshold in meters to consider as progress.
14+
max_no_progress_time: Maximum time in seconds without progress before
15+
considering the robot as stuck.
16+
17+
"""
18+
self.threshold = threshold
19+
self.max_no_progress_time = max_no_progress_time
20+
self.last_position = None
21+
self.last_progress_time = None
22+
23+
def step_is_stuck(self, position: msgs.Transform, current_time: float) -> bool:
24+
"""Update the detector with the current position.
25+
26+
Returns True if the robot is considered stuck.
27+
"""
28+
if self.last_position is None or self.last_progress_time is None:
29+
self.last_position = position
30+
self.last_progress_time = current_time
31+
return False
32+
33+
distance_moved = (
34+
(position.x - self.last_position.x) ** 2
35+
+ (position.y - self.last_position.y) ** 2
36+
+ (position.z - self.last_position.z) ** 2
37+
) ** 0.5
38+
39+
if distance_moved >= self.threshold:
40+
self.last_position = position
41+
self.last_progress_time = current_time
42+
return False
43+
else:
44+
if current_time - self.last_progress_time > self.max_no_progress_time:
45+
print(
46+
"Robot is stuck: no significant movement detected."
47+
f" Distance moved: {distance_moved:.3f} m in"
48+
f" {current_time - self.last_progress_time:.2f} s."
49+
)
50+
return True
51+
52+
return False

nodes/tester/tester/test_waypoints_poses.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
"""Dora node that test waypoint mission completions using the robot pose."""
22

3-
import time
4-
53
import msgs
64
import pytest
75

6+
from tester.stuck_detector import StuckDetector
87
from tester.transforms import Transforms
98

109

@@ -20,22 +19,28 @@ def test_receives_scene_info_on_startup(node):
2019

2120
@pytest.mark.parametrize("difficulty", [0.1, 0.7, 1.1])
2221
@pytest.mark.clock_timeout(30)
23-
def test_completes_waypoint_mission_with_variable_height_steps(node, difficulty: float):
22+
def test_completes_waypoint_mission_with_variable_height_steps(
23+
node, difficulty: float, metrics: dict
24+
):
2425
"""Test that the waypoint mission completes successfully.
2526
2627
The pyramid steps height is configured via difficulty.
2728
"""
28-
run_waypoint_mission_test(node, scene="generated_pyramid", difficulty=difficulty)
29+
run_waypoint_mission_test(
30+
node, scene="generated_pyramid", difficulty=difficulty, metrics=metrics
31+
)
2932

3033

3134
@pytest.mark.parametrize("scene", ["rail_blocks", "stone_stairs", "excavator"])
3235
@pytest.mark.clock_timeout(30)
33-
def test_completes_waypoint_mission_in_photo_realistic_env(node, scene: str):
36+
def test_completes_waypoint_mission_in_photo_realistic_env(
37+
node, scene: str, metrics: dict
38+
):
3439
"""Test that the waypoint mission completes successfully."""
35-
run_waypoint_mission_test(node, scene, difficulty=1.0)
40+
run_waypoint_mission_test(node, scene, difficulty=1.0, metrics=metrics)
3641

3742

38-
def run_waypoint_mission_test(node, scene: str, difficulty: float):
43+
def run_waypoint_mission_test(node, scene: str, difficulty: float, metrics: dict):
3944
"""Run the waypoint mission test."""
4045
transforms = Transforms()
4146
node.send_output(
@@ -45,7 +50,22 @@ def run_waypoint_mission_test(node, scene: str, difficulty: float):
4550
waypoint_list: list[str] = []
4651
next_waypoint_index = 0
4752

53+
metrics_key = f"completion_time.{scene}_{difficulty}"
54+
start_time_ms = None
55+
current_time_ms = None
56+
stuck_detector = StuckDetector(max_no_progress_time=5000) # 5 seconds
57+
4858
for event in node:
59+
if event["type"] == "INPUT" and event["id"] == "clock":
60+
now = msgs.Timestamp.from_arrow(event["value"]).float_milliseconds
61+
if start_time_ms is None or now < start_time_ms:
62+
start_time_ms = now
63+
current_time_ms = now
64+
65+
# Bail if we haven't started yet
66+
if current_time_ms is None:
67+
continue
68+
4969
if event["type"] == "INPUT" and event["id"] == "waypoints":
5070
waypoint_list_msg = msgs.WaypointList.from_arrow(event["value"])
5171
waypoints = waypoint_list_msg.waypoints
@@ -60,7 +80,7 @@ def run_waypoint_mission_test(node, scene: str, difficulty: float):
6080

6181
transforms.add_transform(
6282
wp.transform,
63-
int(time.time()),
83+
int(current_time_ms),
6484
parent_frame="world",
6585
child_frame=waypoint_frame,
6686
)
@@ -71,7 +91,7 @@ def run_waypoint_mission_test(node, scene: str, difficulty: float):
7191
continue
7292

7393
transform = msgs.Transform.from_arrow(event["value"])
74-
timestamp = int(time.time())
94+
timestamp = int(current_time_ms)
7595
transforms.add_transform(
7696
transform,
7797
timestamp,
@@ -95,3 +115,13 @@ def run_waypoint_mission_test(node, scene: str, difficulty: float):
95115
else:
96116
print("All waypoints completed!")
97117
break
118+
119+
if stuck_detector.step_is_stuck(transform, timestamp):
120+
raise AssertionError(
121+
"Robot is stuck and cannot complete the waypoint mission."
122+
)
123+
124+
if start_time_ms is not None and current_time_ms is not None:
125+
elapsed_time_ms = current_time_ms - start_time_ms
126+
# Convert to seconds
127+
metrics[metrics_key] = elapsed_time_ms / 1000.0

uv.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)