oopsie-data-tools 0.2.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 (74) hide show
  1. oopsie_data_tools/__init__.py +7 -0
  2. oopsie_data_tools/annotation_tool/__init__.py +5 -0
  3. oopsie_data_tools/annotation_tool/annotation_schema.py +266 -0
  4. oopsie_data_tools/annotation_tool/annotator_server.py +850 -0
  5. oopsie_data_tools/annotation_tool/episode_recorder.py +648 -0
  6. oopsie_data_tools/annotation_tool/rollout_annotator.py +333 -0
  7. oopsie_data_tools/annotation_tool/ui/annotator.html +2240 -0
  8. oopsie_data_tools/cli.py +894 -0
  9. oopsie_data_tools/init_wizard.py +329 -0
  10. oopsie_data_tools/skill/SKILL.md +98 -0
  11. oopsie_data_tools/skill/reference/conversion.md +257 -0
  12. oopsie_data_tools/skill/reference/format.md +112 -0
  13. oopsie_data_tools/skill/reference/robot-profile.md +104 -0
  14. oopsie_data_tools/skill/reference/setup.md +216 -0
  15. oopsie_data_tools/skill/reference/troubleshooting.md +97 -0
  16. oopsie_data_tools/test/__init__.py +1 -0
  17. oopsie_data_tools/test/conftest.py +174 -0
  18. oopsie_data_tools/test/fixtures/__init__.py +0 -0
  19. oopsie_data_tools/test/fixtures/make_invalid.py +633 -0
  20. oopsie_data_tools/test/fixtures/make_valid.py +406 -0
  21. oopsie_data_tools/test/test_annotation_completeness.py +154 -0
  22. oopsie_data_tools/test/test_annotation_schema.py +190 -0
  23. oopsie_data_tools/test/test_annotator_server.py +240 -0
  24. oopsie_data_tools/test/test_annotator_server_guards.py +146 -0
  25. oopsie_data_tools/test/test_bulk_inference_end_to_end.py +112 -0
  26. oopsie_data_tools/test/test_cli_config.py +111 -0
  27. oopsie_data_tools/test/test_cli_tools.py +438 -0
  28. oopsie_data_tools/test/test_contributor_config.py +38 -0
  29. oopsie_data_tools/test/test_conversion_utils_annotations.py +75 -0
  30. oopsie_data_tools/test/test_credentials_location.py +106 -0
  31. oopsie_data_tools/test/test_diversity.py +33 -0
  32. oopsie_data_tools/test/test_episode_recorder.py +335 -0
  33. oopsie_data_tools/test/test_episode_video_paths.py +173 -0
  34. oopsie_data_tools/test/test_hf_upload.py +234 -0
  35. oopsie_data_tools/test/test_init_wizard.py +193 -0
  36. oopsie_data_tools/test/test_install_skill.py +120 -0
  37. oopsie_data_tools/test/test_migrate_taxonomy_v2.py +255 -0
  38. oopsie_data_tools/test/test_new_profile.py +84 -0
  39. oopsie_data_tools/test/test_paths.py +137 -0
  40. oopsie_data_tools/test/test_python38_compat.py +79 -0
  41. oopsie_data_tools/test/test_record_step_purity.py +127 -0
  42. oopsie_data_tools/test/test_robot_setup.py +244 -0
  43. oopsie_data_tools/test/test_rollout_annotator.py +134 -0
  44. oopsie_data_tools/test/test_rotation_utils.py +126 -0
  45. oopsie_data_tools/test/test_validate.py +494 -0
  46. oopsie_data_tools/utils/__init__.py +1 -0
  47. oopsie_data_tools/utils/claude_skill.py +96 -0
  48. oopsie_data_tools/utils/contributor_config.py +91 -0
  49. oopsie_data_tools/utils/conversion_utils.py +220 -0
  50. oopsie_data_tools/utils/h5.py +60 -0
  51. oopsie_data_tools/utils/h5_inspect.py +167 -0
  52. oopsie_data_tools/utils/hf_limits.py +22 -0
  53. oopsie_data_tools/utils/hf_upload.py +235 -0
  54. oopsie_data_tools/utils/log.py +35 -0
  55. oopsie_data_tools/utils/migrate_taxonomy_v2.py +215 -0
  56. oopsie_data_tools/utils/paths.py +162 -0
  57. oopsie_data_tools/utils/restructure.py +402 -0
  58. oopsie_data_tools/utils/robot_profile/__init__.py +1 -0
  59. oopsie_data_tools/utils/robot_profile/robot_profile.py +240 -0
  60. oopsie_data_tools/utils/robot_profile/rotation_utils.py +119 -0
  61. oopsie_data_tools/utils/robot_profile/template.py +108 -0
  62. oopsie_data_tools/utils/validation/__init__.py +5 -0
  63. oopsie_data_tools/utils/validation/annotation_completeness.py +67 -0
  64. oopsie_data_tools/utils/validation/diversity.py +93 -0
  65. oopsie_data_tools/utils/validation/episode_data.py +54 -0
  66. oopsie_data_tools/utils/validation/episode_loader.py +201 -0
  67. oopsie_data_tools/utils/validation/episode_validator.py +315 -0
  68. oopsie_data_tools/utils/validation/errors.py +17 -0
  69. oopsie_data_tools/utils/validation/validation_utils.py +88 -0
  70. oopsie_data_tools-0.2.0.dist-info/METADATA +131 -0
  71. oopsie_data_tools-0.2.0.dist-info/RECORD +74 -0
  72. oopsie_data_tools-0.2.0.dist-info/WHEEL +4 -0
  73. oopsie_data_tools-0.2.0.dist-info/entry_points.txt +2 -0
  74. oopsie_data_tools-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,244 @@
