Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 1 | #!/usr/bin/python3 |
| 2 | |
| 3 | import numpy |
| 4 | import sys, os |
| 5 | import casadi |
| 6 | from numpy.testing import assert_array_equal, assert_array_almost_equal |
| 7 | import unittest |
| 8 | |
| 9 | from frc971.control_loops.swerve import dynamics |
| 10 | |
| 11 | |
| 12 | def state_vector(velocity=numpy.array([[1.0], [0.0]]), |
| 13 | dx=0.0, |
| 14 | dy=0.0, |
| 15 | theta=0.0, |
| 16 | omega=0.0, |
| 17 | module_omega=0.0, |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 18 | module_angle=0.0, |
| 19 | drive_wheel_velocity=None, |
| 20 | module_angles=None): |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 21 | """Returns the state vector with the requested state.""" |
| 22 | X_initial = numpy.zeros((25, 1)) |
| 23 | # All the wheels are spinning at the speed needed to hit the velocity in m/s |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 24 | drive_wheel_velocity = (drive_wheel_velocity |
| 25 | or numpy.linalg.norm(velocity)) |
| 26 | |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 27 | X_initial[2, 0] = module_omega |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 28 | X_initial[3, 0] = drive_wheel_velocity / (dynamics.WHEEL_RADIUS) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 29 | |
| 30 | X_initial[6, 0] = module_omega |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 31 | X_initial[7, 0] = drive_wheel_velocity / (dynamics.WHEEL_RADIUS) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 32 | |
| 33 | X_initial[10, 0] = module_omega |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 34 | X_initial[11, 0] = drive_wheel_velocity / (dynamics.WHEEL_RADIUS) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 35 | |
| 36 | X_initial[14, 0] = module_omega |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 37 | X_initial[15, 0] = drive_wheel_velocity / (dynamics.WHEEL_RADIUS) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 38 | |
| 39 | X_initial[0, 0] = module_angle |
| 40 | X_initial[4, 0] = module_angle |
| 41 | X_initial[8, 0] = module_angle |
| 42 | X_initial[12, 0] = module_angle |
| 43 | |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 44 | if module_angles is not None: |
| 45 | assert (len(module_angles) == 4) |
| 46 | X_initial[0, 0] = module_angles[0] |
| 47 | X_initial[4, 0] = module_angles[1] |
| 48 | X_initial[8, 0] = module_angles[2] |
| 49 | X_initial[12, 0] = module_angles[3] |
| 50 | |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 51 | X_initial[18, 0] = theta |
| 52 | |
| 53 | X_initial[19, 0] = velocity[0, 0] + dx |
| 54 | X_initial[20, 0] = velocity[1, 0] + dy |
| 55 | X_initial[21, 0] = omega |
| 56 | |
| 57 | return X_initial |
| 58 | |
| 59 | |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 60 | def wrap(fn): |
| 61 | evaluated_fn = fn(casadi.SX.sym("X", 25, 1), casadi.SX.sym("U", 8, 1)) |
| 62 | return lambda X, U: numpy.array(evaluated_fn(X, U)) |
| 63 | |
| 64 | |
| 65 | def wrap_module(fn, i): |
| 66 | evaluated_fn = fn(i, casadi.SX.sym("X", 25, 1), casadi.SX.sym("U", 8, 1)) |
| 67 | return lambda X, U: numpy.array(evaluated_fn(X, U)) |
| 68 | |
| 69 | |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 70 | class TestHPolytope(unittest.TestCase): |
| 71 | I = numpy.zeros((8, 1)) |
| 72 | |
| 73 | def setUp(self): |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 74 | self.swerve_physics = wrap(dynamics.swerve_physics) |
| 75 | self.contact_patch_velocity = [ |
| 76 | wrap_module(dynamics.contact_patch_velocity, i) for i in range(4) |
| 77 | ] |
| 78 | self.wheel_ground_velocity = [ |
| 79 | wrap_module(dynamics.wheel_ground_velocity, i) for i in range(4) |
| 80 | ] |
| 81 | self.wheel_slip_velocity = [ |
| 82 | wrap_module(dynamics.wheel_slip_velocity, i) for i in range(4) |
| 83 | ] |
| 84 | self.wheel_force = [ |
| 85 | wrap_module(dynamics.wheel_force, i) for i in range(4) |
| 86 | ] |
| 87 | self.module_angular_accel = [ |
| 88 | wrap_module(dynamics.module_angular_accel, i) for i in range(4) |
| 89 | ] |
| 90 | self.F = [wrap_module(dynamics.F, i) for i in range(4)] |
| 91 | self.mounting_location = [ |
| 92 | wrap_module(dynamics.mounting_location, i) for i in range(4) |
| 93 | ] |
| 94 | |
| 95 | self.slip_angle = [ |
| 96 | wrap_module(dynamics.slip_angle, i) for i in range(4) |
| 97 | ] |
| 98 | self.slip_ratio = [ |
| 99 | wrap_module(dynamics.slip_ratio, i) for i in range(4) |
| 100 | ] |
| 101 | self.Ms = [wrap_module(dynamics.Ms, i) for i in range(4)] |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 102 | |
| 103 | def test_contact_patch_velocity(self): |
| 104 | """Tests that the contact patch velocity makes sense.""" |
| 105 | for i in range(4): |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 106 | contact_patch_velocity = wrap_module( |
| 107 | dynamics.contact_patch_velocity, i) |
| 108 | wheel_ground_velocity = wrap_module(dynamics.wheel_ground_velocity, |
| 109 | i) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 110 | |
| 111 | # No angular velocity should result in just linear motion. |
| 112 | for velocity in [ |
| 113 | numpy.array([[1.5], [0.0]]), |
| 114 | numpy.array([[0.0], [1.0]]), |
| 115 | numpy.array([[-1.5], [0.0]]), |
| 116 | numpy.array([[0.0], [-1.0]]), |
| 117 | numpy.array([[2.0], [-1.7]]), |
| 118 | ]: |
| 119 | for theta in [0.0, 1.0, -1.0, 100.0]: |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 120 | patch_velocity = contact_patch_velocity( |
| 121 | state_vector(velocity=velocity, theta=theta), self.I) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 122 | |
| 123 | assert_array_equal(patch_velocity, velocity) |
| 124 | |
| 125 | # Now, test that spinning the robot results in module velocities. |
| 126 | # We are assuming that each module is on a square robot. |
| 127 | module_center_of_mass_angle = i * numpy.pi / 2.0 + numpy.pi / 4.0 |
| 128 | for theta in [-module_center_of_mass_angle, 0.0, 1.0, -1.0, 100.0]: |
| 129 | for omega in [0.65, -0.1]: |
| 130 | # Point each module to the center to make the math easier. |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 131 | patch_velocity = contact_patch_velocity( |
| 132 | state_vector(velocity=numpy.array([[0.0], [0.0]]), |
| 133 | theta=theta, |
| 134 | omega=omega, |
| 135 | module_angle=module_center_of_mass_angle), |
| 136 | self.I) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 137 | |
| 138 | assert_array_almost_equal( |
| 139 | patch_velocity, |
| 140 | (dynamics.ROBOT_WIDTH / numpy.sqrt(2.0) + |
| 141 | dynamics.CASTER) * omega * numpy.array([[ |
| 142 | -numpy.sin(theta + module_center_of_mass_angle) |
| 143 | ], [numpy.cos(theta + module_center_of_mass_angle)]])) |
| 144 | |
| 145 | # Point the wheel along +x, rotate it by theta, then spin it. |
| 146 | # Confirm the velocities come out right. |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 147 | patch_velocity = contact_patch_velocity( |
| 148 | state_vector( |
| 149 | velocity=numpy.array([[0.0], [0.0]]), |
| 150 | theta=-module_center_of_mass_angle, |
| 151 | module_omega=omega, |
| 152 | module_angle=(theta + |
| 153 | module_center_of_mass_angle)), |
| 154 | self.I) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 155 | |
| 156 | assert_array_almost_equal( |
| 157 | patch_velocity, |
| 158 | dynamics.CASTER * omega * |
| 159 | numpy.array([[-numpy.sin(theta)], [numpy.cos(theta)]])) |
| 160 | |
| 161 | # Now, test that the rotation back to wheel coordinates works. |
| 162 | # The easiest way to do this is to point the wheel in a direction, |
| 163 | # move in that direction, and confirm that there is no lateral velocity. |
| 164 | for robot_angle in [0.0, 1.0, -5.0]: |
| 165 | for module_angle in [0.0, 1.0, -5.0]: |
| 166 | wheel_patch_velocity = numpy.array( |
| 167 | wheel_ground_velocity( |
| 168 | state_vector(velocity=numpy.array( |
| 169 | [[numpy.cos(robot_angle + module_angle)], |
| 170 | [numpy.sin(robot_angle + module_angle)]]), |
| 171 | theta=robot_angle, |
| 172 | module_angle=module_angle), self.I)) |
| 173 | |
| 174 | assert_array_almost_equal(wheel_patch_velocity, |
| 175 | numpy.array([[1], [0]])) |
| 176 | |
| 177 | def test_slip_angle(self): |
| 178 | """Tests that the slip_angle calculation works.""" |
| 179 | velocity = numpy.array([[1.5], [0.0]]) |
| 180 | |
| 181 | for i in range(4): |
| 182 | x = casadi.SX.sym("x") |
| 183 | y = casadi.SX.sym("y") |
| 184 | half_atan2 = casadi.Function('half_atan2', [y, x], |
| 185 | [dynamics.half_atan2(y, x)]) |
| 186 | |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 187 | for wrap in range(-3, 3): |
| 188 | for theta in [0.0, 0.6, -0.4]: |
| 189 | module_angle = numpy.pi * wrap + theta |
| 190 | |
| 191 | self.assertAlmostEqual( |
| 192 | theta, |
| 193 | half_atan2(numpy.sin(module_angle), |
| 194 | numpy.cos(module_angle))) |
| 195 | |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 196 | computed_angle = self.slip_angle[i](state_vector( |
| 197 | velocity=velocity, |
| 198 | module_angle=numpy.pi * wrap + theta), self.I)[0, 0] |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 199 | |
| 200 | self.assertAlmostEqual(theta, computed_angle) |
| 201 | |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 202 | def test_wheel_torque(self): |
| 203 | """Tests that the per module self aligning forces have the right signs.""" |
| 204 | X = state_vector(module_angles=[-0.001, -0.001, 0.001, 0.001]) |
| 205 | xdot_equal = self.swerve_physics(X, self.I) |
| 206 | |
| 207 | self.assertGreater(xdot_equal[2, 0], 0.0) |
| 208 | self.assertAlmostEqual(xdot_equal[3, 0], 0.0, places=1) |
| 209 | self.assertGreater(self.Ms[0](X, self.I)[0, 0], 0.0) |
| 210 | |
| 211 | self.assertGreater(xdot_equal[6, 0], 0.0) |
| 212 | self.assertAlmostEqual(xdot_equal[7, 0], 0.0, places=1) |
| 213 | self.assertGreater(self.Ms[1](X, self.I)[0, 0], 0.0) |
| 214 | |
| 215 | self.assertLess(xdot_equal[10, 0], 0.0) |
| 216 | self.assertAlmostEqual(xdot_equal[11, 0], 0.0, places=1) |
| 217 | self.assertLess(self.Ms[2](X, self.I)[0, 0], 0.0) |
| 218 | |
| 219 | self.assertLess(xdot_equal[14, 0], 0.0) |
| 220 | self.assertAlmostEqual(xdot_equal[15, 0], 0.0, places=1) |
| 221 | self.assertLess(self.Ms[3](X, self.I)[0, 0], 0.0) |
| 222 | |
| 223 | # Shouldn't be spinning. |
| 224 | self.assertAlmostEqual(xdot_equal[21, 0], 0.0, places=2) |
| 225 | |
| 226 | # Now, make the bot want to go left. |
| 227 | # The wheels will be going too fast based on our calcs, so they should be decelerating. |
| 228 | X = state_vector(module_angles=[0.01, 0.01, 0.01, 0.01]) |
| 229 | xdot_left = self.swerve_physics(X, self.I) |
| 230 | |
| 231 | self.assertLess(xdot_left[2, 0], -0.1) |
| 232 | self.assertLess(xdot_left[3, 0], 0.0) |
| 233 | self.assertLess(self.Ms[0](X, self.I)[0, 0], 0.0) |
| 234 | |
| 235 | self.assertLess(xdot_left[6, 0], -0.1) |
| 236 | self.assertLess(xdot_left[7, 0], 0.0) |
| 237 | self.assertLess(self.Ms[1](X, self.I)[0, 0], 0.0) |
| 238 | |
| 239 | self.assertLess(xdot_left[10, 0], -0.1) |
| 240 | self.assertLess(xdot_left[11, 0], 0.0) |
| 241 | self.assertLess(self.Ms[2](X, self.I)[0, 0], 0.0) |
| 242 | |
| 243 | self.assertLess(xdot_left[14, 0], -0.1) |
| 244 | self.assertLess(xdot_left[15, 0], 0.0) |
| 245 | self.assertLess(self.Ms[3](X, self.I)[0, 0], 0.0) |
| 246 | |
| 247 | self.assertGreater(xdot_left[19, 0], 0.0001) |
| 248 | self.assertGreater(xdot_left[20, 0], 0.1) |
| 249 | # Shouldn't be spinning. |
| 250 | self.assertAlmostEqual(xdot_left[21, 0], 0.0) |
| 251 | |
| 252 | # And now do it to the right too. |
| 253 | X = state_vector(module_angles=[-0.01, -0.01, -0.01, -0.01]) |
| 254 | xdot_right = self.swerve_physics(X, self.I) |
| 255 | |
| 256 | self.assertGreater(xdot_right[2, 0], 0.1) |
| 257 | self.assertLess(xdot_right[3, 0], 0.0) |
| 258 | self.assertGreater(self.Ms[0](X, self.I)[0, 0], 0.0) |
| 259 | |
| 260 | self.assertGreater(xdot_right[6, 0], 0.1) |
| 261 | self.assertLess(xdot_right[7, 0], 0.0) |
| 262 | self.assertGreater(self.Ms[1](X, self.I)[0, 0], 0.0) |
| 263 | |
| 264 | self.assertGreater(xdot_right[10, 0], 0.1) |
| 265 | self.assertLess(xdot_right[11, 0], 0.0) |
| 266 | self.assertGreater(self.Ms[2](X, self.I)[0, 0], 0.0) |
| 267 | |
| 268 | self.assertGreater(xdot_right[14, 0], 0.1) |
| 269 | self.assertLess(xdot_right[15, 0], 0.0) |
| 270 | self.assertGreater(self.Ms[3](X, self.I)[0, 0], 0.0) |
| 271 | |
| 272 | self.assertGreater(xdot_right[19, 0], 0.0001) |
| 273 | self.assertLess(xdot_right[20, 0], -0.1) |
| 274 | # Shouldn't be spinning. |
| 275 | self.assertAlmostEqual(xdot_right[21, 0], 0.0) |
| 276 | |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 277 | def test_wheel_forces(self): |
| 278 | """Tests that the per module forces have the right signs.""" |
| 279 | for i in range(4): |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 280 | wheel_force = wrap_module(dynamics.wheel_force, i) |
| 281 | |
| 282 | X = state_vector() |
| 283 | robot_equal = wheel_force(X, self.I) |
| 284 | xdot_equal = self.swerve_physics(X, self.I) |
| 285 | self.assertEqual(robot_equal[0, 0], 0.0) |
| 286 | self.assertEqual(robot_equal[1, 0], 0.0) |
| 287 | self.assertEqual(xdot_equal[2 + 4 * i], 0.0) |
| 288 | self.assertEqual(xdot_equal[3 + 4 * i], 0.0) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 289 | |
| 290 | # Robot is moving faster than the wheels, it should decelerate. |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 291 | X = state_vector(dx=0.01) |
| 292 | robot_faster = wheel_force(X, self.I) |
| 293 | xdot_faster = self.swerve_physics(X, self.I) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 294 | self.assertLess(robot_faster[0, 0], -0.1) |
| 295 | self.assertEqual(robot_faster[1, 0], 0.0) |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 296 | self.assertGreater(xdot_faster[3 + 4 * i], 0.0) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 297 | |
| 298 | # Robot is now going slower than the wheels. It should accelerate. |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 299 | X = state_vector(dx=-0.01) |
| 300 | robot_slower = wheel_force(X, self.I) |
| 301 | xdot_slower = self.swerve_physics(X, self.I) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 302 | self.assertGreater(robot_slower[0, 0], 0.1) |
| 303 | self.assertEqual(robot_slower[1, 0], 0.0) |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 304 | self.assertLess(xdot_slower[3 + 4 * i], 0.0) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 305 | |
| 306 | # Positive lateral velocity -> negative force. |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 307 | robot_left = wheel_force(state_vector(dy=0.01), self.I) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 308 | self.assertEqual(robot_left[0, 0], 0.0) |
| 309 | self.assertLess(robot_left[1, 0], -0.1) |
| 310 | |
| 311 | # Negative lateral velocity -> positive force. |
Austin Schuh | b8b34be | 2024-07-14 16:06:19 -0700 | [diff] [blame^] | 312 | robot_right = wheel_force(state_vector(dy=-0.01), self.I) |
Austin Schuh | 2a1abec | 2024-07-10 20:31:16 -0700 | [diff] [blame] | 313 | self.assertEqual(robot_right[0, 0], 0.0) |
| 314 | self.assertGreater(robot_right[1, 0], 0.1) |
| 315 | |
| 316 | |
| 317 | if __name__ == '__main__': |
| 318 | unittest.main() |