|
5 | 5 | import unittest.mock |
6 | 6 | from typing import Literal, cast |
7 | 7 |
|
| 8 | +from pylabrobot.arms.standard import CartesianCoords |
8 | 9 | from pylabrobot.liquid_handling import LiquidHandler |
9 | 10 | from pylabrobot.liquid_handling.standard import GripDirection, Pickup |
10 | 11 | from pylabrobot.plate_reading import PlateReader |
@@ -147,6 +148,157 @@ def _any_write_and_read_command_call(cmd): |
147 | 148 | ) |
148 | 149 |
|
149 | 150 |
|
| 151 | +class TestiSWAPForwardKinematics(unittest.TestCase): |
| 152 | + """Geometry of `STARBackend._iswap_fk` (pure FK, no I/O). |
| 153 | +
|
| 154 | + Verifies the canonical (W, T) configurations against the docstring examples |
| 155 | + in `request_iswap_wrist_drive_orientation`: |
| 156 | + - W=FRONT (0) + T=STRAIGHT -> arm extends in -y (front of deck) |
| 157 | + - W=LEFT (-90) + T=STRAIGHT -> arm extends in -x (left of deck) |
| 158 | + - W=RIGHT(+90) + T=STRAIGHT -> arm extends in +x (right of deck) |
| 159 | + - W=FRONT (0) + T=RIGHT -> arm tip ends up to the left (-x) of rot. drive |
| 160 | + Uses factory-default link lengths (138 mm each) and the factory-default |
| 161 | + STRAIGHT angle (~-45 deg). Asserts only on the public pose contract |
| 162 | + (`location` + `rotation.z`). |
| 163 | + """ |
| 164 | + |
| 165 | + L1 = 138.0 |
| 166 | + L2 = 138.0 |
| 167 | + T_STRAIGHT = -45.0 # factory-default STRAIGHT calibration (EEPROM-dependent in practice) |
| 168 | + Z_OFFSET = STARBackend.iswap_rotation_drive_z_offset_above_finger_mm |
| 169 | + BASE_X, BASE_Y, BASE_Z = 100.0, 500.0, 200.0 |
| 170 | + # Gripper jaw width: hardware range ~71-134 mm (drive min/max increments). |
| 171 | + # FK doesn't read this axis, but the joint dict represents full state, so |
| 172 | + # use a realistic mid-range plate-grip value. |
| 173 | + GRIPPER_WIDTH = 90.0 |
| 174 | + |
| 175 | + def _fk(self, w: float, t: float) -> CartesianCoords: |
| 176 | + joints = { |
| 177 | + STARBackend.iSWAPAxis.X: self.BASE_X, |
| 178 | + STARBackend.iSWAPAxis.Y: self.BASE_Y, |
| 179 | + STARBackend.iSWAPAxis.Z: self.BASE_Z, |
| 180 | + STARBackend.iSWAPAxis.ROTATION: w, |
| 181 | + STARBackend.iSWAPAxis.WRIST: t, |
| 182 | + STARBackend.iSWAPAxis.GRIPPER: self.GRIPPER_WIDTH, |
| 183 | + } |
| 184 | + return STARBackend._iswap_fk( |
| 185 | + joints=joints, |
| 186 | + link_1_length=self.L1, |
| 187 | + link_2_length=self.L2, |
| 188 | + wrist_straight_angle=self.T_STRAIGHT, |
| 189 | + ) |
| 190 | + |
| 191 | + def test_front_straight_extends_in_minus_y(self): |
| 192 | + pose = self._fk(w=0.0, t=self.T_STRAIGHT) |
| 193 | + self.assertAlmostEqual(pose.location.x, self.BASE_X, places=6) |
| 194 | + self.assertAlmostEqual(pose.location.y, self.BASE_Y - (self.L1 + self.L2), places=6) |
| 195 | + self.assertAlmostEqual(pose.location.z, self.BASE_Z - self.Z_OFFSET, places=6) |
| 196 | + self.assertAlmostEqual(pose.rotation.z, -90.0, places=6) |
| 197 | + |
| 198 | + def test_left_straight_extends_in_minus_x(self): |
| 199 | + pose = self._fk(w=-90.0, t=self.T_STRAIGHT) |
| 200 | + self.assertAlmostEqual(pose.location.x, self.BASE_X - (self.L1 + self.L2), places=6) |
| 201 | + self.assertAlmostEqual(pose.location.y, self.BASE_Y, places=6) |
| 202 | + self.assertAlmostEqual(pose.rotation.z, -180.0, places=6) |
| 203 | + |
| 204 | + def test_front_right_extends_in_minus_x(self): |
| 205 | + """W=FRONT + T=RIGHT (-135 deg motor) -> gripper points to deck-left.""" |
| 206 | + pose = self._fk(w=0.0, t=-135.0) |
| 207 | + # Link 1 points -y from base; link 2 is bent right (CW by 90 deg) -> points -x. |
| 208 | + self.assertAlmostEqual(pose.location.x, self.BASE_X - self.L2, places=6) |
| 209 | + self.assertAlmostEqual(pose.location.y, self.BASE_Y - self.L1, places=6) |
| 210 | + self.assertAlmostEqual(pose.rotation.z, -180.0, places=6) |
| 211 | + |
| 212 | + def test_reverse_folds_arm_back_onto_base_xy(self): |
| 213 | + """T=REVERSE (+135) folds link 2 back 180 deg from link 1 -> tip XY = base XY.""" |
| 214 | + pose = self._fk(w=0.0, t=+135.0) |
| 215 | + self.assertAlmostEqual(pose.location.x, self.BASE_X, places=6) |
| 216 | + self.assertAlmostEqual(pose.location.y, self.BASE_Y, places=6) |
| 217 | + |
| 218 | + |
| 219 | +class TestiSWAPAxisPredicates(unittest.TestCase): |
| 220 | + """Predicates on `STARBackend.iSWAPAxis` classify axes by kinematic role / unit.""" |
| 221 | + |
| 222 | + def test_is_in_kinematic_chain(self): |
| 223 | + Axis = STARBackend.iSWAPAxis |
| 224 | + for a in (Axis.X, Axis.Y, Axis.Z, Axis.ROTATION, Axis.WRIST): |
| 225 | + self.assertTrue(a.is_in_kinematic_chain, f"{a.name} should be in the chain") |
| 226 | + self.assertFalse(Axis.GRIPPER.is_in_kinematic_chain, "GRIPPER should NOT be in the chain") |
| 227 | + |
| 228 | + |
| 229 | +class TestiSWAPRequestJointState(unittest.IsolatedAsyncioTestCase): |
| 230 | + """`iswap_request_joint_state` composes the per-axis request methods into one dict.""" |
| 231 | + |
| 232 | + def _make_backend(self) -> STARBackend: |
| 233 | + b = STARBackend() |
| 234 | + b._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION |
| 235 | + b.iswap_rotation_drive_request_x = unittest.mock.AsyncMock(return_value=100.0) |
| 236 | + b.iswap_rotation_drive_request_y = unittest.mock.AsyncMock(return_value=500.0) |
| 237 | + b.iswap_rotation_drive_request_z = unittest.mock.AsyncMock(return_value=200.0) |
| 238 | + b.iswap_rotation_drive_request_angle = unittest.mock.AsyncMock(return_value=0.0) |
| 239 | + b.iswap_wrist_drive_request_angle = unittest.mock.AsyncMock(return_value=-45.0) |
| 240 | + b.iswap_gripper_request_width = unittest.mock.AsyncMock(return_value=90.0) |
| 241 | + return b |
| 242 | + |
| 243 | + async def test_returns_full_axis_dict(self): |
| 244 | + b = self._make_backend() |
| 245 | + joints = await b.iswap_request_joint_state() |
| 246 | + Axis = STARBackend.iSWAPAxis |
| 247 | + self.assertEqual( |
| 248 | + joints, |
| 249 | + { |
| 250 | + Axis.X: 100.0, |
| 251 | + Axis.Y: 500.0, |
| 252 | + Axis.Z: 200.0, |
| 253 | + Axis.ROTATION: 0.0, |
| 254 | + Axis.WRIST: -45.0, |
| 255 | + Axis.GRIPPER: 90.0, |
| 256 | + }, |
| 257 | + ) |
| 258 | + |
| 259 | + |
| 260 | +class TestiSWAPRequestPose(unittest.IsolatedAsyncioTestCase): |
| 261 | + """`iswap_request_pose` reads joints + runs FK against the cached link lengths.""" |
| 262 | + |
| 263 | + def _make_backend(self) -> STARBackend: |
| 264 | + b = STARBackend() |
| 265 | + b._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION |
| 266 | + b._iswap_link_1_length = 138.0 |
| 267 | + b._iswap_link_2_length = 138.0 |
| 268 | + b._iswap_wrist_drive_predefined_increments = { |
| 269 | + STARBackend.WristDriveOrientation.RIGHT: -26577, |
| 270 | + STARBackend.WristDriveOrientation.STRAIGHT: -8859, |
| 271 | + STARBackend.WristDriveOrientation.LEFT: 8859, |
| 272 | + STARBackend.WristDriveOrientation.REVERSE: 26577, |
| 273 | + } |
| 274 | + b.iswap_rotation_drive_request_x = unittest.mock.AsyncMock(return_value=100.0) |
| 275 | + b.iswap_rotation_drive_request_y = unittest.mock.AsyncMock(return_value=500.0) |
| 276 | + b.iswap_rotation_drive_request_z = unittest.mock.AsyncMock(return_value=200.0) |
| 277 | + b.iswap_rotation_drive_request_angle = unittest.mock.AsyncMock(return_value=0.0) |
| 278 | + b.iswap_wrist_drive_request_angle = unittest.mock.AsyncMock(return_value=-45.0) |
| 279 | + b.iswap_gripper_request_width = unittest.mock.AsyncMock(return_value=90.0) |
| 280 | + return b |
| 281 | + |
| 282 | + async def test_front_straight_pose(self): |
| 283 | + """Canonical W=0 / T=-45 / base=(100, 500, 200) -> grip ~ (100, 224, 187), yaw ~ -90°. |
| 284 | +
|
| 285 | + Verifies the full I/O path (per-axis reads -> joint state -> FK -> pose). EEPROM |
| 286 | + STRAIGHT (-8859 incr) maps to ~-45.0007 deg, so the canonical -90° yaw lands at |
| 287 | + -89.999° and grip x picks up a ~0.002 mm offset - this is intentional per-machine |
| 288 | + calibration drift, not an FK bug. |
| 289 | + """ |
| 290 | + b = self._make_backend() |
| 291 | + pose = await b.iswap_request_pose() |
| 292 | + self.assertIsInstance(pose, CartesianCoords) |
| 293 | + self.assertAlmostEqual(pose.location.x, 100.0, places=2) |
| 294 | + self.assertAlmostEqual(pose.location.y, 224.0, places=3) |
| 295 | + self.assertAlmostEqual(pose.location.z, 187.0, places=6) |
| 296 | + self.assertAlmostEqual(pose.rotation.z, -90.0, places=2) |
| 297 | + # rotation.x / y are always 0 - the gripper plane stays parallel to the deck. |
| 298 | + self.assertEqual(pose.rotation.x, 0.0) |
| 299 | + self.assertEqual(pose.rotation.y, 0.0) |
| 300 | + |
| 301 | + |
150 | 302 | class TestSTARUSBComms(unittest.IsolatedAsyncioTestCase): |
151 | 303 | """Test that USB data is parsed correctly.""" |
152 | 304 |
|
|
0 commit comments