1
+ """Tests for robot profile YAML loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from oopsie_data_tools.utils.paths import PROFILES_DIR_NAME, repo_config_dir
12
+ from oopsie_data_tools.utils.robot_profile.robot_profile import (
13
+ RobotProfile,
14
+ load_robot_profile,
15
+ )
16
+
17
+ # The shipped example profiles, addressed directly. This used to go through
18
+ # openpi_example_robot_profile_path(), which resolved via the cwd-sensitive profile lookup
19
+ # chain and so failed whenever $OOPSIE_ROBOT_PROFILES_DIR was set or pytest ran from a
20
+ # directory containing ./robot_profiles.
21
+ BUNDLED_PROFILES = repo_config_dir() / PROFILES_DIR_NAME if repo_config_dir() else None
22
+
23
+ VALID_PROFILE = {
24
+ "policy_name": "test_policy",
25
+ "robot_name": "test_robot",
26
+ "is_biarm": False,
27
+ "uses_mobile_base": False,
28
+ "gripper_name": "test_gripper",
29
+ "control_freq": 10,
30
+ "camera_names": ["cam0"],
31
+ "robot_state_keys": ["cartesian_position", "gripper_position"],
32
+ "action_space": ["cartesian_position", "gripper_position"],
33
+ }
34
+
35
+
36
+ def _write_profile(data: dict, tmp_dir: str) -> Path:
37
+ p = Path(tmp_dir) / "profile.yaml"
38
+ p.write_text(yaml.dump(data))
39
+ return p
40
+
41
+
42
+ class TestRobotProfileValid(unittest.TestCase):
43
+ def test_load_openpi_example_yaml(self) -> None:
44
+ self.assertIsNotNone(BUNDLED_PROFILES, "these tests run from a checkout")
45
+ path = BUNDLED_PROFILES / "openpi_example_robot_profile.yaml"
46
+ self.assertTrue(path.is_file(), msg=f"Missing {path}")
47
+ profile = load_robot_profile(path)
48
+ self.assertIsInstance(profile, RobotProfile)
49
+ self.assertIsInstance(profile.action_space, list)
50
+ self.assertIn("joint_velocity", profile.action_space)
51
+ self.assertEqual(len(profile.action_joint_names or []), 7)
52
+
53
+ def test_load_minimal_valid_profile(self) -> None:
54
+ with tempfile.TemporaryDirectory() as tmp:
55
+ path = _write_profile(VALID_PROFILE, tmp)
56
+ profile = load_robot_profile(path)
57
+ self.assertIsInstance(profile, RobotProfile)
58
+ self.assertFalse(profile.is_biarm)
59
+
60
+
61
+ class TestRobotProfileFileErrors(unittest.TestCase):
62
+ def test_missing_file_raises(self) -> None:
63
+ with self.assertRaises(FileNotFoundError):
64
+ load_robot_profile("/nonexistent/path/profile.yaml")
65
+
66
+ def test_non_mapping_yaml_raises(self) -> None:
67
+ with tempfile.TemporaryDirectory() as tmp:
68
+ p = Path(tmp) / "profile.yaml"
69
+ p.write_text("- just\n- a\n- list\n")
70
+ with self.assertRaises(ValueError, msg="non-mapping YAML should raise"):
71
+ load_robot_profile(p)
72
+
73
+
74
+ class TestRobotProfileMissingRequiredKeys(unittest.TestCase):
75
+ def _missing_key_raises(self, key: str) -> None:
76
+ data = {k: v for k, v in VALID_PROFILE.items() if k != key}
77
+ with tempfile.TemporaryDirectory() as tmp:
78
+ path = _write_profile(data, tmp)
79
+ with self.assertRaises(ValueError, msg=f"missing '{key}' should raise"):
80
+ load_robot_profile(path)
81
+
82
+ def test_missing_policy_name(self) -> None:
83
+ self._missing_key_raises("policy_name")
84
+
85
+ def test_missing_robot_name(self) -> None:
86
+ self._missing_key_raises("robot_name")
87
+
88
+ def test_missing_gripper_name(self) -> None:
89
+ self._missing_key_raises("gripper_name")
90
+
91
+ def test_missing_control_freq(self) -> None:
92
+ self._missing_key_raises("control_freq")
93
+
94
+ def test_missing_camera_names(self) -> None:
95
+ self._missing_key_raises("camera_names")
96
+
97
+ def test_missing_robot_state_keys(self) -> None:
98
+ self._missing_key_raises("robot_state_keys")
99
+
100
+ def test_missing_action_space(self) -> None:
101
+ self._missing_key_raises("action_space")
102
+
103
+
104
+ class TestRobotProfileInvalidRobotStateKeys(unittest.TestCase):
105
+ def test_cartesian_action_without_cartesian_position_state_key(self) -> None:
106
+ data = {**VALID_PROFILE, "robot_state_keys": ["gripper_position"]}
107
+ with tempfile.TemporaryDirectory() as tmp:
108
+ path = _write_profile(data, tmp)
109
+ with self.assertRaisesRegex(
110
+ ValueError,
111
+ "cartesian_position",
112
+ msg="Cartesian control without cartesian_position state should raise",
113
+ ):
114
+ load_robot_profile(path)
115
+
116
+ def test_missing_gripper_position_state_key(self) -> None:
117
+ data = {**VALID_PROFILE, "robot_state_keys": ["cartesian_position"]}
118
+ with tempfile.TemporaryDirectory() as tmp:
119
+ path = _write_profile(data, tmp)
120
+ with self.assertRaises(ValueError, msg="missing gripper_position in robot_state_keys"):
121
+ load_robot_profile(path)
122
+
123
+ def test_joint_velocity_action_without_joint_position_state_key(self) -> None:
124
+ data = {
125
+ **VALID_PROFILE,
126
+ "robot_state_keys": ["gripper_position"],
127
+ "action_space": ["joint_velocity", "gripper_position"],
128
+ "action_joint_names": ["j0", "j1"],
129
+ }
130
+ with tempfile.TemporaryDirectory() as tmp:
131
+ path = _write_profile(data, tmp)
132
+ with self.assertRaisesRegex(
133
+ ValueError,
134
+ "joint_position",
135
+ msg="Joint velocity control without joint_position state should raise",
136
+ ):
137
+ load_robot_profile(path)
138
+
139
+ def test_joint_velocity_action_with_joint_position_state_key(self) -> None:
140
+ data = {
141
+ **VALID_PROFILE,
142
+ "robot_state_keys": ["joint_position", "gripper_position"],
143
+ "robot_state_joint_names": ["j0", "j1"],
144
+ "action_space": ["joint_velocity", "gripper_position"],
145
+ "action_joint_names": ["j0", "j1"],
146
+ }
147
+ with tempfile.TemporaryDirectory() as tmp:
148
+ profile = load_robot_profile(_write_profile(data, tmp))
149
+ self.assertEqual(
150
+ profile.robot_state_keys,
151
+ ["joint_position", "gripper_position"],
152
+ )
153
+
154
+ def test_joint_position_state_without_joint_names(self) -> None:
155
+ for joint_names in (None, []):
156
+ with self.subTest(robot_state_joint_names=joint_names):
157
+ data = {
158
+ **VALID_PROFILE,
159
+ "robot_state_keys": [
160
+ "joint_position",
161
+ "cartesian_position",
162
+ "gripper_position",
163
+ ],
164
+ }
165
+ if joint_names is not None:
166
+ data["robot_state_joint_names"] = joint_names
167
+
168
+ with tempfile.TemporaryDirectory() as tmp:
169
+ path = _write_profile(data, tmp)
170
+ with self.assertRaisesRegex(
171
+ ValueError,
172
+ "robot_state_joint_names is required",
173
+ ):
174
+ load_robot_profile(path)
175
+
176
+ def test_empty_robot_state_keys(self) -> None:
177
+ data = {**VALID_PROFILE, "robot_state_keys": []}
178
+ with tempfile.TemporaryDirectory() as tmp:
179
+ path = _write_profile(data, tmp)
180
+ with self.assertRaises(ValueError):
181
+ load_robot_profile(path)
182
+
183
+
184
+ class TestRobotProfileInvalidActionSpace(unittest.TestCase):
185
+ def test_completely_invalid_action_key(self) -> None:
186
+ data = {**VALID_PROFILE, "action_space": ["invalid_key", "gripper_position"]}
187
+ with tempfile.TemporaryDirectory() as tmp:
188
+ path = _write_profile(data, tmp)
189
+ with self.assertRaises(ValueError, msg="invalid action key should raise"):
190
+ load_robot_profile(path)
191
+
192
+ def test_no_allowed_action_key(self) -> None:
193
+ data = {**VALID_PROFILE, "action_space": ["gripper_position"]}
194
+ with tempfile.TemporaryDirectory() as tmp:
195
+ path = _write_profile(data, tmp)
196
+ with self.assertRaises(ValueError, msg="action_space with no ALLOWED_ACTION_SPACES key"):
197
+ load_robot_profile(path)
198
+
199
+ def test_missing_gripper_in_action_space(self) -> None:
200
+ data = {**VALID_PROFILE, "action_space": ["cartesian_position"]}
201
+ with tempfile.TemporaryDirectory() as tmp:
202
+ path = _write_profile(data, tmp)
203
+ with self.assertRaises(ValueError, msg="no gripper key in action_space"):
204
+ load_robot_profile(path)
205
+
206
+ def test_joint_space_without_joint_names(self) -> None:
207
+ data = {
208
+ **VALID_PROFILE,
209
+ "robot_state_keys": ["joint_position", "gripper_position"],
210
+ "robot_state_joint_names": ["j0", "j1"],
211
+ "action_space": ["joint_position", "gripper_position"],
212
+ # action_joint_names intentionally omitted
213
+ }
214
+ with tempfile.TemporaryDirectory() as tmp:
215
+ path = _write_profile(data, tmp)
216
+ with self.assertRaises(ValueError, msg="joint_position without action_joint_names"):
217
+ load_robot_profile(path)
218
+
219
+ def test_joint_velocity_without_joint_names(self) -> None:
220
+ data = {
221
+ **VALID_PROFILE,
222
+ "robot_state_keys": ["joint_position", "gripper_position"],
223
+ "robot_state_joint_names": ["j0", "j1"],
224
+ "action_space": ["joint_velocity", "gripper_position"],
225
+ }
226
+ with tempfile.TemporaryDirectory() as tmp:
227
+ path = _write_profile(data, tmp)
228
+ with self.assertRaises(ValueError, msg="joint_velocity without action_joint_names"):
229
+ load_robot_profile(path)
230
+
231
+ def test_mobile_base_without_base_action_key(self) -> None:
232
+ data = {
233
+ **VALID_PROFILE,
234
+ "uses_mobile_base": True,
235
+ # action_space has no base_velocity or base_position
236
+ }
237
+ with tempfile.TemporaryDirectory() as tmp:
238
+ path = _write_profile(data, tmp)
239
+ with self.assertRaises(ValueError, msg="mobile base without base action key"):
240
+ load_robot_profile(path)
241
+
242
+
243
+ if __name__ == "__main__":
244
+ unittest.main()
@@ -0,0 +1,134 @@
1
+ """Tests for WebRolloutAnnotator: construction and recording delegation.
2
+
3
+ Server-dependent methods (start, wait_for_task, finish_rollout) require a
4
+ running Flask process and are not covered here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import tempfile
10
+ import unittest
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+
15
+ from oopsie_data_tools.annotation_tool.episode_recorder import EpisodeRecorder
16
+ from oopsie_data_tools.annotation_tool.rollout_annotator import WebRolloutAnnotator
17
+ from oopsie_data_tools.utils.robot_profile.robot_profile import RobotProfile
18
+
19
+
20
+ def _profile(**overrides) -> RobotProfile:
21
+ defaults = dict(
22
+ policy_name="test_policy",
23
+ robot_name="test_robot",
24
+ is_biarm=False,
25
+ uses_mobile_base=False,
26
+ gripper_name="test_gripper",
27
+ control_freq=10,
28
+ camera_names=["left"],
29
+ robot_state_keys=["joint_position", "gripper_position"],
30
+ robot_state_joint_names=["j1", "j2", "j3", "j4", "j5", "j6", "j7"],
31
+ action_space=["joint_velocity", "gripper_position"],
32
+ action_joint_names=["j1", "j2", "j3", "j4", "j5", "j6", "j7"],
33
+ )
34
+ defaults.update(overrides)
35
+ return RobotProfile(**defaults)
36
+
37
+
38
+ def _obs(profile: RobotProfile) -> dict:
39
+ return {
40
+ "robot_state": {
41
+ "joint_position": np.zeros(7, dtype=np.float32),
42
+ "gripper_position": np.zeros(1, dtype=np.float32),
43
+ },
44
+ "image_observation": {
45
+ cam: np.zeros((64, 64, 3), dtype=np.uint8) for cam in profile.camera_names
46
+ },
47
+ }
48
+
49
+
50
+ def _action(profile: RobotProfile) -> dict:
51
+ sizes = {
52
+ "joint_velocity": 7,
53
+ "joint_position": 7,
54
+ "gripper_position": 1,
55
+ "gripper_velocity": 1,
56
+ }
57
+ return {
58
+ k: np.zeros(sizes.get(k, 1), dtype=np.float32) for k in profile.action_space
59
+ }
60
+
61
+
62
+ class TestWebRolloutAnnotatorConstruction(unittest.TestCase):
63
+ def setUp(self):
64
+ self._tmp = tempfile.TemporaryDirectory()
65
+ self.profile = _profile()
66
+ self.annotator = WebRolloutAnnotator(
67
+ robot_profile=self.profile,
68
+ data_root_dir=Path(self._tmp.name),
69
+ operator_name=" tester ",
70
+ annotator_name="test_annotator",
71
+ wait_for_annotation=False,
72
+ open_browser=False,
73
+ )
74
+
75
+ def tearDown(self):
76
+ self.annotator.stop()
77
+ self._tmp.cleanup()
78
+
79
+ def test_operator_name_stripped(self):
80
+ self.assertEqual(self.annotator.operator_name, "tester")
81
+
82
+ def test_wait_for_annotation_stored(self):
83
+ self.assertFalse(self.annotator.wait_for_annotation)
84
+
85
+ def test_internal_recorder_is_episode_recorder(self):
86
+ self.assertIsInstance(self.annotator._active_recorder, EpisodeRecorder)
87
+
88
+ def test_data_root_dir_resolved(self):
89
+ self.assertTrue(self.annotator.data_root_dir.is_absolute())
90
+
91
+ def test_stop_is_safe_without_start(self):
92
+ self.annotator.stop() # _proc is None — must not raise
93
+
94
+
95
+ class TestWebRolloutAnnotatorRecording(unittest.TestCase):
96
+ def setUp(self):
97
+ self._tmp = tempfile.TemporaryDirectory()
98
+ self.profile = _profile()
99
+ self.annotator = WebRolloutAnnotator(
100
+ robot_profile=self.profile,
101
+ data_root_dir=Path(self._tmp.name),
102
+ operator_name="tester",
103
+ annotator_name="test_annotator",
104
+ wait_for_annotation=False,
105
+ open_browser=False,
106
+ )
107
+ self.annotator.reset_episode_recorder()
108
+
109
+ def tearDown(self):
110
+ self.annotator.stop()
111
+ self._tmp.cleanup()
112
+
113
+ def test_record_step_delegates_to_recorder(self):
114
+ self.annotator.record_step(_obs(self.profile), _action(self.profile))
115
+ self.assertEqual(self.annotator._active_recorder.num_steps, 1)
116
+
117
+ def test_multiple_record_steps(self):
118
+ for _ in range(3):
119
+ self.annotator.record_step(_obs(self.profile), _action(self.profile))
120
+ self.assertEqual(self.annotator._active_recorder.num_steps, 3)
121
+
122
+ def test_reset_clears_recorder(self):
123
+ self.annotator.record_step(_obs(self.profile), _action(self.profile))
124
+ self.annotator.reset_episode_recorder()
125
+ self.assertEqual(self.annotator._active_recorder.num_steps, 0)
126
+
127
+ def test_record_step_propagates_validation_error(self):
128
+ bad_action = {"joint_velocity": np.zeros(7)} # missing gripper_position
129
+ with self.assertRaises(ValueError):
130
+ self.annotator.record_step(_obs(self.profile), bad_action)
131
+
132
+
133
+ if __name__ == "__main__":
134
+ unittest.main()
@@ -0,0 +1,126 @@
1
+ """Tests for orientation-representation conversion.
2
+
3
+ This module had no coverage at all, which is how the ``matrix`` branch shipped broken:
4
+ it handed scipy a flat 9-element slice without reshaping, so every profile declaring
5
+ ``orientation_representation: matrix`` raised at the first recorded step.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import pytest
12
+ from scipy.spatial.transform import Rotation as R
13
+
14
+ from oopsie_data_tools.utils.robot_profile.rotation_utils import ActionQuatConversion, RotOption
15
+
16
+ POSITION = np.array([0.1, -0.2, 0.3])
17
+ # An arbitrary rotation, expressed once and then re-encoded per representation below.
18
+ REFERENCE = R.from_euler("xyz", [0.3, -0.5, 1.1])
19
+
20
+
21
+ def _assert_same_rotation(quat: np.ndarray, expected: R) -> None:
22
+ """Compare as matrices: q and -q are the same rotation, and Rotation.approx_equal
23
+ does not exist on the scipy that ships with our Python 3.8 floor."""
24
+ np.testing.assert_allclose(R.from_quat(quat).as_matrix(), expected.as_matrix(), atol=1e-8)
25
+
26
+
27
+ def _encode(option: RotOption) -> np.ndarray:
28
+ if option == RotOption.QUAT:
29
+ return REFERENCE.as_quat()
30
+ if option == RotOption.MATRIX:
31
+ return REFERENCE.as_matrix().reshape(9)
32
+ if option == RotOption.ROT6D:
33
+ # First two columns of the rotation matrix, flattened row-wise as the converter reads it.
34
+ return REFERENCE.as_matrix()[:, :2].T.reshape(6)
35
+ if option == RotOption.ROTVEC:
36
+ return REFERENCE.as_rotvec()
37
+ return REFERENCE.as_euler(option.name)
38
+
39
+
40
+ @pytest.mark.parametrize(
41
+ "option",
42
+ [
43
+ RotOption.QUAT,
44
+ RotOption.MATRIX,
45
+ RotOption.ROT6D,
46
+ RotOption.ROTVEC,
47
+ RotOption.xyz,
48
+ RotOption.zyx,
49
+ RotOption.XYZ,
50
+ ],
51
+ )
52
+ def test_every_representation_round_trips_to_the_same_rotation(option):
53
+ action = np.concatenate([POSITION, _encode(option)])
54
+
55
+ result = ActionQuatConversion(option).convert_position(action)
56
+
57
+ assert result.shape == (7,), "position + scalar-last quaternion"
58
+ np.testing.assert_allclose(result[:3], POSITION)
59
+ _assert_same_rotation(result[3:], REFERENCE)
60
+
61
+
62
+ def test_matrix_is_accepted_flat():
63
+ """The regression: a flat 3x3 in the action vector must not reach scipy unreshaped."""
64
+ action = np.concatenate([POSITION, np.eye(3).reshape(9)])
65
+
66
+ result = ActionQuatConversion(RotOption.MATRIX).convert_position(action)
67
+
68
+ # 1e-9 rather than exact: the value comes out of scipy's matrix-to-quaternion routine,
69
+ # and pinning it to machine epsilon would make this brittle across scipy/BLAS builds
70
+ # for no gain — the point is that it converts at all, and to the identity rotation.
71
+ np.testing.assert_allclose(result, [*POSITION, 0.0, 0.0, 0.0, 1.0], atol=1e-9)
72
+
73
+
74
+ def test_biarm_converts_each_arm_independently():
75
+ left = R.from_euler("xyz", [0.2, 0.0, 0.0])
76
+ right = R.from_euler("xyz", [0.0, 0.7, 0.0])
77
+ action = np.concatenate(
78
+ [POSITION, left.as_euler("xyz"), -POSITION, right.as_euler("xyz")]
79
+ )
80
+
81
+ result = ActionQuatConversion(RotOption.xyz, is_biarm=True).convert_position(action)
82
+
83
+ assert result.shape == (14,)
84
+ np.testing.assert_allclose(result[:3], POSITION)
85
+ np.testing.assert_allclose(result[7:10], -POSITION)
86
+ _assert_same_rotation(result[3:7], left)
87
+ _assert_same_rotation(result[10:14], right)
88
+
89
+
90
+ def test_wrong_orientation_width_names_the_profile_field():
91
+ """A profile/policy mismatch should say so, not surface a scipy internal error."""
92
+ action = np.concatenate([POSITION, np.zeros(3)]) # euler-sized, but declared as quat
93
+
94
+ with pytest.raises(ValueError, match="QUAT orientation expects 4 value"):
95
+ ActionQuatConversion(RotOption.QUAT).convert_position(action)
96
+
97
+
98
+ def test_biarm_rejects_an_odd_action_length():
99
+ with pytest.raises(ValueError, match="even length"):
100
+ ActionQuatConversion(RotOption.QUAT, is_biarm=True).convert_position(np.zeros(13))
101
+
102
+
103
+ @pytest.mark.parametrize(
104
+ ("text", "expected"),
105
+ [
106
+ ("quat", RotOption.QUAT),
107
+ ("matrix", RotOption.MATRIX),
108
+ ("rot6d", RotOption.ROT6D),
109
+ ("rotvec", RotOption.ROTVEC),
110
+ ("euler_xyz", RotOption.xyz),
111
+ ("euler_XYZ", RotOption.XYZ),
112
+ ],
113
+ )
114
+ def test_from_string_accepts_every_documented_option(text, expected):
115
+ """Every option named in configs/robot_profiles/template.yaml must parse."""
116
+ assert RotOption.from_string(text) == expected
117
+
118
+
119
+ def test_from_string_rejects_an_unknown_euler_order():
120
+ with pytest.raises(ValueError, match="Unsupported Euler format"):
121
+ RotOption.from_string("euler_abc")
122
+
123
+
124
+ def test_from_string_rejects_an_unknown_representation():
125
+ with pytest.raises(ValueError, match="Unsupported rotation option"):
126
+ RotOption.from_string("axis_angle")