|
| 1 | +""" |
| 2 | +uav_geometric_tracking.py |
| 3 | +Example: Geometric tracking control of a batch quadrotor UAV using NonlinearSystem and a geometric controller (PID-based outer loop). |
| 4 | +Tracks a circular trajectory in the xy-plane for all batch environments. |
| 5 | +""" |
| 6 | + |
| 7 | +import os |
| 8 | +import torch |
| 9 | +import numpy as np |
| 10 | +import matplotlib.pyplot as plt |
| 11 | + |
| 12 | +from torchcontrol.plants.nonlinear_system import NonlinearSystem |
| 13 | +from torchcontrol.plants.nonlinear_system_cfg import NonlinearSystemCfg, Parameters |
| 14 | +from torchcontrol.utils.math import quaternion_to_dcm |
| 15 | +from torchcontrol.controllers.pid import PID |
| 16 | +from torchcontrol.controllers.pid_cfg import PIDCfg |
| 17 | +from uav_thrust_descent_to_hover import uav_dynamics, uav_output |
| 18 | + |
| 19 | +class GeometricTrackingController(PID): |
| 20 | + def __init__(self, m, g, dt, num_envs, device, kp=8.0, kd=4.0): |
| 21 | + # PID config for position+velocity (outer loop) |
| 22 | + state_dim = 3 # x, y, z |
| 23 | + action_dim = 3 # a_des (x, y, z) |
| 24 | + cfg = PIDCfg( |
| 25 | + Kp=kp * torch.ones(action_dim), |
| 26 | + Ki=torch.zeros(action_dim), |
| 27 | + Kd=kd * torch.ones(action_dim), |
| 28 | + u_ff=torch.zeros(action_dim), |
| 29 | + num_envs=num_envs, |
| 30 | + state_dim=state_dim, |
| 31 | + action_dim=action_dim, |
| 32 | + dt=dt, |
| 33 | + ) |
| 34 | + super().__init__(cfg) |
| 35 | + self.m = m |
| 36 | + self.g = g |
| 37 | + # Remove redundant .to(device) calls for Kp, Ki, Kd, u_ff |
| 38 | + self.e3 = torch.tensor([0.0, 0.0, 1.0], device=device).expand(num_envs, 3) |
| 39 | + self.kR = 4.0 # attitude gain |
| 40 | + |
| 41 | + def step(self, x, v, q, x_ref, v_ref, a_ref, yaw_ref, yaw_rate_ref): |
| 42 | + # Ensure all tensors are on the correct device |
| 43 | + device = self.device |
| 44 | + x = x.to(device) |
| 45 | + v = v.to(device) |
| 46 | + q = q.to(device) |
| 47 | + x_ref = x_ref.to(device) |
| 48 | + v_ref = v_ref.to(device) |
| 49 | + a_ref = a_ref.to(device) |
| 50 | + yaw_ref = yaw_ref.to(device) |
| 51 | + yaw_rate_ref = yaw_rate_ref.to(device) |
| 52 | + # 1. Outer loop: position+velocity PID to get a_des |
| 53 | + a_fb = self.forward(x, x_ref) + self.Kd[0, 0] * (v_ref - v) # batch PD |
| 54 | + a_des = a_fb + self.g + a_ref # (num_envs, 3) |
| 55 | + # 2. Desired body z axis |
| 56 | + b3_des = a_des / (a_des.norm(dim=1, keepdim=True) + 1e-6) |
| 57 | + # 3. Desired body x axis from yaw |
| 58 | + b1_des = torch.stack([torch.cos(yaw_ref), torch.sin(yaw_ref), torch.zeros_like(yaw_ref)], dim=1) |
| 59 | + # 4. Desired body y axis |
| 60 | + b2_des = torch.cross(b3_des, b1_des, dim=1) |
| 61 | + b2_des = b2_des / (b2_des.norm(dim=1, keepdim=True) + 1e-6) |
| 62 | + b1_des = torch.cross(b2_des, b3_des, dim=1) |
| 63 | + # 5. Desired rotation matrix |
| 64 | + R_des = torch.stack([b1_des, b2_des, b3_des], dim=2) # (num_envs, 3, 3) |
| 65 | + # 6. Current rotation matrix |
| 66 | + R = quaternion_to_dcm(q) |
| 67 | + # 7. Attitude error (SO(3) vee map) |
| 68 | + e_R_mat = 0.5 * (torch.matmul(R_des.transpose(1,2), R) - torch.matmul(R.transpose(1,2), R_des)) |
| 69 | + e_R = torch.stack([e_R_mat[:,2,1], e_R_mat[:,0,2], e_R_mat[:,1,0]], dim=1) |
| 70 | + # 8. Thrust (projected to body z) |
| 71 | + F = (self.m * (a_des * (R @ self.e3.unsqueeze(2)).squeeze(2)).sum(dim=1)).clamp(min=0.0) |
| 72 | + # 9. Omega_cmd (no feedforward, only feedback for hover/tracking) |
| 73 | + omega_cmd = self.kR * e_R # (num_envs, 3) |
| 74 | + # 10. Output action |
| 75 | + u = torch.zeros(self.num_envs, 4, device=self.device) |
| 76 | + u[:, 0] = F |
| 77 | + u[:, 1:4] = omega_cmd |
| 78 | + return u |
| 79 | + |
| 80 | +if __name__ == "__main__": |
| 81 | + # Batch grid size |
| 82 | + height, width = 4, 4 |
| 83 | + num_envs = height * width |
| 84 | + dt = 0.01 |
| 85 | + device = "cuda" if torch.cuda.is_available() else "cpu" |
| 86 | + m = torch.full((num_envs,), 1.0, device=device) |
| 87 | + g = torch.tensor([0.0, 0.0, -9.81], device=device).expand(num_envs, 3) |
| 88 | + params = Parameters(m=m, g=g) |
| 89 | + # Initial state: [x, y, z, vx, vy, vz, qw, qx, qy, qz] |
| 90 | + initial_state = torch.zeros(num_envs, 10, device=device) |
| 91 | + initial_state[:, 6] = 1.0 # Set quaternion to [1,0,0,0] for all envs |
| 92 | + initial_state[:, 2] = 0.0 # z=0 |
| 93 | + initial_state[:, 0] = 1.0 # x=1 (start on circle) |
| 94 | + # System configuration |
| 95 | + cfg = NonlinearSystemCfg( |
| 96 | + dynamics=uav_dynamics, |
| 97 | + output=uav_output, |
| 98 | + dt=dt, |
| 99 | + num_envs=num_envs, |
| 100 | + state_dim=10, |
| 101 | + action_dim=4, |
| 102 | + initial_state=initial_state, |
| 103 | + params=params, |
| 104 | + device=device, |
| 105 | + ) |
| 106 | + print(f"\033[1;33mSystem configuration:\n{cfg}\033[0m") |
| 107 | + plant = NonlinearSystem(cfg) |
| 108 | + controller = GeometricTrackingController(m, g, dt, num_envs, device) |
| 109 | + # Trajectory: circle in xy-plane, z=1.0 |
| 110 | + T = 10 |
| 111 | + steps = int(T / dt) |
| 112 | + t_arr = torch.linspace(0, T, steps+1, device=device) |
| 113 | + radius = 1.0 |
| 114 | + omega_traj = 2 * np.pi / T # one circle in T seconds |
| 115 | + x_ref = torch.stack([ |
| 116 | + radius * torch.cos(omega_traj * t_arr), |
| 117 | + radius * torch.sin(omega_traj * t_arr), |
| 118 | + torch.ones_like(t_arr) |
| 119 | + ], dim=1) # (steps+1, 3) |
| 120 | + v_ref = torch.stack([ |
| 121 | + -radius * omega_traj * torch.sin(omega_traj * t_arr), |
| 122 | + radius * omega_traj * torch.cos(omega_traj * t_arr), |
| 123 | + torch.zeros_like(t_arr) |
| 124 | + ], dim=1) |
| 125 | + a_ref = torch.stack([ |
| 126 | + -radius * omega_traj**2 * torch.cos(omega_traj * t_arr), |
| 127 | + -radius * omega_traj**2 * torch.sin(omega_traj * t_arr), |
| 128 | + torch.zeros_like(t_arr) |
| 129 | + ], dim=1) |
| 130 | + yaw_ref = omega_traj * t_arr # optional: face along tangent |
| 131 | + yaw_rate_ref = torch.full_like(t_arr, omega_traj) |
| 132 | + # Main simulation loop |
| 133 | + y = [initial_state] |
| 134 | + for k in range(steps): |
| 135 | + x = y[-1][:, 0:3] |
| 136 | + v = y[-1][:, 3:6] |
| 137 | + q = y[-1][:, 6:10] |
| 138 | + # batch reference for all envs |
| 139 | + x_ref_batch = x_ref[k].unsqueeze(0).expand(num_envs, 3) |
| 140 | + v_ref_batch = v_ref[k].unsqueeze(0).expand(num_envs, 3) |
| 141 | + a_ref_batch = a_ref[k].unsqueeze(0).expand(num_envs, 3) |
| 142 | + yaw_ref_batch = yaw_ref[k].repeat(num_envs) |
| 143 | + yaw_rate_ref_batch = yaw_rate_ref[k].repeat(num_envs) |
| 144 | + u = controller.step(x, v, q, x_ref_batch, v_ref_batch, a_ref_batch, yaw_ref_batch, yaw_rate_ref_batch) |
| 145 | + output = plant.step(u) |
| 146 | + y.append(output) |
| 147 | + y = torch.stack(y, dim=1).cpu().numpy() # [num_envs, steps+1, state_dim] |
| 148 | + # Plotting results |
| 149 | + save_dir = os.path.join(os.path.dirname(__file__), "results") |
| 150 | + os.makedirs(save_dir, exist_ok=True) |
| 151 | + fig, axes = plt.subplots(height, width, figsize=(12, 10)) |
| 152 | + for idx in range(num_envs): |
| 153 | + i, j = np.unravel_index(idx, (height, width)) |
| 154 | + ax = axes[i, j] |
| 155 | + ax.plot(y[idx, :, 0], y[idx, :, 1], label='xy (track)') |
| 156 | + ax.plot(x_ref[:, 0].cpu(), x_ref[:, 1].cpu(), 'r--', label='xy_ref') |
| 157 | + ax.set_title(f'Env {idx} Circle Track') |
| 158 | + ax.set_xlabel('x (m)') |
| 159 | + ax.set_ylabel('y (m)') |
| 160 | + ax.axis('equal') |
| 161 | + ax.grid() |
| 162 | + ax.legend(fontsize=8) |
| 163 | + plt.tight_layout() |
| 164 | + fig_path = os.path.join(save_dir, "uav_geometric_tracking.png") |
| 165 | + plt.savefig(fig_path) |
| 166 | + print("Geometric tracking plot saved to:", fig_path) |
| 167 | + print("\033[1;32mTracking test completed successfully.\033[0m") |
0 commit comments