robovast 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- robovast/__init__.py +0 -0
- robovast/common/__init__.py +39 -0
- robovast/common/analysis/__init__.py +20 -0
- robovast/common/analysis/common.py +353 -0
- robovast/common/analysis/ros2.py +176 -0
- robovast/common/campaign_data.py +310 -0
- robovast/common/cli/__init__.py +26 -0
- robovast/common/cli/checks.py +55 -0
- robovast/common/cli/cli.py +373 -0
- robovast/common/cli/project_config.py +235 -0
- robovast/common/cluster_context.py +266 -0
- robovast/common/common.py +173 -0
- robovast/common/config.py +219 -0
- robovast/common/config_generation.py +728 -0
- robovast/common/config_identifier.py +240 -0
- robovast/common/execution.py +754 -0
- robovast/common/file_cache.py +220 -0
- robovast/common/file_cache2.py +368 -0
- robovast/common/logging_config.py +95 -0
- robovast/common/progress.py +148 -0
- robovast/common/results_utils.py +97 -0
- robovast/common/variation/__init__.py +32 -0
- robovast/common/variation/base_variation.py +242 -0
- robovast/common/variation/base_variation_gui.py +54 -0
- robovast/common/variation/loader.py +98 -0
- robovast/common/variation/one_of_variation.py +148 -0
- robovast/common/variation/parameter_variation.py +286 -0
- robovast/configuration/__init__.py +0 -0
- robovast/configuration/configuration_utils/__init__.py +0 -0
- robovast/configuration/configuration_utils/cli.py +539 -0
- robovast/configuration/gui/__init__.py +0 -0
- robovast/configuration/gui/config_editor.py +779 -0
- robovast/configuration/gui/config_list.py +162 -0
- robovast/configuration/gui/config_view.py +147 -0
- robovast/configuration/gui/yaml_editor.py +177 -0
- robovast/configuration/gui/yaml_highlighter.py +81 -0
- robovast/evaluation/__init__.py +0 -0
- robovast/evaluation/mcp_server/__init__.py +15 -0
- robovast/evaluation/mcp_server/plugin.py +51 -0
- robovast/evaluation/mcp_server/plugin_common.py +128 -0
- robovast/evaluation/mcp_server/plugins/__init__.py +0 -0
- robovast/evaluation/mcp_server/plugins/campaign_metadata.py +391 -0
- robovast/evaluation/mcp_server/plugins/configuration_metadata.py +244 -0
- robovast/evaluation/mcp_server/plugins/docs.py +331 -0
- robovast/evaluation/mcp_server/plugins/plugin_metadata.py +261 -0
- robovast/evaluation/mcp_server/plugins/prompts.py +82 -0
- robovast/evaluation/mcp_server/plugins/run_data.py +973 -0
- robovast/evaluation/mcp_server/plugins/run_metadata.py +211 -0
- robovast/evaluation/mcp_server/plugins/search_metadata.py +454 -0
- robovast/evaluation/mcp_server/registry.py +80 -0
- robovast/evaluation/mcp_server/results_resolver.py +156 -0
- robovast/evaluation/mcp_server/server.py +146 -0
- robovast/evaluation/result_analyzer/__init__.py +0 -0
- robovast/evaluation/result_analyzer/cli.py +167 -0
- robovast/evaluation/result_analyzer/result_analyzer.py +914 -0
- robovast/evaluation/result_analyzer/widgets/__init__.py +0 -0
- robovast/evaluation/result_analyzer/widgets/common.py +92 -0
- robovast/evaluation/result_analyzer/widgets/jupyter_widget.py +893 -0
- robovast/evaluation/result_analyzer/widgets/local_execution_widget.py +357 -0
- robovast/evaluation/result_analyzer/widgets/log_viewer_widget.py +156 -0
- robovast/evaluation/result_analyzer/widgets/terminal_output_widget.py +250 -0
- robovast/evaluation/result_analyzer/widgets/worker_thread.py +242 -0
- robovast/execution/__init__.py +0 -0
- robovast/execution/cluster_config/__init__.py +0 -0
- robovast/execution/cluster_config/azure.py +191 -0
- robovast/execution/cluster_config/base_config.py +213 -0
- robovast/execution/cluster_config/gcp.py +733 -0
- robovast/execution/cluster_config/minikube.py +171 -0
- robovast/execution/cluster_config/minimal_s3.py +148 -0
- robovast/execution/cluster_config/rke2.py +203 -0
- robovast/execution/cluster_execution/__init__.py +0 -0
- robovast/execution/cluster_execution/archiver.py +475 -0
- robovast/execution/cluster_execution/bucket_ops.py +391 -0
- robovast/execution/cluster_execution/cluster_execution.py +1144 -0
- robovast/execution/cluster_execution/cluster_setup.py +412 -0
- robovast/execution/cluster_execution/gcs_to_targz.py +266 -0
- robovast/execution/cluster_execution/kubernetes.py +202 -0
- robovast/execution/cluster_execution/kubernetes_kueue.py +752 -0
- robovast/execution/cluster_execution/manifests.py +56 -0
- robovast/execution/cluster_execution/s3_client.py +515 -0
- robovast/execution/cluster_execution/s3_to_targz.py +131 -0
- robovast/execution/cluster_execution/share_providers/__init__.py +49 -0
- robovast/execution/cluster_execution/share_providers/base.py +233 -0
- robovast/execution/cluster_execution/share_providers/gcs.py +323 -0
- robovast/execution/cluster_execution/share_providers/gcs_upload_script.py +379 -0
- robovast/execution/cluster_execution/share_providers/nextcloud.py +229 -0
- robovast/execution/cluster_execution/share_providers/nextcloud_upload_script.py +192 -0
- robovast/execution/cluster_execution/share_providers/sftp.py +244 -0
- robovast/execution/cluster_execution/share_providers/sftp_upload_script.py +208 -0
- robovast/execution/cluster_execution/share_providers/webdav.py +331 -0
- robovast/execution/cluster_execution/share_providers/webdav_upload_script.py +239 -0
- robovast/execution/cluster_execution/targz_to_gcs.py +225 -0
- robovast/execution/cluster_execution/targz_to_s3.py +185 -0
- robovast/execution/cluster_execution/upload_to_share.py +429 -0
- robovast/execution/data/__init__.py +17 -0
- robovast/execution/data/collect_sysinfo.py +160 -0
- robovast/execution/data/entrypoint.sh +133 -0
- robovast/execution/data/monitor_resources.py +59 -0
- robovast/execution/data/secondary_entrypoint.sh +45 -0
- robovast/execution/execution_utils/__init__.py +0 -0
- robovast/execution/execution_utils/cli.py +1267 -0
- robovast/execution/execution_utils/execute_local.py +725 -0
- robovast/pipeline/optuna_sweep.py +231 -0
- robovast/results_processing/__init__.py +8 -0
- robovast/results_processing/cli.py +872 -0
- robovast/results_processing/data/__init__.py +17 -0
- robovast/results_processing/data/docker_exec.sh +142 -0
- robovast/results_processing/data/ros2_exec.sh +32 -0
- robovast/results_processing/data/rosbags_common.py +134 -0
- robovast/results_processing/data/rosbags_process.py +1015 -0
- robovast/results_processing/fair_metadata.py +816 -0
- robovast/results_processing/fair_metadata.py.orig +751 -0
- robovast/results_processing/fair_metadata_BACKUP_1981149.py +718 -0
- robovast/results_processing/fair_metadata_BASE_1981149.py +728 -0
- robovast/results_processing/fair_metadata_LOCAL_1981149.py +730 -0
- robovast/results_processing/fair_metadata_REMOTE_1981149.py +649 -0
- robovast/results_processing/merge_results.py +193 -0
- robovast/results_processing/metadata.py +596 -0
- robovast/results_processing/postprocessing.py +548 -0
- robovast/results_processing/postprocessing_plugins.py +672 -0
- robovast/results_processing/publication.py +315 -0
- robovast/results_processing/publication_plugins/__init__.py +21 -0
- robovast/results_processing/publication_plugins/base.py +105 -0
- robovast/results_processing/publication_plugins/zenodo.py +762 -0
- robovast/results_processing/publication_plugins/zip.py +431 -0
- robovast/sphinx_ext/__init__.py +1 -0
- robovast-1.0.0.dist-info/LICENSE +201 -0
- robovast-1.0.0.dist-info/METADATA +54 -0
- robovast-1.0.0.dist-info/RECORD +131 -0
- robovast-1.0.0.dist-info/WHEEL +4 -0
- robovast-1.0.0.dist-info/entry_points.txt +45 -0
robovast/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from .common import (convert_dataclasses_to_dict, filter_configs,
|
|
4
|
+
get_scenario_parameters, is_scenario_parameter,
|
|
5
|
+
load_config)
|
|
6
|
+
from .config import (VariationConfig, get_validated_config,
|
|
7
|
+
normalize_secondary_containers)
|
|
8
|
+
from .config_generation import execute_variation, generate_scenario_variations
|
|
9
|
+
from .execution import (COMPAT_VERSION, create_execution_yaml,
|
|
10
|
+
generate_execution_yaml_script, get_campaign,
|
|
11
|
+
get_campaign_timestamp, get_execution_env_variables,
|
|
12
|
+
is_campaign_dir, prepare_campaign_configs)
|
|
13
|
+
from .file_cache import FileCache
|
|
14
|
+
from .progress import ProgressBar, fmt_size, make_download_progress_callback
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
'ProgressBar',
|
|
18
|
+
'fmt_size',
|
|
19
|
+
'make_download_progress_callback',
|
|
20
|
+
'FileCache',
|
|
21
|
+
'generate_scenario_variations',
|
|
22
|
+
'load_config',
|
|
23
|
+
'execute_variation',
|
|
24
|
+
'convert_dataclasses_to_dict',
|
|
25
|
+
'prepare_campaign_configs',
|
|
26
|
+
'get_execution_env_variables',
|
|
27
|
+
'VariationConfig',
|
|
28
|
+
'get_validated_config',
|
|
29
|
+
'filter_configs',
|
|
30
|
+
'get_scenario_parameters',
|
|
31
|
+
'is_scenario_parameter',
|
|
32
|
+
'get_campaign',
|
|
33
|
+
'get_campaign_timestamp',
|
|
34
|
+
'is_campaign_dir',
|
|
35
|
+
'generate_execution_yaml_script',
|
|
36
|
+
'create_execution_yaml',
|
|
37
|
+
'normalize_secondary_containers',
|
|
38
|
+
'COMPAT_VERSION',
|
|
39
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from .common import (for_each_run, get_run_status, get_scenario_parameter,
|
|
4
|
+
read_output_csv, read_output_files, read_output_yaml_list,
|
|
5
|
+
read_run_statuses)
|
|
6
|
+
from .ros2 import (calculate_speeds_from_poses, get_behavior_info,
|
|
7
|
+
print_bag_topics)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'read_output_files',
|
|
11
|
+
'read_output_csv',
|
|
12
|
+
'read_output_yaml_list',
|
|
13
|
+
'read_run_statuses',
|
|
14
|
+
'get_run_status',
|
|
15
|
+
'for_each_run',
|
|
16
|
+
'print_bag_topics',
|
|
17
|
+
'get_behavior_info',
|
|
18
|
+
'calculate_speeds_from_poses',
|
|
19
|
+
'get_scenario_parameter',
|
|
20
|
+
]
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copyright (C) 2025 Frederik Pasch
|
|
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,
|
|
11
|
+
# software 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
|
|
14
|
+
# and limitations under the License.
|
|
15
|
+
#
|
|
16
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import xml.etree.ElementTree as ET
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Callable, Optional, Union
|
|
22
|
+
|
|
23
|
+
import pandas as pd
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_scenario_parameter(data_dir: str, parameter_name: str):
|
|
28
|
+
"""
|
|
29
|
+
Get a specific scenario parameter from the scenario configuration file.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
data_dir (str): Path to the directory containing the scenario.config file.
|
|
33
|
+
parameter_name (str): Name of the parameter to retrieve.
|
|
34
|
+
Returns:
|
|
35
|
+
The value of the specified parameter, or None if not found.
|
|
36
|
+
"""
|
|
37
|
+
config_path = Path(data_dir) / "_config" / "scenario.config"
|
|
38
|
+
if not config_path.exists():
|
|
39
|
+
raise FileNotFoundError(f"scenario.config not found in {data_dir}")
|
|
40
|
+
|
|
41
|
+
with open(config_path, 'r') as f:
|
|
42
|
+
config_content = yaml.safe_load(f)
|
|
43
|
+
|
|
44
|
+
# skip scenario-name
|
|
45
|
+
if isinstance(config_content, dict) and len(config_content) == 1:
|
|
46
|
+
config_content = next(iter(config_content.values()))
|
|
47
|
+
|
|
48
|
+
return config_content.get(parameter_name, None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_output_files(data_dir: str, reader_func: Callable[[Path], pd.DataFrame], debug: bool = False) -> pd.DataFrame:
|
|
52
|
+
"""
|
|
53
|
+
Reads and combines output data from all run subdirectories within data_dir.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
data_dir (str): Path to the directory containing run subdirectories.
|
|
57
|
+
reader_func (Callable[[Path], pd.DataFrame]): Function that reads run data from a directory and returns a DataFrame.
|
|
58
|
+
debug (bool, optional): If True, prints debug information. Defaults to False.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
pd.DataFrame: Combined DataFrame containing all run data, with additional columns for run, config, and scenario parameters.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If data_dir does not exist, no test.xml files are found, or no valid run data could be read.
|
|
65
|
+
"""
|
|
66
|
+
data_path = Path(data_dir)
|
|
67
|
+
|
|
68
|
+
if not data_path.exists():
|
|
69
|
+
raise ValueError(f"Data directory does not exist: {data_dir}")
|
|
70
|
+
|
|
71
|
+
all_dataframes = []
|
|
72
|
+
skipped_warnings = []
|
|
73
|
+
|
|
74
|
+
# Find all test.xml files in subdirectories
|
|
75
|
+
run_xml_files = list(data_path.rglob("test.xml"))
|
|
76
|
+
|
|
77
|
+
if not run_xml_files:
|
|
78
|
+
raise ValueError(f"No test.xml files found in subdirectories of {data_dir}")
|
|
79
|
+
|
|
80
|
+
if debug:
|
|
81
|
+
print(f"Found {len(run_xml_files)} run directories")
|
|
82
|
+
|
|
83
|
+
category_names = set({'run', 'config'})
|
|
84
|
+
for run_xml in run_xml_files:
|
|
85
|
+
if debug:
|
|
86
|
+
print(f"Reading data from: {run_xml}")
|
|
87
|
+
run_dir = run_xml.parent
|
|
88
|
+
run_name = run_dir.name
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Call the user-provided reader function
|
|
92
|
+
if reader_func:
|
|
93
|
+
df = reader_func(run_dir)
|
|
94
|
+
else:
|
|
95
|
+
df = pd.DataFrame()
|
|
96
|
+
scenario_config_path = run_dir.parent / "_config" / "scenario.config"
|
|
97
|
+
config_parameters = {}
|
|
98
|
+
try:
|
|
99
|
+
with open(scenario_config_path, 'r') as f:
|
|
100
|
+
config_content = yaml.safe_load(f)
|
|
101
|
+
|
|
102
|
+
# skip scenario-name
|
|
103
|
+
if isinstance(config_content, dict) and len(config_content) == 1:
|
|
104
|
+
config_parameters = next(iter(config_content.values()))
|
|
105
|
+
except Exception as e:
|
|
106
|
+
if debug:
|
|
107
|
+
print(f"Could not read scenario.config: {e}\n")
|
|
108
|
+
|
|
109
|
+
df['run'] = str(run_name)
|
|
110
|
+
df['config'] = str(os.path.basename(run_dir.parent))
|
|
111
|
+
category_names.update(config_parameters.keys())
|
|
112
|
+
for param_name, param_value in config_parameters.items():
|
|
113
|
+
if isinstance(param_value, (dict, list)):
|
|
114
|
+
df[param_name] = yaml.safe_dump(param_value)
|
|
115
|
+
else:
|
|
116
|
+
df[param_name] = param_value
|
|
117
|
+
|
|
118
|
+
all_dataframes.append(df)
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
skipped_warnings.append(f"{run_dir}: {e}")
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
if skipped_warnings and debug:
|
|
125
|
+
print(f"Warning: Could not read data from {len(skipped_warnings)} run(s): " +
|
|
126
|
+
"; ".join(skipped_warnings))
|
|
127
|
+
|
|
128
|
+
if debug and not all_dataframes:
|
|
129
|
+
raise ValueError(f"No valid run data could be read from {data_dir}")
|
|
130
|
+
|
|
131
|
+
# Combine all dataframes
|
|
132
|
+
combined_df = pd.concat(all_dataframes, ignore_index=True)
|
|
133
|
+
|
|
134
|
+
for category in category_names:
|
|
135
|
+
combined_df[category] = combined_df[category].astype('category')
|
|
136
|
+
|
|
137
|
+
if debug:
|
|
138
|
+
print(f"Combined dataframe shape: {combined_df.shape}")
|
|
139
|
+
print(f"Columns: {list(combined_df.columns)}")
|
|
140
|
+
print(f"Number of unique runs: {combined_df['run'].nunique()}")
|
|
141
|
+
|
|
142
|
+
return combined_df
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_output_csv(run_dir: str, filename: str, skiprows: int = 0) -> pd.DataFrame:
|
|
146
|
+
"""
|
|
147
|
+
Read a CSV file from a run directory, skipping the first line (comment).
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
run_dir: Path to the run directory as a string
|
|
151
|
+
filename: Name of the CSV file to read
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
DataFrame with the CSV data
|
|
155
|
+
"""
|
|
156
|
+
csv_path = os.path.join(run_dir, filename)
|
|
157
|
+
if not os.path.exists(csv_path):
|
|
158
|
+
raise FileNotFoundError(f"{filename} not found in {run_dir}")
|
|
159
|
+
|
|
160
|
+
# Read CSV, skipping the first line (comment)
|
|
161
|
+
df = pd.read_csv(Path(csv_path), skiprows=skiprows)
|
|
162
|
+
return df
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _flatten_value(value, key: str, level: int, merge_level: int) -> dict:
|
|
166
|
+
"""Flatten a value (dict, list, or scalar) into top-level key-value pairs."""
|
|
167
|
+
if isinstance(value, dict) and level < merge_level:
|
|
168
|
+
result = {}
|
|
169
|
+
for k, v in value.items():
|
|
170
|
+
subkey = f"{key}_{k}" if key else k
|
|
171
|
+
result.update(_flatten_value(v, subkey, level + 1, merge_level))
|
|
172
|
+
return result
|
|
173
|
+
elif isinstance(value, list) and level < merge_level:
|
|
174
|
+
result = {}
|
|
175
|
+
for i, elem in enumerate(value):
|
|
176
|
+
subkey = f"{key}_{i}" if key else str(i)
|
|
177
|
+
result.update(_flatten_value(elem, subkey, level + 1, merge_level))
|
|
178
|
+
return result
|
|
179
|
+
else:
|
|
180
|
+
return {key: value}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _flatten_item_for_merge(item: dict, prefix: str, level: int, merge_level: int) -> dict:
|
|
184
|
+
"""Flatten nested dict/list values into top-level keys, up to merge_level depth."""
|
|
185
|
+
result = {}
|
|
186
|
+
for k, v in item.items():
|
|
187
|
+
key = f"{prefix}_{k}" if prefix else k
|
|
188
|
+
result.update(_flatten_value(v, key, level, merge_level))
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def read_output_yaml_list(
|
|
193
|
+
run_dir: str, filename: str, list_key: str, merge_level: int = 0
|
|
194
|
+
) -> pd.DataFrame:
|
|
195
|
+
"""
|
|
196
|
+
Read a YAML file from a run directory where the specified key contains a list
|
|
197
|
+
of records (e.g. feedback messages with feedback, goal_id, timestamp per item).
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
run_dir: Path to the run directory
|
|
201
|
+
filename: Name of the YAML file
|
|
202
|
+
list_key: Key that holds the list (e.g. "feedback")
|
|
203
|
+
merge_level: How many levels of nested dicts/lists to flatten into columns.
|
|
204
|
+
0 = keep as-is. 1 = flatten one level (e.g. feedback dict becomes
|
|
205
|
+
feedback_current_pose, feedback_distance_remaining, ...). Lists
|
|
206
|
+
become key_0, key_1, etc. 2 = flatten two levels, etc.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
DataFrame with one row per list item, columns from each item's keys
|
|
210
|
+
"""
|
|
211
|
+
yaml_path = os.path.join(run_dir, filename)
|
|
212
|
+
if not os.path.exists(yaml_path):
|
|
213
|
+
raise FileNotFoundError(f"{filename} not found in {run_dir}")
|
|
214
|
+
with open(yaml_path, 'r') as f:
|
|
215
|
+
yaml_data = yaml.safe_load(f)
|
|
216
|
+
items = yaml_data[list_key]
|
|
217
|
+
if not isinstance(items, list):
|
|
218
|
+
raise ValueError(f"Key '{list_key}' does not contain a list, got {type(items)}")
|
|
219
|
+
if merge_level > 0:
|
|
220
|
+
items = [_flatten_item_for_merge(item, "", 0, merge_level) for item in items]
|
|
221
|
+
return pd.DataFrame(items)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _get_failure_text(root) -> str:
|
|
225
|
+
"""Get failure element text from a parsed test.xml root element."""
|
|
226
|
+
for testcase in root.iter('testcase'):
|
|
227
|
+
failure = testcase.find('failure')
|
|
228
|
+
if failure is not None:
|
|
229
|
+
return failure.text or failure.get('message', '') or ''
|
|
230
|
+
return ''
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _extract_failure_summary(failure_text: str) -> Optional[str]:
|
|
234
|
+
"""Extract a short summary from a failure message.
|
|
235
|
+
|
|
236
|
+
Algorithm: last '[✕] -- ' -> text after on that line;
|
|
237
|
+
else last '[✓] -- ' -> text after on that line;
|
|
238
|
+
if single-line message and no marker found -> take it completely.
|
|
239
|
+
"""
|
|
240
|
+
if not failure_text:
|
|
241
|
+
return None
|
|
242
|
+
for marker in ("[✕] -- ", "[✓] -- "):
|
|
243
|
+
idx = failure_text.rfind(marker)
|
|
244
|
+
if idx >= 0:
|
|
245
|
+
start = idx + len(marker)
|
|
246
|
+
end = failure_text.find("\n", start)
|
|
247
|
+
rest = failure_text[start:end] if end >= 0 else failure_text[start:]
|
|
248
|
+
s = rest.strip()
|
|
249
|
+
return s if s else None
|
|
250
|
+
if "\n" not in failure_text:
|
|
251
|
+
s = failure_text.strip()
|
|
252
|
+
return s if s else None
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def get_run_status(run_dir: Union[str, Path]) -> tuple:
|
|
257
|
+
"""Get the pass/fail status of a single run from its test.xml file.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
run_dir: Path to the run directory (must contain a ``test.xml`` file).
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
A ``(status, summary)`` tuple where *status* is ``'passed'``,
|
|
264
|
+
``'failed'``, or ``'unknown'``, and *summary* is a short descriptive
|
|
265
|
+
string taken from the failure message (or ``None`` when not applicable).
|
|
266
|
+
"""
|
|
267
|
+
run_xml_path = Path(run_dir) / "test.xml"
|
|
268
|
+
if not run_xml_path.exists():
|
|
269
|
+
return "unknown", None
|
|
270
|
+
try:
|
|
271
|
+
tree = ET.parse(run_xml_path)
|
|
272
|
+
root = tree.getroot()
|
|
273
|
+
testsuite = root if root.tag == 'testsuite' else root.find('testsuite')
|
|
274
|
+
if testsuite is not None:
|
|
275
|
+
errors = int(testsuite.get('errors', 0))
|
|
276
|
+
failures = int(testsuite.get('failures', 0))
|
|
277
|
+
status = "passed" if (errors == 0 and failures == 0) else "failed"
|
|
278
|
+
summary = None
|
|
279
|
+
if status == "failed":
|
|
280
|
+
summary = _extract_failure_summary(_get_failure_text(root))
|
|
281
|
+
return status, summary
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
return "unknown", None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def read_run_statuses(data_dir: str) -> pd.DataFrame:
|
|
288
|
+
"""Read pass/fail status for every run in *data_dir*.
|
|
289
|
+
|
|
290
|
+
Iterates the same ``test.xml``-based run directories used by
|
|
291
|
+
:func:`read_output_files` and returns a tidy DataFrame.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
data_dir: Path to the results directory (campaign root or config root).
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
DataFrame with columns ``run``, ``config``, ``status``
|
|
298
|
+
(``'passed'`` | ``'failed'`` | ``'unknown'``), and ``summary``
|
|
299
|
+
(short failure description or ``None``).
|
|
300
|
+
"""
|
|
301
|
+
data_path = Path(data_dir)
|
|
302
|
+
if not data_path.exists():
|
|
303
|
+
raise ValueError(f"Data directory does not exist: {data_dir}")
|
|
304
|
+
|
|
305
|
+
rows = []
|
|
306
|
+
for run_xml in sorted(data_path.rglob("test.xml")):
|
|
307
|
+
run_dir = run_xml.parent
|
|
308
|
+
status, summary = get_run_status(run_dir)
|
|
309
|
+
rows.append({
|
|
310
|
+
'run': run_dir.name,
|
|
311
|
+
'config': os.path.basename(run_dir.parent),
|
|
312
|
+
'status': status,
|
|
313
|
+
'summary': summary,
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
return pd.DataFrame(rows)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def for_each_run(data_dir: str, func: Callable[[Path], None], debug=False) -> None:
|
|
320
|
+
"""
|
|
321
|
+
Applies a given function to each run directory within data_dir.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
data_dir (str): Path to the directory containing run subdirectories.
|
|
325
|
+
func (Callable[[Path], None]): Function to apply to each run directory.
|
|
326
|
+
debug (bool, optional): If True, prints debug information. Defaults to False.
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
ValueError: If data_dir does not exist or no test.xml files are found.
|
|
330
|
+
"""
|
|
331
|
+
data_path = Path(data_dir)
|
|
332
|
+
|
|
333
|
+
if not data_path.exists():
|
|
334
|
+
raise ValueError(f"Data directory does not exist: {data_dir}")
|
|
335
|
+
|
|
336
|
+
# Find all test.xml files in subdirectories
|
|
337
|
+
run_xml_files = list(data_path.rglob("test.xml"))
|
|
338
|
+
|
|
339
|
+
if not run_xml_files:
|
|
340
|
+
raise ValueError(f"No test.xml files found in subdirectories of {data_dir}")
|
|
341
|
+
|
|
342
|
+
if debug:
|
|
343
|
+
print(f"Found {len(run_xml_files)} run directories")
|
|
344
|
+
|
|
345
|
+
for run_xml in run_xml_files:
|
|
346
|
+
run_dir = run_xml.parent
|
|
347
|
+
if debug:
|
|
348
|
+
print(f"Applying function to: {run_dir}")
|
|
349
|
+
try:
|
|
350
|
+
func(run_dir)
|
|
351
|
+
except Exception as e:
|
|
352
|
+
print(f"Warning: Could not apply function to {run_dir}: {e}")
|
|
353
|
+
continue
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_bag_info(bag_path: str) -> dict:
|
|
9
|
+
"""
|
|
10
|
+
Extracts information from a ROS2 bag file.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
bag_path (str): Path to the ROS2 bag file.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
dict: A dictionary containing the extracted bag information.
|
|
17
|
+
"""
|
|
18
|
+
rosbag2_metadata_path = os.path.join(bag_path, "metadata.yaml")
|
|
19
|
+
bag_info = {}
|
|
20
|
+
if os.path.exists(rosbag2_metadata_path):
|
|
21
|
+
try:
|
|
22
|
+
with open(rosbag2_metadata_path, 'r') as f:
|
|
23
|
+
bag_info = yaml.safe_load(f)
|
|
24
|
+
except Exception as e:
|
|
25
|
+
print(f"Error reading bag metadata file {rosbag2_metadata_path}: {e}")
|
|
26
|
+
else:
|
|
27
|
+
print(f"Bag metadata file does not exist: {rosbag2_metadata_path}")
|
|
28
|
+
return bag_info
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def print_bag_topics(bag_path: str, bag_dir_name: str = "rosbag2"):
|
|
32
|
+
"""
|
|
33
|
+
Retrieves the list of topics from a ROS2 bag file.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
bag_path (str): Path to the ROS2 bag file.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
list: A list of topic names.
|
|
40
|
+
"""
|
|
41
|
+
bag_info = get_bag_info(os.path.join(bag_path, bag_dir_name))
|
|
42
|
+
if not bag_info:
|
|
43
|
+
raise ValueError(f"Could not retrieve bag info for path: {bag_path}")
|
|
44
|
+
if 'rosbag2_bagfile_information' not in bag_info and 'topics_with_message_count' not in bag_info['rosbag2_bagfile_information']:
|
|
45
|
+
raise ValueError(f"Invalid bag info format for path: {bag_path}")
|
|
46
|
+
|
|
47
|
+
topics = bag_info['rosbag2_bagfile_information']['topics_with_message_count']
|
|
48
|
+
print(f"# Topics in bag at {bag_path}:")
|
|
49
|
+
for topic in topics:
|
|
50
|
+
metadata = topic.get('topic_metadata', {})
|
|
51
|
+
topic_name = metadata.get('name', 'unknown')
|
|
52
|
+
topic_type = metadata.get('type', 'unknown')
|
|
53
|
+
topic_message_count = topic.get('message_count', 0)
|
|
54
|
+
print(f" - Topic: {topic_name}, Type: {topic_type}, Message Count: {topic_message_count}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_behavior_info(behavior_name: str, behavior_dataframe: pd.DataFrame):
|
|
58
|
+
"""
|
|
59
|
+
Retrieves information for each instance of a specified behavior.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
behavior_name (str): The name of the behavior to filter.
|
|
63
|
+
behavior_dataframe (pd.DataFrame): DataFrame containing columns: timestamp, behavior_name, behavior_id, status_name,
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
pd.DataFrame: DataFrame with columns: behavior_name, id, start_time, end_time, duration
|
|
67
|
+
"""
|
|
68
|
+
behavior_df = behavior_dataframe[behavior_dataframe['behavior_name'] == behavior_name].copy()
|
|
69
|
+
|
|
70
|
+
if behavior_df.empty:
|
|
71
|
+
cols = ['behavior_name', 'id', 'duration', 'run', 'config']
|
|
72
|
+
return pd.DataFrame(columns=cols)
|
|
73
|
+
|
|
74
|
+
results = []
|
|
75
|
+
|
|
76
|
+
# Group by behavior_id, run, and config to handle multiple instances and configs
|
|
77
|
+
group_cols = ['behavior_id', 'run', 'config']
|
|
78
|
+
|
|
79
|
+
for group_keys, group in behavior_df.groupby(group_cols, observed=False):
|
|
80
|
+
# Unpack group_keys depending on grouping columns
|
|
81
|
+
if len(group_cols) == 1:
|
|
82
|
+
behavior_id = group_keys
|
|
83
|
+
run = group['run'].iloc[0] if 'run' in behavior_df.columns else None
|
|
84
|
+
config = group['config'].iloc[0] if 'config' in behavior_df.columns else None
|
|
85
|
+
elif len(group_cols) == 2:
|
|
86
|
+
behavior_id, run = group_keys
|
|
87
|
+
config = group['config'].iloc[0] if 'config' in behavior_df.columns else None
|
|
88
|
+
else:
|
|
89
|
+
behavior_id, run, config = group_keys
|
|
90
|
+
|
|
91
|
+
# Find first RUNNING timestamp
|
|
92
|
+
start_rows = group[group['status_name'] == 'RUNNING'].sort_values('timestamp')
|
|
93
|
+
if start_rows.empty:
|
|
94
|
+
continue # RUNNING not found, skip this behavior instance
|
|
95
|
+
|
|
96
|
+
start_time = start_rows.iloc[0]['timestamp']
|
|
97
|
+
|
|
98
|
+
# Find first SUCCESS or FAILURE timestamp after start
|
|
99
|
+
end_rows = group[group['status_name'].isin(['SUCCESS', 'FAILURE'])].sort_values('timestamp')
|
|
100
|
+
if end_rows.empty:
|
|
101
|
+
continue # No terminal state found, skip this behavior instance
|
|
102
|
+
|
|
103
|
+
end_time = end_rows.iloc[0]['timestamp']
|
|
104
|
+
|
|
105
|
+
record = {
|
|
106
|
+
'behavior_name': behavior_name,
|
|
107
|
+
'id': behavior_id,
|
|
108
|
+
'start_time': start_time,
|
|
109
|
+
'end_time': end_time,
|
|
110
|
+
'duration': end_time - start_time,
|
|
111
|
+
'run': run,
|
|
112
|
+
'config': config
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
results.append(record)
|
|
116
|
+
|
|
117
|
+
return pd.DataFrame(results)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def calculate_speeds_from_poses(df_groundtruth):
|
|
121
|
+
# Calculate linear and angular speeds from ground truth data
|
|
122
|
+
group_cols = ['run', 'config']
|
|
123
|
+
min_dt = 1e-6
|
|
124
|
+
|
|
125
|
+
result_dfs = []
|
|
126
|
+
|
|
127
|
+
for _, group in df_groundtruth.groupby(group_cols, observed=False):
|
|
128
|
+
# Need at least 2 data points to calculate speeds
|
|
129
|
+
if len(group) < 2:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
df_gt_speeds = group[['run', 'config', 'position.x', 'position.y',
|
|
133
|
+
'orientation.yaw', 'timestamp']].copy()
|
|
134
|
+
|
|
135
|
+
# Calculate time differences (dt)
|
|
136
|
+
dt = np.diff(df_gt_speeds['timestamp'].values)
|
|
137
|
+
|
|
138
|
+
# Calculate position changes
|
|
139
|
+
dx = np.diff(df_gt_speeds['position.x'].values)
|
|
140
|
+
dy = np.diff(df_gt_speeds['position.y'].values)
|
|
141
|
+
|
|
142
|
+
# Calculate yaw changes for angular speed
|
|
143
|
+
dyaw = np.diff(df_gt_speeds['orientation.yaw'].values)
|
|
144
|
+
# Normalize angle differences to [-pi, pi]
|
|
145
|
+
dyaw = np.arctan2(np.sin(dyaw), np.cos(dyaw))
|
|
146
|
+
|
|
147
|
+
# Filter out very small time differences to avoid division issues
|
|
148
|
+
valid_mask = dt > min_dt
|
|
149
|
+
|
|
150
|
+
# Calculate speeds only for valid time differences
|
|
151
|
+
linear_speed = np.zeros_like(dt)
|
|
152
|
+
angular_speed = np.zeros_like(dt)
|
|
153
|
+
|
|
154
|
+
linear_speed[valid_mask] = np.sqrt(dx[valid_mask]**2 + dy[valid_mask]**2) / dt[valid_mask]
|
|
155
|
+
angular_speed[valid_mask] = dyaw[valid_mask] / dt[valid_mask]
|
|
156
|
+
|
|
157
|
+
# Add speeds to dataframe (shift by 1 since diff reduces length by 1)
|
|
158
|
+
df_gt_speeds['linear_speed'] = np.append(linear_speed, np.nan)
|
|
159
|
+
df_gt_speeds['angular_speed'] = np.append(angular_speed, np.nan)
|
|
160
|
+
df_gt_speeds['dt'] = np.append(dt, np.nan)
|
|
161
|
+
|
|
162
|
+
# Remove the last row with NaN values and rows with invalid dt
|
|
163
|
+
df_gt_speeds = df_gt_speeds[:-1].copy()
|
|
164
|
+
df_gt_speeds = df_gt_speeds[df_gt_speeds['dt'] > min_dt].copy()
|
|
165
|
+
|
|
166
|
+
# Only add if we have valid data remaining
|
|
167
|
+
if not df_gt_speeds.empty:
|
|
168
|
+
result_dfs.append(df_gt_speeds)
|
|
169
|
+
|
|
170
|
+
# Return empty dataframe with correct columns if no valid data
|
|
171
|
+
if not result_dfs:
|
|
172
|
+
return pd.DataFrame(columns=['run', 'config', 'position.x', 'position.y',
|
|
173
|
+
'orientation.yaw', 'timestamp', 'linear_speed',
|
|
174
|
+
'angular_speed', 'dt'])
|
|
175
|
+
|
|
176
|
+
return pd.concat(result_dfs, ignore_index=True)
|