Skip to content

Commit 3342874

Browse files
author
Longbin Tang
authored
Add UAV control examples and update CI workflow (#7)
* Add UAV geometric hover and thrust step response examples * Add UAV descent and hover example; remove thrust step response example * Remove PID control examples for second-order systems in both CPU and CUDA implementations * Add UAV geometric tracking example with PID-based control * Create python-publish.yml * Add testing step to Python package publish workflow * Fix formatting issue in Python setup action step * Refactor test structure: add step response test for second-order system and remove outdated examples * Update module docstring in test_torchcontrol.py for clarity and consistency
1 parent 0cbea23 commit 3342874

8 files changed

Lines changed: 481 additions & 0 deletions

File tree

149 KB
Loading
136 KB
Loading

examples/uav_geometric_hover.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""
2+
uav_geometric_hover.py
3+
Example: Geometric hover control of a batch quadrotor UAV using NonlinearSystem and a simple geometric controller.
4+
"""
5+
6+
import os
7+
import torch
8+
import numpy as np
9+
import matplotlib.pyplot as plt
10+
11+
from torchcontrol.plants.nonlinear_system import NonlinearSystem
12+
from torchcontrol.plants.nonlinear_system_cfg import NonlinearSystemCfg, Parameters
13+
from uav_thrust_descent_to_hover import uav_dynamics, uav_output # Use absolute import for script execution
14+
15+
16+
class GeometricHoverController:
17+
def __init__(self, m, g, dt, num_envs, device):
18+
self.m = m
19+
self.g = g
20+
self.dt = dt
21+
self.num_envs = num_envs
22+
self.device = device
23+
self.z_ref = torch.ones(num_envs, device=device) * 1.0 # hover at z=1.0
24+
self.kp = 8.0
25+
self.kd = 4.0
26+
27+
def step(self, x):
28+
# x: [num_envs, 10]
29+
z = x[:, 2]
30+
vz = x[:, 5]
31+
e = self.z_ref - z
32+
de = -vz
33+
# Use only z-axis gravity for thrust control
34+
u_thrust = self.m * (self.g[:, 2].abs() + self.kp * e + self.kd * de)
35+
u = torch.zeros(self.num_envs, 4, device=self.device)
36+
u[:, 0] = u_thrust
37+
return u
38+
39+
40+
if __name__ == "__main__":
41+
# Batch grid size
42+
height, width = 4, 4
43+
num_envs = height * width
44+
dt = 0.01
45+
device = "cuda" if torch.cuda.is_available() else "cpu"
46+
47+
# UAV parameters (consistent with uav_thrust_descent_to_hover.py)
48+
m = torch.full((num_envs,), 1.0, device=device)
49+
# Define gravity as a 3D vector and broadcast to (num_envs, 3)
50+
g = torch.tensor([0.0, 0.0, -9.81], device=device).expand(num_envs, 3)
51+
params = Parameters(m=m, g=g)
52+
53+
# Initial state: [x, y, z, vx, vy, vz, qw, qx, qy, qz]
54+
initial_state = torch.zeros(num_envs, 10, device=device)
55+
initial_state[:, 6] = 1.0 # Set quaternion to [1,0,0,0] for all envs
56+
initial_state[:, 5] = 1.0 # Set initial vz = 1.0 m/s for all envs (to see response)
57+
58+
# System configuration
59+
cfg = NonlinearSystemCfg(
60+
dynamics=uav_dynamics,
61+
output=uav_output,
62+
dt=dt,
63+
num_envs=num_envs,
64+
state_dim=10,
65+
action_dim=4,
66+
initial_state=initial_state,
67+
params=params,
68+
device=device,
69+
)
70+
print(f"\033[1;33mSystem configuration:\n{cfg}\033[0m")
71+
72+
plant = NonlinearSystem(cfg)
73+
controller = GeometricHoverController(m, g, dt, num_envs, device)
74+
75+
# Simulation parameters
76+
T = 5
77+
steps = int(T / dt)
78+
t_arr = [0.0]
79+
y = [initial_state]
80+
81+
# Main simulation loop
82+
for k in range(steps):
83+
u = controller.step(y[-1])
84+
output = plant.step(u)
85+
y.append(output)
86+
t_arr.append(t_arr[-1] + dt)
87+
88+
y = torch.stack(y, dim=1).cpu().numpy() # [num_envs, steps+1, state_dim]
89+
90+
# Plotting results
91+
save_dir = os.path.join(os.path.dirname(__file__), "results")
92+
os.makedirs(save_dir, exist_ok=True)
93+
fig, axes = plt.subplots(height, width, figsize=(12, 10))
94+
for idx in range(num_envs):
95+
i, j = np.unravel_index(idx, (height, width))
96+
ax = axes[i, j]
97+
ax.plot(t_arr, y[idx, :, 2], label='z (pos)')
98+
ax.plot(t_arr, y[idx, :, 5], label='vz (vel)', linestyle='--')
99+
ax.axhline(1.0, color='r', linestyle=':', label='z_ref')
100+
ax.set_title(f'Env {idx} Hover')
101+
ax.set_xlabel('Time (s)')
102+
ax.set_ylabel('State')
103+
ax.grid()
104+
ax.legend(fontsize=8)
105+
plt.tight_layout()
106+
fig_path = os.path.join(save_dir, "uav_geometric_hover.png")
107+
plt.savefig(fig_path)
108+
print("Geometric hover plot saved to:", fig_path)
109+
print("\033[1;32mTest completed successfully.\033[0m")

examples/uav_geometric_tracking.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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

Comments
 (0)