|
| 1 | +""" |
| 2 | +Whole-Body QP Controller for Biped Walking using TSID + Pinocchio. |
| 3 | +
|
| 4 | +This example shows how to replace differential inverse kinematics with a |
| 5 | +torque-level whole-body QP controller. The pipeline becomes: |
| 6 | +
|
| 7 | + ZMP Preview Controller -> COM/foot reference trajectories |
| 8 | + -> TSID (this code) |
| 9 | + -> joint torques |
| 10 | +
|
| 11 | +Decision variables: joint accelerations (q_ddot) and contact forces (f_c) |
| 12 | +Equality constraint: M * q_ddot + h = S^T * tau + J_c^T * f_c |
| 13 | +Inequality constraints: friction cones, torque limits |
| 14 | +Cost: weighted COM tracking + foot tracking + posture regularization |
| 15 | +
|
| 16 | +Dependencies: |
| 17 | + pip install tsid pin numpy example-robot-data |
| 18 | + # or: conda install tsid pinocchio example-robot-data -c conda-forge |
| 19 | +""" |
| 20 | + |
| 21 | +import argparse |
| 22 | +from pathlib import Path |
| 23 | + |
| 24 | +import numpy as np |
| 25 | +import tsid |
| 26 | +import pinocchio as pin |
| 27 | + |
| 28 | + |
| 29 | +def set_joint(q, model, joint_name, val): |
| 30 | + jid = model.getJointId(joint_name) |
| 31 | + if jid > 0 and model.joints[jid].nq == 1: |
| 32 | + q[model.joints[jid].idx_q] = val |
| 33 | + |
| 34 | + |
| 35 | +def make_initial_configuration(model): |
| 36 | + q = pin.neutral(model) |
| 37 | + |
| 38 | + # Talos standing pose. Missing joints are ignored so this still works with |
| 39 | + # another URDF, where the neutral configuration is kept for unknown joints. |
| 40 | + set_joint(q, model, "leg_left_1_joint", 0.0) |
| 41 | + set_joint(q, model, "leg_left_2_joint", 0.0) |
| 42 | + set_joint(q, model, "leg_left_3_joint", -0.5) |
| 43 | + set_joint(q, model, "leg_left_4_joint", 1.0) |
| 44 | + set_joint(q, model, "leg_left_5_joint", -0.6) |
| 45 | + set_joint(q, model, "leg_right_1_joint", 0.0) |
| 46 | + set_joint(q, model, "leg_right_2_joint", 0.0) |
| 47 | + set_joint(q, model, "leg_right_3_joint", -0.5) |
| 48 | + set_joint(q, model, "leg_right_4_joint", 1.0) |
| 49 | + set_joint(q, model, "leg_right_5_joint", -0.6) |
| 50 | + set_joint(q, model, "arm_right_4_joint", -1.5) |
| 51 | + set_joint(q, model, "arm_left_4_joint", -1.5) |
| 52 | + |
| 53 | + return q |
| 54 | + |
| 55 | + |
| 56 | +def parse_args(): |
| 57 | + parser = argparse.ArgumentParser() |
| 58 | + parser.add_argument( |
| 59 | + "--model-root", |
| 60 | + type=Path, |
| 61 | + default=Path("/home/rdesarz/projects"), |
| 62 | + help="Folder used as the package root for meshes and relative URDF paths.", |
| 63 | + ) |
| 64 | + parser.add_argument( |
| 65 | + "--urdf", |
| 66 | + type=Path, |
| 67 | + default=Path("talos_data/urdf/talos_full.urdf"), |
| 68 | + help="URDF path. Relative paths are resolved from --model-root.", |
| 69 | + ) |
| 70 | + return parser.parse_args() |
| 71 | + |
| 72 | + |
| 73 | +# ============================================================================ |
| 74 | +# 1. ROBOT SETUP |
| 75 | +# ============================================================================ |
| 76 | + |
| 77 | +args = parse_args() |
| 78 | +model_root = args.model_root.expanduser().resolve() |
| 79 | +urdf_path = args.urdf.expanduser() |
| 80 | +if not urdf_path.is_absolute(): |
| 81 | + urdf_path = model_root / urdf_path |
| 82 | +urdf_path = urdf_path.resolve() |
| 83 | + |
| 84 | +if not urdf_path.is_file(): |
| 85 | + raise FileNotFoundError(f"URDF file not found: {urdf_path}") |
| 86 | + |
| 87 | +# Load a humanoid URDF (Talos by default, or pass --urdf for yours). |
| 88 | +full_model, full_col_model, full_vis_model = pin.buildModelsFromUrdf( |
| 89 | + str(urdf_path), str(model_root), pin.JointModelFreeFlyer() |
| 90 | +) |
| 91 | + |
| 92 | +# TSID RobotWrapper: loads URDF with a free-flyer joint (floating base) |
| 93 | +robot = tsid.RobotWrapper(str(urdf_path), [str(model_root)], pin.JointModelFreeFlyer(), False) |
| 94 | +model = robot.model() |
| 95 | +data = robot.data() |
| 96 | + |
| 97 | +# Frame names for Talos (adapt these to your URDF) |
| 98 | +LEFT_FOOT_FRAME = "left_sole_link" |
| 99 | +RIGHT_FOOT_FRAME = "right_sole_link" |
| 100 | + |
| 101 | +# Initial configuration: half-sitting / standing pose |
| 102 | +# Use a Talos standing pose when these joint names exist, otherwise neutral. |
| 103 | +q0 = make_initial_configuration(model) |
| 104 | +v0 = np.zeros(robot.nv) |
| 105 | + |
| 106 | +# ============================================================================ |
| 107 | +# 2. SOLVER SETUP |
| 108 | +# ============================================================================ |
| 109 | + |
| 110 | +dt = 1e-3 # Control timestep (1 kHz) |
| 111 | + |
| 112 | +# Create the inverse dynamics QP formulation |
| 113 | +# This sets up: min ||tasks||^2 s.t. dynamics + contacts |
| 114 | +invdyn = tsid.InverseDynamicsFormulationAccForce("tsid", robot, False) |
| 115 | +invdyn.computeProblemData(0.0, q0, v0) |
| 116 | + |
| 117 | +# ============================================================================ |
| 118 | +# 3. CONTACT SETUP (both feet on ground in double support) |
| 119 | +# ============================================================================ |
| 120 | + |
| 121 | +# Contact parameters |
| 122 | +mu = 0.5 # Friction coefficient |
| 123 | +f_min = 1.0 # Minimum normal force (N) -- prevents "pulling" on ground |
| 124 | +f_max = 1000.0 # Maximum normal force (N) |
| 125 | +contact_normal = np.array([0.0, 0.0, 1.0]) # Ground normal |
| 126 | + |
| 127 | +# Contact surface corners (rectangular foot, in local foot frame) |
| 128 | +# Adapt these dimensions to your robot's foot geometry |
| 129 | +lx = 0.10 # half-length along x |
| 130 | +ly = 0.05 # half-width along y |
| 131 | +contact_points = np.array([ |
| 132 | + [ lx, ly, 0.0], |
| 133 | + [ lx, -ly, 0.0], |
| 134 | + [-lx, -ly, 0.0], |
| 135 | + [-lx, ly, 0.0], |
| 136 | +]).T # Shape: (3, 4) |
| 137 | + |
| 138 | +# Gains for contact stabilization (keeps feet from drifting) |
| 139 | +kp_contact = 30.0 |
| 140 | +kd_contact = 2.0 * np.sqrt(kp_contact) |
| 141 | + |
| 142 | +# Weight for contact force regularization in the cost |
| 143 | +w_force_reg = 1e-5 |
| 144 | + |
| 145 | +# --- Left foot contact --- |
| 146 | +contact_left = tsid.Contact6d( |
| 147 | + "contact_left", robot, LEFT_FOOT_FRAME, |
| 148 | + contact_points, contact_normal, mu, f_min, f_max |
| 149 | +) |
| 150 | +contact_left.setKp(kp_contact * np.ones(6)) |
| 151 | +contact_left.setKd(kd_contact * np.ones(6)) |
| 152 | +H_left_ref = robot.framePosition(invdyn.data(), model.getFrameId(LEFT_FOOT_FRAME)) |
| 153 | +contact_left.setReference(H_left_ref) |
| 154 | +invdyn.addRigidContact(contact_left, w_force_reg, 1.0, 1) |
| 155 | + |
| 156 | +# --- Right foot contact --- |
| 157 | +contact_right = tsid.Contact6d( |
| 158 | + "contact_right", robot, RIGHT_FOOT_FRAME, |
| 159 | + contact_points, contact_normal, mu, f_min, f_max |
| 160 | +) |
| 161 | +contact_right.setKp(kp_contact * np.ones(6)) |
| 162 | +contact_right.setKd(kd_contact * np.ones(6)) |
| 163 | +H_right_ref = robot.framePosition(invdyn.data(), model.getFrameId(RIGHT_FOOT_FRAME)) |
| 164 | +contact_right.setReference(H_right_ref) |
| 165 | +invdyn.addRigidContact(contact_right, w_force_reg, 1.0, 1) |
| 166 | + |
| 167 | +# ============================================================================ |
| 168 | +# 4. TASK DEFINITIONS |
| 169 | +# ============================================================================ |
| 170 | + |
| 171 | +# --- COM tracking task --- |
| 172 | +# This is where your ZMP preview controller output goes |
| 173 | +kp_com = 30.0 |
| 174 | +kd_com = 2.0 * np.sqrt(kp_com) |
| 175 | +w_com = 1.0 # Weight in the cost |
| 176 | + |
| 177 | +com_task = tsid.TaskComEquality("task-com", robot) |
| 178 | +com_task.setKp(kp_com * np.ones(3)) |
| 179 | +com_task.setKd(kd_com * np.ones(3)) |
| 180 | +invdyn.addMotionTask(com_task, w_com, 1, 0.0) |
| 181 | + |
| 182 | +# Initial COM reference (will be overwritten by your trajectory generator) |
| 183 | +com_ref = robot.com(invdyn.data()) |
| 184 | +com_traj = tsid.TrajectoryEuclidianConstant("traj-com", com_ref) |
| 185 | + |
| 186 | +# --- Swing foot tracking task (SE3) --- |
| 187 | +# Only active during single support; add/remove dynamically |
| 188 | +kp_foot = 100.0 |
| 189 | +kd_foot = 2.0 * np.sqrt(kp_foot) |
| 190 | +w_foot = 1.0 |
| 191 | + |
| 192 | +swing_foot_task = tsid.TaskSE3Equality("task-swing-foot", robot, RIGHT_FOOT_FRAME) |
| 193 | +swing_foot_task.setKp(kp_foot * np.ones(6)) |
| 194 | +swing_foot_task.setKd(kd_foot * np.ones(6)) |
| 195 | +# Mask: track all 6 DoF (position + orientation). Set mask[3:6]=0 to ignore rotation. |
| 196 | +swing_foot_task.setMask(np.ones(6)) |
| 197 | +# NOTE: Don't add this task yet -- it's only active during single support. |
| 198 | + |
| 199 | +# --- Posture regularization task --- |
| 200 | +# Prevents the QP from producing wild joint motions |
| 201 | +kp_posture = 1.0 |
| 202 | +kd_posture = 2.0 * np.sqrt(kp_posture) |
| 203 | +w_posture = 0.1 # Low weight: posture is a soft objective |
| 204 | + |
| 205 | +posture_task = tsid.TaskJointPosture("task-posture", robot) |
| 206 | +posture_task.setKp(kp_posture * np.ones(robot.nv - 6)) # -6 for free-flyer |
| 207 | +posture_task.setKd(kd_posture * np.ones(robot.nv - 6)) |
| 208 | +invdyn.addMotionTask(posture_task, w_posture, 1, 0.0) |
| 209 | + |
| 210 | +# Posture reference: the standing configuration (minus free-flyer) |
| 211 | +q_posture_ref = q0[7:] # Remove 7 DoF free-flyer (pos + quat) |
| 212 | +sample_posture = tsid.TrajectorySample(robot.nv - 6) |
| 213 | +sample_posture.pos(q_posture_ref) |
| 214 | + |
| 215 | +# ============================================================================ |
| 216 | +# 5. QP SOLVER |
| 217 | +# ============================================================================ |
| 218 | + |
| 219 | +solver = tsid.SolverHQuadProgFast("solver-qp") |
| 220 | +solver.resize(invdyn.nVar, invdyn.nEq, invdyn.nIn) |
| 221 | + |
| 222 | +# ============================================================================ |
| 223 | +# 6. SIMULATION LOOP |
| 224 | +# ============================================================================ |
| 225 | + |
| 226 | +N_STEPS = 5000 # 5 seconds at 1 kHz |
| 227 | +q = q0.copy() |
| 228 | +v = v0.copy() |
| 229 | + |
| 230 | +# Storage for logging |
| 231 | +com_log = np.zeros((N_STEPS, 3)) |
| 232 | +tau_log = [] |
| 233 | + |
| 234 | +for i in range(N_STEPS): |
| 235 | + t = i * dt |
| 236 | + |
| 237 | + # ------------------------------------------------------------------ |
| 238 | + # 6a. SET REFERENCES FROM YOUR TRAJECTORY GENERATOR |
| 239 | + # ------------------------------------------------------------------ |
| 240 | + # Replace these with your actual ZMP preview controller outputs: |
| 241 | + # com_des[t], com_vel_des[t], com_acc_des[t] |
| 242 | + # swing_foot_pose_des[t], swing_foot_vel_des[t], swing_foot_acc_des[t] |
| 243 | + |
| 244 | + # Example: constant COM reference (just standing) |
| 245 | + sample_com = com_traj.computeNext() |
| 246 | + com_task.setReference(sample_com) |
| 247 | + |
| 248 | + # Example: constant posture reference |
| 249 | + posture_task.setReference(sample_posture) |
| 250 | + |
| 251 | + # ------------------------------------------------------------------ |
| 252 | + # 6b. CONTACT SWITCHING (for walking) |
| 253 | + # ------------------------------------------------------------------ |
| 254 | + # During single support, you need to: |
| 255 | + # 1. Remove the contact for the swing foot |
| 256 | + # 2. Add the swing foot tracking task |
| 257 | + # |
| 258 | + # Example (not active in this standing demo): |
| 259 | + # |
| 260 | + # # Entering single support (right foot swings): |
| 261 | + # invdyn.removeRigidContact(contact_right.name, transition_duration=0.0) |
| 262 | + # invdyn.addMotionTask(swing_foot_task, w_foot, 1, 0.0) |
| 263 | + # swing_foot_task.setReference(desired_foot_SE3_sample) |
| 264 | + # |
| 265 | + # # Entering double support (right foot lands): |
| 266 | + # invdyn.removeTask(swing_foot_task.name, transition_duration=0.0) |
| 267 | + # H_right_new = <desired landing pose> |
| 268 | + # contact_right.setReference(H_right_new) |
| 269 | + # invdyn.addRigidContact(contact_right, w_force_reg, 1.0, 1) |
| 270 | + |
| 271 | + # ------------------------------------------------------------------ |
| 272 | + # 6c. SOLVE THE QP |
| 273 | + # ------------------------------------------------------------------ |
| 274 | + HQP = invdyn.computeProblemData(t, q, v) |
| 275 | + sol = solver.solve(HQP) |
| 276 | + |
| 277 | + if sol.status != 0: |
| 278 | + print(f"[t={t:.3f}] QP solver failed with status {sol.status}") |
| 279 | + break |
| 280 | + |
| 281 | + # Extract joint torques from the solution |
| 282 | + tau = invdyn.getActuatorForces(sol) |
| 283 | + |
| 284 | + # Extract joint accelerations |
| 285 | + dv = invdyn.getAccelerations(sol) |
| 286 | + |
| 287 | + # ------------------------------------------------------------------ |
| 288 | + # 6d. INTEGRATE (simple Euler -- replace with your simulator) |
| 289 | + # ------------------------------------------------------------------ |
| 290 | + # In practice, send `tau` to your simulator (PyBullet, MuJoCo, etc.) |
| 291 | + # and read back q, v. Here we do a simple forward integration. |
| 292 | + v_next = v + dv * dt |
| 293 | + q_next = pin.integrate(model, q, v_next * dt) |
| 294 | + |
| 295 | + q = q_next |
| 296 | + v = v_next |
| 297 | + |
| 298 | + # ------------------------------------------------------------------ |
| 299 | + # 6e. LOG |
| 300 | + # ------------------------------------------------------------------ |
| 301 | + com_log[i] = robot.com(invdyn.data()) |
| 302 | + tau_log.append(tau.copy()) |
| 303 | + |
| 304 | + if i % 1000 == 0: |
| 305 | + com_pos = robot.com(invdyn.data()) |
| 306 | + print(f"[t={t:.3f}] COM: [{com_pos[0]:.4f}, {com_pos[1]:.4f}, {com_pos[2]:.4f}]") |
| 307 | + |
| 308 | + |
| 309 | +print("\nDone. Final COM:", robot.com(invdyn.data())) |
| 310 | +print(f"Torque vector dimension: {tau.shape[0]} (actuated joints)") |
| 311 | + |
| 312 | +# ============================================================================ |
| 313 | +# 7. HELPER: HOW TO SET SE3 REFERENCES FOR SWING FOOT |
| 314 | +# ============================================================================ |
| 315 | + |
| 316 | +def make_se3_sample(position, orientation_matrix, velocity=None, acceleration=None): |
| 317 | + """ |
| 318 | + Build a TrajectorySample for TaskSE3Equality from position + rotation. |
| 319 | +
|
| 320 | + TSID expects a 12-dim pos vector: [translation(3), rotation_matrix_flat(9)] |
| 321 | + and 6-dim vel/acc vectors: [linear(3), angular(3)] |
| 322 | + """ |
| 323 | + sample = tsid.TrajectorySample(12, 6) |
| 324 | + |
| 325 | + pos = np.zeros(12) |
| 326 | + pos[:3] = position |
| 327 | + pos[3:] = orientation_matrix.flatten() # Column-major |
| 328 | + sample.pos(pos) |
| 329 | + |
| 330 | + if velocity is not None: |
| 331 | + sample.vel(velocity) # 6D: [v_linear, omega] |
| 332 | + if acceleration is not None: |
| 333 | + sample.acc(acceleration) |
| 334 | + |
| 335 | + return sample |
| 336 | + |
| 337 | + |
| 338 | +# Example usage for a swing foot trajectory point: |
| 339 | +# |
| 340 | +# foot_pos = np.array([0.0, -0.1, 0.05]) # Foot lifted 5cm |
| 341 | +# foot_rot = np.eye(3) # Flat orientation |
| 342 | +# foot_vel = np.zeros(6) |
| 343 | +# foot_acc = np.zeros(6) |
| 344 | +# sample = make_se3_sample(foot_pos, foot_rot, foot_vel, foot_acc) |
0 commit comments