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.
Files changed (131) hide show
  1. robovast/__init__.py +0 -0
  2. robovast/common/__init__.py +39 -0
  3. robovast/common/analysis/__init__.py +20 -0
  4. robovast/common/analysis/common.py +353 -0
  5. robovast/common/analysis/ros2.py +176 -0
  6. robovast/common/campaign_data.py +310 -0
  7. robovast/common/cli/__init__.py +26 -0
  8. robovast/common/cli/checks.py +55 -0
  9. robovast/common/cli/cli.py +373 -0
  10. robovast/common/cli/project_config.py +235 -0
  11. robovast/common/cluster_context.py +266 -0
  12. robovast/common/common.py +173 -0
  13. robovast/common/config.py +219 -0
  14. robovast/common/config_generation.py +728 -0
  15. robovast/common/config_identifier.py +240 -0
  16. robovast/common/execution.py +754 -0
  17. robovast/common/file_cache.py +220 -0
  18. robovast/common/file_cache2.py +368 -0
  19. robovast/common/logging_config.py +95 -0
  20. robovast/common/progress.py +148 -0
  21. robovast/common/results_utils.py +97 -0
  22. robovast/common/variation/__init__.py +32 -0
  23. robovast/common/variation/base_variation.py +242 -0
  24. robovast/common/variation/base_variation_gui.py +54 -0
  25. robovast/common/variation/loader.py +98 -0
  26. robovast/common/variation/one_of_variation.py +148 -0
  27. robovast/common/variation/parameter_variation.py +286 -0
  28. robovast/configuration/__init__.py +0 -0
  29. robovast/configuration/configuration_utils/__init__.py +0 -0
  30. robovast/configuration/configuration_utils/cli.py +539 -0
  31. robovast/configuration/gui/__init__.py +0 -0
  32. robovast/configuration/gui/config_editor.py +779 -0
  33. robovast/configuration/gui/config_list.py +162 -0
  34. robovast/configuration/gui/config_view.py +147 -0
  35. robovast/configuration/gui/yaml_editor.py +177 -0
  36. robovast/configuration/gui/yaml_highlighter.py +81 -0
  37. robovast/evaluation/__init__.py +0 -0
  38. robovast/evaluation/mcp_server/__init__.py +15 -0
  39. robovast/evaluation/mcp_server/plugin.py +51 -0
  40. robovast/evaluation/mcp_server/plugin_common.py +128 -0
  41. robovast/evaluation/mcp_server/plugins/__init__.py +0 -0
  42. robovast/evaluation/mcp_server/plugins/campaign_metadata.py +391 -0
  43. robovast/evaluation/mcp_server/plugins/configuration_metadata.py +244 -0
  44. robovast/evaluation/mcp_server/plugins/docs.py +331 -0
  45. robovast/evaluation/mcp_server/plugins/plugin_metadata.py +261 -0
  46. robovast/evaluation/mcp_server/plugins/prompts.py +82 -0
  47. robovast/evaluation/mcp_server/plugins/run_data.py +973 -0
  48. robovast/evaluation/mcp_server/plugins/run_metadata.py +211 -0
  49. robovast/evaluation/mcp_server/plugins/search_metadata.py +454 -0
  50. robovast/evaluation/mcp_server/registry.py +80 -0
  51. robovast/evaluation/mcp_server/results_resolver.py +156 -0
  52. robovast/evaluation/mcp_server/server.py +146 -0
  53. robovast/evaluation/result_analyzer/__init__.py +0 -0
  54. robovast/evaluation/result_analyzer/cli.py +167 -0
  55. robovast/evaluation/result_analyzer/result_analyzer.py +914 -0
  56. robovast/evaluation/result_analyzer/widgets/__init__.py +0 -0
  57. robovast/evaluation/result_analyzer/widgets/common.py +92 -0
  58. robovast/evaluation/result_analyzer/widgets/jupyter_widget.py +893 -0
  59. robovast/evaluation/result_analyzer/widgets/local_execution_widget.py +357 -0
  60. robovast/evaluation/result_analyzer/widgets/log_viewer_widget.py +156 -0
  61. robovast/evaluation/result_analyzer/widgets/terminal_output_widget.py +250 -0
  62. robovast/evaluation/result_analyzer/widgets/worker_thread.py +242 -0
  63. robovast/execution/__init__.py +0 -0
  64. robovast/execution/cluster_config/__init__.py +0 -0
  65. robovast/execution/cluster_config/azure.py +191 -0
  66. robovast/execution/cluster_config/base_config.py +213 -0
  67. robovast/execution/cluster_config/gcp.py +733 -0
  68. robovast/execution/cluster_config/minikube.py +171 -0
  69. robovast/execution/cluster_config/minimal_s3.py +148 -0
  70. robovast/execution/cluster_config/rke2.py +203 -0
  71. robovast/execution/cluster_execution/__init__.py +0 -0
  72. robovast/execution/cluster_execution/archiver.py +475 -0
  73. robovast/execution/cluster_execution/bucket_ops.py +391 -0
  74. robovast/execution/cluster_execution/cluster_execution.py +1144 -0
  75. robovast/execution/cluster_execution/cluster_setup.py +412 -0
  76. robovast/execution/cluster_execution/gcs_to_targz.py +266 -0
  77. robovast/execution/cluster_execution/kubernetes.py +202 -0
  78. robovast/execution/cluster_execution/kubernetes_kueue.py +752 -0
  79. robovast/execution/cluster_execution/manifests.py +56 -0
  80. robovast/execution/cluster_execution/s3_client.py +515 -0
  81. robovast/execution/cluster_execution/s3_to_targz.py +131 -0
  82. robovast/execution/cluster_execution/share_providers/__init__.py +49 -0
  83. robovast/execution/cluster_execution/share_providers/base.py +233 -0
  84. robovast/execution/cluster_execution/share_providers/gcs.py +323 -0
  85. robovast/execution/cluster_execution/share_providers/gcs_upload_script.py +379 -0
  86. robovast/execution/cluster_execution/share_providers/nextcloud.py +229 -0
  87. robovast/execution/cluster_execution/share_providers/nextcloud_upload_script.py +192 -0
  88. robovast/execution/cluster_execution/share_providers/sftp.py +244 -0
  89. robovast/execution/cluster_execution/share_providers/sftp_upload_script.py +208 -0
  90. robovast/execution/cluster_execution/share_providers/webdav.py +331 -0
  91. robovast/execution/cluster_execution/share_providers/webdav_upload_script.py +239 -0
  92. robovast/execution/cluster_execution/targz_to_gcs.py +225 -0
  93. robovast/execution/cluster_execution/targz_to_s3.py +185 -0
  94. robovast/execution/cluster_execution/upload_to_share.py +429 -0
  95. robovast/execution/data/__init__.py +17 -0
  96. robovast/execution/data/collect_sysinfo.py +160 -0
  97. robovast/execution/data/entrypoint.sh +133 -0
  98. robovast/execution/data/monitor_resources.py +59 -0
  99. robovast/execution/data/secondary_entrypoint.sh +45 -0
  100. robovast/execution/execution_utils/__init__.py +0 -0
  101. robovast/execution/execution_utils/cli.py +1267 -0
  102. robovast/execution/execution_utils/execute_local.py +725 -0
  103. robovast/pipeline/optuna_sweep.py +231 -0
  104. robovast/results_processing/__init__.py +8 -0
  105. robovast/results_processing/cli.py +872 -0
  106. robovast/results_processing/data/__init__.py +17 -0
  107. robovast/results_processing/data/docker_exec.sh +142 -0
  108. robovast/results_processing/data/ros2_exec.sh +32 -0
  109. robovast/results_processing/data/rosbags_common.py +134 -0
  110. robovast/results_processing/data/rosbags_process.py +1015 -0
  111. robovast/results_processing/fair_metadata.py +816 -0
  112. robovast/results_processing/fair_metadata.py.orig +751 -0
  113. robovast/results_processing/fair_metadata_BACKUP_1981149.py +718 -0
  114. robovast/results_processing/fair_metadata_BASE_1981149.py +728 -0
  115. robovast/results_processing/fair_metadata_LOCAL_1981149.py +730 -0
  116. robovast/results_processing/fair_metadata_REMOTE_1981149.py +649 -0
  117. robovast/results_processing/merge_results.py +193 -0
  118. robovast/results_processing/metadata.py +596 -0
  119. robovast/results_processing/postprocessing.py +548 -0
  120. robovast/results_processing/postprocessing_plugins.py +672 -0
  121. robovast/results_processing/publication.py +315 -0
  122. robovast/results_processing/publication_plugins/__init__.py +21 -0
  123. robovast/results_processing/publication_plugins/base.py +105 -0
  124. robovast/results_processing/publication_plugins/zenodo.py +762 -0
  125. robovast/results_processing/publication_plugins/zip.py +431 -0
  126. robovast/sphinx_ext/__init__.py +1 -0
  127. robovast-1.0.0.dist-info/LICENSE +201 -0
  128. robovast-1.0.0.dist-info/METADATA +54 -0
  129. robovast-1.0.0.dist-info/RECORD +131 -0
  130. robovast-1.0.0.dist-info/WHEEL +4 -0
  131. 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)