drones-sim 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- drones_sim/__init__.py +15 -0
- drones_sim/control/__init__.py +15 -0
- drones_sim/control/allocation.py +76 -0
- drones_sim/control/cascaded.py +204 -0
- drones_sim/control/geometric.py +170 -0
- drones_sim/control/lqr.py +174 -0
- drones_sim/control/pid.py +50 -0
- drones_sim/dynamics/__init__.py +13 -0
- drones_sim/dynamics/config.py +117 -0
- drones_sim/dynamics/disturbances.py +262 -0
- drones_sim/dynamics/quadcopter.py +317 -0
- drones_sim/estimation/__init__.py +2 -0
- drones_sim/estimation/ahrs.py +79 -0
- drones_sim/estimation/ekf.py +594 -0
- drones_sim/logging/__init__.py +13 -0
- drones_sim/logging/csv_logger.py +76 -0
- drones_sim/logging/json_logger.py +53 -0
- drones_sim/math_utils.py +119 -0
- drones_sim/models/__init__.py +21 -0
- drones_sim/models/quadcopter.urdf +296 -0
- drones_sim/models/urdf_loader.py +444 -0
- drones_sim/rl/__init__.py +16 -0
- drones_sim/rl/actions.py +219 -0
- drones_sim/rl/env.py +195 -0
- drones_sim/rl/observations.py +40 -0
- drones_sim/rl/reward.py +69 -0
- drones_sim/rl/tasks.py +73 -0
- drones_sim/sensors/__init__.py +3 -0
- drones_sim/sensors/gps.py +158 -0
- drones_sim/sensors/imu.py +196 -0
- drones_sim/sensors/models.py +113 -0
- drones_sim/simulation.py +283 -0
- drones_sim/state.py +133 -0
- drones_sim/trajectory.py +391 -0
- drones_sim/visualization/__init__.py +23 -0
- drones_sim/visualization/api.py +50 -0
- drones_sim/visualization/dashboard.py +74 -0
- drones_sim/visualization/plots.py +183 -0
- drones_sim/visualization/rerun_viewer.py +411 -0
- drones_sim/visualization/viewer.py +452 -0
- drones_sim-0.2.0.dist-info/METADATA +323 -0
- drones_sim-0.2.0.dist-info/RECORD +45 -0
- drones_sim-0.2.0.dist-info/WHEEL +5 -0
- drones_sim-0.2.0.dist-info/licenses/LICENSE +21 -0
- drones_sim-0.2.0.dist-info/top_level.txt +1 -0
drones_sim/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Composable quadcopter modeling, control, estimation, and visualization."""
|
|
2
|
+
|
|
3
|
+
from .simulation import ClosedLoopSimulator, SimulationConfig, SimulationResult
|
|
4
|
+
from .state import ControlOutput, TrajectorySetpoint, VehicleState
|
|
5
|
+
from .visualization import visualize
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ClosedLoopSimulator",
|
|
9
|
+
"ControlOutput",
|
|
10
|
+
"SimulationConfig",
|
|
11
|
+
"SimulationResult",
|
|
12
|
+
"TrajectorySetpoint",
|
|
13
|
+
"VehicleState",
|
|
14
|
+
"visualize",
|
|
15
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .allocation import AllocationResult, ControlAllocator
|
|
2
|
+
from .cascaded import QuadcopterController
|
|
3
|
+
from .geometric import GeometricController, GeometricControllerConfig
|
|
4
|
+
from .lqr import LQRController
|
|
5
|
+
from .pid import PIDController
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AllocationResult",
|
|
9
|
+
"ControlAllocator",
|
|
10
|
+
"GeometricController",
|
|
11
|
+
"GeometricControllerConfig",
|
|
12
|
+
"LQRController",
|
|
13
|
+
"PIDController",
|
|
14
|
+
"QuadcopterController",
|
|
15
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Bounded control allocation from body wrench to rotor speeds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
from scipy.optimize import lsq_linear
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AllocationResult:
|
|
14
|
+
motor_speeds: NDArray
|
|
15
|
+
achieved_wrench: NDArray
|
|
16
|
+
saturated: bool
|
|
17
|
+
residual_norm: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ControlAllocator:
|
|
21
|
+
"""Weighted, bounded least-squares rotor allocator.
|
|
22
|
+
|
|
23
|
+
Solving in squared-speed space respects the one-sided nature of propeller
|
|
24
|
+
thrust. Unlike inverse-then-clip allocation, it redistributes an infeasible
|
|
25
|
+
request over the remaining authority instead of silently changing all four
|
|
26
|
+
wrench axes.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
allocation_matrix: NDArray,
|
|
32
|
+
min_speed: float = 0.0,
|
|
33
|
+
max_speed: float = 4000.0,
|
|
34
|
+
weights: NDArray | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.matrix = np.asarray(allocation_matrix, dtype=float).copy()
|
|
37
|
+
if self.matrix.shape != (4, 4):
|
|
38
|
+
raise ValueError("allocation_matrix must have shape (4, 4)")
|
|
39
|
+
self.min_speed = float(min_speed)
|
|
40
|
+
self.max_speed = float(max_speed)
|
|
41
|
+
if not 0.0 <= self.min_speed < self.max_speed:
|
|
42
|
+
raise ValueError("motor speed limits are invalid")
|
|
43
|
+
self.weights = np.asarray(
|
|
44
|
+
np.ones(4) if weights is None else weights, dtype=float
|
|
45
|
+
)
|
|
46
|
+
if self.weights.shape != (4,) or np.any(self.weights <= 0.0):
|
|
47
|
+
raise ValueError("weights must be a positive shape-(4,) vector")
|
|
48
|
+
|
|
49
|
+
def allocate(self, wrench: NDArray) -> AllocationResult:
|
|
50
|
+
desired = np.asarray(wrench, dtype=float)
|
|
51
|
+
if desired.shape != (4,) or not np.all(np.isfinite(desired)):
|
|
52
|
+
raise ValueError("wrench must be a finite shape-(4,) vector")
|
|
53
|
+
weighted_matrix = self.weights[:, None] * self.matrix
|
|
54
|
+
weighted_wrench = self.weights * desired
|
|
55
|
+
solution = lsq_linear(
|
|
56
|
+
weighted_matrix,
|
|
57
|
+
weighted_wrench,
|
|
58
|
+
bounds=(self.min_speed**2, self.max_speed**2),
|
|
59
|
+
method="bvls",
|
|
60
|
+
tol=1e-10,
|
|
61
|
+
)
|
|
62
|
+
squared_speeds = np.maximum(solution.x, 0.0)
|
|
63
|
+
motor_speeds = np.sqrt(squared_speeds)
|
|
64
|
+
achieved = self.matrix @ squared_speeds
|
|
65
|
+
tolerance = 1e-7 * max(1.0, self.max_speed**2)
|
|
66
|
+
saturated = bool(
|
|
67
|
+
np.any(squared_speeds <= self.min_speed**2 + tolerance)
|
|
68
|
+
or np.any(squared_speeds >= self.max_speed**2 - tolerance)
|
|
69
|
+
or np.linalg.norm(achieved - desired) > 1e-5
|
|
70
|
+
)
|
|
71
|
+
return AllocationResult(
|
|
72
|
+
motor_speeds=motor_speeds,
|
|
73
|
+
achieved_wrench=achieved,
|
|
74
|
+
saturated=saturated,
|
|
75
|
+
residual_norm=float(np.linalg.norm(achieved - desired)),
|
|
76
|
+
)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Cascaded PID controller for quadcopter position→velocity→attitude control.
|
|
2
|
+
|
|
3
|
+
Consolidated from quadcopter_simulation.py.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import NDArray
|
|
10
|
+
|
|
11
|
+
from ..dynamics.quadcopter import QuadcopterDynamics
|
|
12
|
+
from .allocation import ControlAllocator
|
|
13
|
+
from .pid import PIDController
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class QuadcopterController:
|
|
17
|
+
"""Three-loop cascaded controller with physically consistent outputs."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, quad: QuadcopterDynamics):
|
|
20
|
+
self.quad = quad
|
|
21
|
+
self.convergence_threshold = 0.05
|
|
22
|
+
self.max_lateral_speed = 3.0 # increased from 1.5 for trajectory tracking
|
|
23
|
+
self.max_vertical_speed = 1.0
|
|
24
|
+
self.max_lateral_accel = 5.0 # increased from 3.0
|
|
25
|
+
self.max_vertical_accel = 4.0
|
|
26
|
+
self.max_tilt = np.deg2rad(25.0) # increased from 17° — was saturating 74% of the time
|
|
27
|
+
self.max_torque = np.array([0.25, 0.25, 0.08])
|
|
28
|
+
self.rate_damping = np.array([0.03, 0.03, 0.015])
|
|
29
|
+
self.min_thrust = 0.2 * self.quad.mass * self.quad.g
|
|
30
|
+
self.max_thrust = 2.0 * self.quad.mass * self.quad.g
|
|
31
|
+
self.max_motor_speed = 4000.0
|
|
32
|
+
|
|
33
|
+
# -- Position (outer) --
|
|
34
|
+
# Wider output limits match new max_lateral_speed; higher kp+kd for
|
|
35
|
+
# faster acquisition of a moving reference.
|
|
36
|
+
self.x_ctrl = PIDController(1.8, 0.1, 0.5, (-3.0, 3.0), (-1.0, 1.0))
|
|
37
|
+
self.y_ctrl = PIDController(1.8, 0.1, 0.5, (-3.0, 3.0), (-1.0, 1.0))
|
|
38
|
+
self.z_ctrl = PIDController(1.6, 0.25, 0.45, (-1.0, 1.0), (-0.8, 0.8))
|
|
39
|
+
|
|
40
|
+
# -- Velocity (middle) --
|
|
41
|
+
# Higher kp and wider limits feed more accel demand to the attitude loop.
|
|
42
|
+
self.vx_ctrl = PIDController(3.0, 0.4, 0.3, (-5.0, 5.0), (-1.0, 1.0))
|
|
43
|
+
self.vy_ctrl = PIDController(3.0, 0.4, 0.3, (-5.0, 5.0), (-1.0, 1.0))
|
|
44
|
+
self.vz_ctrl = PIDController(5.0, 1.5, 0.3, (-4.0, 4.0), (-1.0, 1.0))
|
|
45
|
+
|
|
46
|
+
# -- Attitude (inner, torque outputs in N·m) --
|
|
47
|
+
# kp doubled + kd added so the drone reaches the commanded tilt quickly;
|
|
48
|
+
# without this, the attitude loop is the bottleneck at high tilt angles.
|
|
49
|
+
self.roll_ctrl = PIDController(
|
|
50
|
+
0.25,
|
|
51
|
+
0.03,
|
|
52
|
+
0.08,
|
|
53
|
+
(-0.25, 0.25),
|
|
54
|
+
(-0.15, 0.15),
|
|
55
|
+
)
|
|
56
|
+
self.pitch_ctrl = PIDController(
|
|
57
|
+
0.25,
|
|
58
|
+
0.03,
|
|
59
|
+
0.08,
|
|
60
|
+
(-0.25, 0.25),
|
|
61
|
+
(-0.15, 0.15),
|
|
62
|
+
)
|
|
63
|
+
self.yaw_ctrl = PIDController(
|
|
64
|
+
0.05,
|
|
65
|
+
0.01,
|
|
66
|
+
0.0,
|
|
67
|
+
(-0.08, 0.08),
|
|
68
|
+
(-0.08, 0.08),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self._prev_target_vel = np.zeros(3)
|
|
72
|
+
|
|
73
|
+
def reset(self) -> None:
|
|
74
|
+
for ctrl in [
|
|
75
|
+
self.x_ctrl, self.y_ctrl, self.z_ctrl,
|
|
76
|
+
self.vx_ctrl, self.vy_ctrl, self.vz_ctrl,
|
|
77
|
+
self.roll_ctrl, self.pitch_ctrl, self.yaw_ctrl,
|
|
78
|
+
]:
|
|
79
|
+
ctrl.reset()
|
|
80
|
+
self._prev_target_vel = np.zeros(3)
|
|
81
|
+
|
|
82
|
+
def compute(
|
|
83
|
+
self,
|
|
84
|
+
target_pos: NDArray,
|
|
85
|
+
target_yaw: float,
|
|
86
|
+
dt: float,
|
|
87
|
+
prev_target_pos: NDArray | None = None,
|
|
88
|
+
) -> NDArray:
|
|
89
|
+
"""Compute 4 motor speeds for position + yaw tracking.
|
|
90
|
+
|
|
91
|
+
Returns motor speeds as a (4,) array.
|
|
92
|
+
"""
|
|
93
|
+
pos = self.quad.get_position()
|
|
94
|
+
vel = self.quad.get_velocity()
|
|
95
|
+
att = self.quad.get_attitude()
|
|
96
|
+
omega = self.quad.get_angular_velocity()
|
|
97
|
+
|
|
98
|
+
target_vel_ff = np.zeros(3)
|
|
99
|
+
if prev_target_pos is not None:
|
|
100
|
+
target_vel_ff = (target_pos - prev_target_pos) / max(dt, 1e-6)
|
|
101
|
+
target_vel_ff = np.clip(
|
|
102
|
+
target_vel_ff,
|
|
103
|
+
[
|
|
104
|
+
-self.max_lateral_speed,
|
|
105
|
+
-self.max_lateral_speed,
|
|
106
|
+
-self.max_vertical_speed,
|
|
107
|
+
],
|
|
108
|
+
[
|
|
109
|
+
self.max_lateral_speed,
|
|
110
|
+
self.max_lateral_speed,
|
|
111
|
+
self.max_vertical_speed,
|
|
112
|
+
],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Position -> desired velocity (m/s)
|
|
116
|
+
target_vel = np.array([
|
|
117
|
+
self.x_ctrl.update(target_pos[0], pos[0], dt),
|
|
118
|
+
self.y_ctrl.update(target_pos[1], pos[1], dt),
|
|
119
|
+
self.z_ctrl.update(target_pos[2], pos[2], dt),
|
|
120
|
+
])
|
|
121
|
+
# High FF coefficient (0.9) directly injects trajectory velocity so
|
|
122
|
+
# the drone doesn't have to build up a large position error first.
|
|
123
|
+
target_vel += np.array([0.9, 0.9, 0.15]) * target_vel_ff
|
|
124
|
+
target_vel = np.clip(
|
|
125
|
+
target_vel,
|
|
126
|
+
[
|
|
127
|
+
-self.max_lateral_speed,
|
|
128
|
+
-self.max_lateral_speed,
|
|
129
|
+
-self.max_vertical_speed,
|
|
130
|
+
],
|
|
131
|
+
[
|
|
132
|
+
self.max_lateral_speed,
|
|
133
|
+
self.max_lateral_speed,
|
|
134
|
+
self.max_vertical_speed,
|
|
135
|
+
],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Velocity -> desired acceleration (m/s^2)
|
|
139
|
+
desired_accel = np.array([
|
|
140
|
+
self.vx_ctrl.update(target_vel[0], vel[0], dt),
|
|
141
|
+
self.vy_ctrl.update(target_vel[1], vel[1], dt),
|
|
142
|
+
self.vz_ctrl.update(target_vel[2], vel[2], dt),
|
|
143
|
+
])
|
|
144
|
+
target_acc_ff = (target_vel - self._prev_target_vel) / max(dt, 1e-6)
|
|
145
|
+
self._prev_target_vel = target_vel.copy()
|
|
146
|
+
desired_accel += np.array([0.3, 0.3, 0.05]) * target_acc_ff
|
|
147
|
+
desired_accel[:2] = np.clip(
|
|
148
|
+
desired_accel[:2],
|
|
149
|
+
-self.max_lateral_accel,
|
|
150
|
+
self.max_lateral_accel,
|
|
151
|
+
)
|
|
152
|
+
desired_accel[2] = np.clip(
|
|
153
|
+
desired_accel[2],
|
|
154
|
+
-self.max_vertical_accel,
|
|
155
|
+
self.max_vertical_accel,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Small-angle mapping with package rotation convention:
|
|
159
|
+
# positive pitch -> +x acceleration, positive roll -> -y acceleration.
|
|
160
|
+
des_pitch = np.clip(
|
|
161
|
+
desired_accel[0] / self.quad.g,
|
|
162
|
+
-self.max_tilt,
|
|
163
|
+
self.max_tilt,
|
|
164
|
+
)
|
|
165
|
+
des_roll = np.clip(
|
|
166
|
+
-desired_accel[1] / self.quad.g,
|
|
167
|
+
-self.max_tilt,
|
|
168
|
+
self.max_tilt,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
thrust = self.quad.mass * (self.quad.g + desired_accel[2])
|
|
172
|
+
thrust /= max(np.cos(att[0]) * np.cos(att[1]), 0.7)
|
|
173
|
+
thrust = float(np.clip(thrust, self.min_thrust, self.max_thrust))
|
|
174
|
+
|
|
175
|
+
# Attitude -> torques (N·m) with angular-rate damping.
|
|
176
|
+
roll_tau = self.roll_ctrl.update(
|
|
177
|
+
des_roll,
|
|
178
|
+
att[0],
|
|
179
|
+
dt,
|
|
180
|
+
) - self.rate_damping[0] * omega[0]
|
|
181
|
+
pitch_tau = self.pitch_ctrl.update(
|
|
182
|
+
des_pitch,
|
|
183
|
+
att[1],
|
|
184
|
+
dt,
|
|
185
|
+
) - self.rate_damping[1] * omega[1]
|
|
186
|
+
yaw_tau = self.yaw_ctrl.update(
|
|
187
|
+
target_yaw,
|
|
188
|
+
att[2],
|
|
189
|
+
dt,
|
|
190
|
+
) - self.rate_damping[2] * omega[2]
|
|
191
|
+
torques = np.clip(
|
|
192
|
+
np.array([roll_tau, pitch_tau, yaw_tau]),
|
|
193
|
+
-self.max_torque,
|
|
194
|
+
self.max_torque,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
desired_wrench = np.array([thrust, torques[0], torques[1], torques[2]])
|
|
198
|
+
|
|
199
|
+
allocator = ControlAllocator(
|
|
200
|
+
self.quad.allocation_matrix(),
|
|
201
|
+
min_speed=self.quad.min_motor_speed,
|
|
202
|
+
max_speed=min(self.max_motor_speed, self.quad.max_motor_speed),
|
|
203
|
+
)
|
|
204
|
+
return allocator.allocate(desired_wrench).motor_speeds
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Coordinate-free SE(3) position and attitude controller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
|
|
10
|
+
from ..state import ControlOutput, TrajectorySetpoint, VehicleState
|
|
11
|
+
from .allocation import ControlAllocator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class GeometricControllerConfig:
|
|
16
|
+
position_gain: NDArray = field(
|
|
17
|
+
default_factory=lambda: np.array([3.0, 3.0, 6.0])
|
|
18
|
+
)
|
|
19
|
+
velocity_gain: NDArray = field(
|
|
20
|
+
default_factory=lambda: np.array([3.0, 3.0, 4.0])
|
|
21
|
+
)
|
|
22
|
+
integral_gain: NDArray = field(
|
|
23
|
+
default_factory=lambda: np.array([0.02, 0.02, 0.10])
|
|
24
|
+
)
|
|
25
|
+
attitude_gain: NDArray = field(
|
|
26
|
+
default_factory=lambda: np.array([0.22, 0.22, 0.08])
|
|
27
|
+
)
|
|
28
|
+
rate_gain: NDArray = field(
|
|
29
|
+
default_factory=lambda: np.array([0.06, 0.06, 0.025])
|
|
30
|
+
)
|
|
31
|
+
integral_limit: NDArray = field(
|
|
32
|
+
default_factory=lambda: np.array([2.0, 2.0, 1.0])
|
|
33
|
+
)
|
|
34
|
+
max_tilt: float = np.deg2rad(45.0)
|
|
35
|
+
min_thrust_ratio: float = 0.05
|
|
36
|
+
max_thrust_ratio: float = 2.5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _vee(skew: NDArray) -> NDArray:
|
|
40
|
+
return np.array([skew[2, 1], skew[0, 2], skew[1, 0]])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GeometricController:
|
|
44
|
+
"""Nonlinear trajectory controller on SE(3).
|
|
45
|
+
|
|
46
|
+
Position feedback produces a desired world-frame force. Its direction and
|
|
47
|
+
the requested yaw define the full desired attitude, avoiding Euler-angle
|
|
48
|
+
singularities and the small-angle approximation used by the legacy PID.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, quad, config: GeometricControllerConfig | None = None):
|
|
52
|
+
self.quad = quad
|
|
53
|
+
self.config = config or GeometricControllerConfig()
|
|
54
|
+
self._integral_error = np.zeros(3)
|
|
55
|
+
self._previous_target: NDArray | None = None
|
|
56
|
+
|
|
57
|
+
def reset(self) -> None:
|
|
58
|
+
self._integral_error.fill(0.0)
|
|
59
|
+
self._previous_target = None
|
|
60
|
+
|
|
61
|
+
def compute_output(
|
|
62
|
+
self,
|
|
63
|
+
setpoint: TrajectorySetpoint,
|
|
64
|
+
dt: float,
|
|
65
|
+
state: VehicleState | None = None,
|
|
66
|
+
) -> ControlOutput:
|
|
67
|
+
if dt <= 0.0:
|
|
68
|
+
raise ValueError("dt must be positive")
|
|
69
|
+
current = self.quad.get_state() if state is None else state
|
|
70
|
+
cfg = self.config
|
|
71
|
+
mass = self.quad.mass
|
|
72
|
+
gravity = self.quad.g
|
|
73
|
+
|
|
74
|
+
position_error = setpoint.position - current.position
|
|
75
|
+
velocity_error = setpoint.velocity - current.velocity
|
|
76
|
+
self._integral_error += position_error * dt
|
|
77
|
+
self._integral_error = np.clip(
|
|
78
|
+
self._integral_error, -cfg.integral_limit, cfg.integral_limit
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
desired_accel = (
|
|
82
|
+
setpoint.acceleration
|
|
83
|
+
+ cfg.position_gain * position_error
|
|
84
|
+
+ cfg.velocity_gain * velocity_error
|
|
85
|
+
+ cfg.integral_gain * self._integral_error
|
|
86
|
+
)
|
|
87
|
+
desired_force = mass * (desired_accel + np.array([0.0, 0.0, gravity]))
|
|
88
|
+
|
|
89
|
+
# Enforce a tilt cone by limiting the horizontal/vertical force ratio.
|
|
90
|
+
vertical = max(float(desired_force[2]), 1e-6)
|
|
91
|
+
horizontal = desired_force[:2]
|
|
92
|
+
horizontal_norm = float(np.linalg.norm(horizontal))
|
|
93
|
+
max_horizontal = vertical * np.tan(cfg.max_tilt)
|
|
94
|
+
if horizontal_norm > max_horizontal:
|
|
95
|
+
desired_force[:2] *= max_horizontal / horizontal_norm
|
|
96
|
+
|
|
97
|
+
force_norm = float(np.linalg.norm(desired_force))
|
|
98
|
+
if force_norm < 1e-9:
|
|
99
|
+
desired_b3 = np.array([0.0, 0.0, 1.0])
|
|
100
|
+
else:
|
|
101
|
+
desired_b3 = desired_force / force_norm
|
|
102
|
+
heading = np.array([np.cos(setpoint.yaw), np.sin(setpoint.yaw), 0.0])
|
|
103
|
+
desired_b2 = np.cross(desired_b3, heading)
|
|
104
|
+
if np.linalg.norm(desired_b2) < 1e-8:
|
|
105
|
+
desired_b2 = np.array([-np.sin(setpoint.yaw), np.cos(setpoint.yaw), 0.0])
|
|
106
|
+
desired_b2 /= np.linalg.norm(desired_b2)
|
|
107
|
+
desired_b1 = np.cross(desired_b2, desired_b3)
|
|
108
|
+
desired_rotation = np.column_stack([desired_b1, desired_b2, desired_b3])
|
|
109
|
+
|
|
110
|
+
rotation = current.rotation_matrix
|
|
111
|
+
attitude_error = 0.5 * _vee(
|
|
112
|
+
desired_rotation.T @ rotation - rotation.T @ desired_rotation
|
|
113
|
+
)
|
|
114
|
+
desired_rates = desired_rotation.T @ np.array(
|
|
115
|
+
[0.0, 0.0, setpoint.yaw_rate]
|
|
116
|
+
)
|
|
117
|
+
rate_error = current.body_rates - rotation.T @ desired_rotation @ desired_rates
|
|
118
|
+
|
|
119
|
+
thrust = float(np.dot(desired_force, rotation[:, 2]))
|
|
120
|
+
hover = mass * gravity
|
|
121
|
+
thrust = float(
|
|
122
|
+
np.clip(
|
|
123
|
+
thrust,
|
|
124
|
+
cfg.min_thrust_ratio * hover,
|
|
125
|
+
cfg.max_thrust_ratio * hover,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
inertia = self.quad.I
|
|
129
|
+
torque = (
|
|
130
|
+
-cfg.attitude_gain * attitude_error
|
|
131
|
+
- cfg.rate_gain * rate_error
|
|
132
|
+
+ np.cross(current.body_rates, inertia @ current.body_rates)
|
|
133
|
+
)
|
|
134
|
+
requested_wrench = np.concatenate([[thrust], torque])
|
|
135
|
+
allocator = ControlAllocator(
|
|
136
|
+
self.quad.allocation_matrix(),
|
|
137
|
+
min_speed=self.quad.min_motor_speed,
|
|
138
|
+
max_speed=self.quad.max_motor_speed,
|
|
139
|
+
weights=np.array([1.0, 5.0, 5.0, 2.0]),
|
|
140
|
+
)
|
|
141
|
+
allocation = allocator.allocate(requested_wrench)
|
|
142
|
+
if allocation.saturated:
|
|
143
|
+
# Back-calculation prevents integral accumulation when thrust is limited.
|
|
144
|
+
self._integral_error *= 0.995
|
|
145
|
+
return ControlOutput(
|
|
146
|
+
thrust=thrust,
|
|
147
|
+
torque=torque,
|
|
148
|
+
motor_speeds=allocation.motor_speeds,
|
|
149
|
+
achieved_wrench=allocation.achieved_wrench,
|
|
150
|
+
saturated=allocation.saturated,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def compute(
|
|
154
|
+
self,
|
|
155
|
+
target_pos: NDArray,
|
|
156
|
+
target_yaw: float,
|
|
157
|
+
dt: float,
|
|
158
|
+
prev_target_pos: NDArray | None = None,
|
|
159
|
+
) -> NDArray:
|
|
160
|
+
"""Compatibility interface shared with the legacy controllers."""
|
|
161
|
+
velocity = np.zeros(3)
|
|
162
|
+
if prev_target_pos is not None:
|
|
163
|
+
velocity = (np.asarray(target_pos) - prev_target_pos) / dt
|
|
164
|
+
output = self.compute_output(
|
|
165
|
+
TrajectorySetpoint(
|
|
166
|
+
position=np.asarray(target_pos), velocity=velocity, yaw=target_yaw
|
|
167
|
+
),
|
|
168
|
+
dt,
|
|
169
|
+
)
|
|
170
|
+
return output.motor_speeds
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""LQR controller for quadcopter stabilization.
|
|
2
|
+
|
|
3
|
+
Linearizes the plant around hover and solves the continuous-time Algebraic
|
|
4
|
+
Riccati Equation to obtain the full-state feedback gain K.
|
|
5
|
+
|
|
6
|
+
Control law: u_delta = -K @ x_error
|
|
7
|
+
Wrench: [T, tau_phi, tau_theta, tau_psi] = hover_wrench + u_delta
|
|
8
|
+
Motors: allocation_matrix inverse maps wrench -> motor speeds
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from numpy.typing import NDArray
|
|
15
|
+
from scipy.linalg import solve_continuous_are
|
|
16
|
+
|
|
17
|
+
from ..dynamics.quadcopter import QuadcopterDynamics
|
|
18
|
+
from .allocation import ControlAllocator
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LQRController:
|
|
22
|
+
"""Full-state feedback LQR for quadcopter position and attitude control.
|
|
23
|
+
|
|
24
|
+
The linearization is computed once at construction around the hover trim
|
|
25
|
+
point (phi=theta=0, T=mg). The resulting gain K is constant (offline
|
|
26
|
+
optimal).
|
|
27
|
+
|
|
28
|
+
Interface matches QuadcopterController.compute() so both can be used
|
|
29
|
+
interchangeably in simulation loops:
|
|
30
|
+
|
|
31
|
+
motors = lqr.compute(target_pos, target_yaw, dt, prev_target_pos)
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
quad:
|
|
36
|
+
Live QuadcopterDynamics instance (reads state each step).
|
|
37
|
+
Q:
|
|
38
|
+
12x12 state-error penalty matrix. Defaults to a diagonal matrix
|
|
39
|
+
that penalizes position and attitude tracking most.
|
|
40
|
+
R:
|
|
41
|
+
4x4 control-effort penalty matrix for [T, tau_phi, tau_theta, tau_psi].
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
quad: QuadcopterDynamics,
|
|
47
|
+
Q: NDArray | None = None,
|
|
48
|
+
R: NDArray | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.quad = quad
|
|
51
|
+
self.K = self._compute_gain(Q, R)
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# Hover linearization
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def _linearize_hover(self) -> tuple[NDArray, NDArray]:
|
|
58
|
+
"""Return (A, B) continuous-time hover linearization matrices."""
|
|
59
|
+
m = self.quad.mass
|
|
60
|
+
g = self.quad.g
|
|
61
|
+
k_d = self.quad.k_d
|
|
62
|
+
Ix, Iy, Iz = np.diag(self.quad.I)
|
|
63
|
+
|
|
64
|
+
# State: [x, y, z, vx, vy, vz, phi, theta, psi, p, q, r]
|
|
65
|
+
A = np.zeros((12, 12))
|
|
66
|
+
# Position kinematics: dp/dt = v
|
|
67
|
+
A[0, 3] = 1.0
|
|
68
|
+
A[1, 4] = 1.0
|
|
69
|
+
A[2, 5] = 1.0
|
|
70
|
+
# Velocity: aero drag + gravity coupling through small-angle attitude
|
|
71
|
+
A[3, 3] = -k_d / m
|
|
72
|
+
A[3, 7] = g # d(vx)/d(theta) (pitch -> forward accel)
|
|
73
|
+
A[4, 4] = -k_d / m
|
|
74
|
+
A[4, 6] = -g # d(vy)/d(phi) (roll -> lateral accel)
|
|
75
|
+
A[5, 5] = -k_d / m
|
|
76
|
+
# Euler angle kinematics (linearized, small angles)
|
|
77
|
+
A[6, 9] = 1.0 # dphi/dt = p
|
|
78
|
+
A[7, 10] = 1.0 # dtheta/dt = q
|
|
79
|
+
A[8, 11] = 1.0 # dpsi/dt = r
|
|
80
|
+
|
|
81
|
+
# Input: [T, tau_phi, tau_theta, tau_psi]
|
|
82
|
+
B = np.zeros((12, 4))
|
|
83
|
+
B[5, 0] = 1.0 / m # d(vz)/dT
|
|
84
|
+
B[9, 1] = 1.0 / Ix # d(p)/d(tau_phi)
|
|
85
|
+
B[10, 2] = 1.0 / Iy # d(q)/d(tau_theta)
|
|
86
|
+
B[11, 3] = 1.0 / Iz # d(r)/d(tau_psi)
|
|
87
|
+
|
|
88
|
+
return A, B
|
|
89
|
+
|
|
90
|
+
def _compute_gain(self, Q: NDArray | None, R: NDArray | None) -> NDArray:
|
|
91
|
+
"""Solve ARE and return gain K = R^{-1} B^T P."""
|
|
92
|
+
A, B = self._linearize_hover()
|
|
93
|
+
|
|
94
|
+
if Q is None:
|
|
95
|
+
q_diag = np.array([
|
|
96
|
+
10.0, 10.0, 12.0, # position x, y, z
|
|
97
|
+
2.0, 2.0, 4.0, # velocity vx, vy, vz
|
|
98
|
+
5.0, 5.0, 3.0, # attitude phi, theta, psi
|
|
99
|
+
0.5, 0.5, 0.3, # body rate p, q, r
|
|
100
|
+
])
|
|
101
|
+
Q = np.diag(q_diag)
|
|
102
|
+
|
|
103
|
+
if R is None:
|
|
104
|
+
R = np.diag([0.01, 50.0, 50.0, 20.0]) # [T, tau_phi, tau_theta, tau_psi]
|
|
105
|
+
|
|
106
|
+
P = solve_continuous_are(A, B, Q, R)
|
|
107
|
+
return np.linalg.inv(R) @ B.T @ P
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Runtime interface
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def reset(self) -> None:
|
|
114
|
+
"""No integrators; nothing to reset."""
|
|
115
|
+
|
|
116
|
+
def compute(
|
|
117
|
+
self,
|
|
118
|
+
target_pos: NDArray,
|
|
119
|
+
target_yaw: float,
|
|
120
|
+
dt: float,
|
|
121
|
+
prev_target_pos: NDArray | None = None,
|
|
122
|
+
) -> NDArray:
|
|
123
|
+
"""Compute motor speeds for a given position and yaw target.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
target_pos:
|
|
128
|
+
Desired 3-D position [x, y, z] in world frame.
|
|
129
|
+
target_yaw:
|
|
130
|
+
Desired yaw angle (rad).
|
|
131
|
+
dt:
|
|
132
|
+
Time step (unused by LQR; kept for interface compatibility).
|
|
133
|
+
prev_target_pos:
|
|
134
|
+
Previous target (unused; kept for interface compatibility).
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
motor_speeds: NDArray shape (4,)
|
|
139
|
+
Commanded rotor speeds in rad/s.
|
|
140
|
+
"""
|
|
141
|
+
m = self.quad.mass
|
|
142
|
+
g = self.quad.g
|
|
143
|
+
|
|
144
|
+
pos = self.quad.get_position()
|
|
145
|
+
vel = self.quad.get_velocity()
|
|
146
|
+
att = self.quad.get_attitude() # [phi, theta, psi]
|
|
147
|
+
omega = self.quad.get_angular_velocity() # [p, q, r]
|
|
148
|
+
|
|
149
|
+
# Error w.r.t. target (target velocity / rate = 0)
|
|
150
|
+
x_err = np.concatenate([
|
|
151
|
+
pos - target_pos,
|
|
152
|
+
vel,
|
|
153
|
+
att - np.array([0.0, 0.0, target_yaw]),
|
|
154
|
+
omega,
|
|
155
|
+
])
|
|
156
|
+
# Wrap yaw error to [-pi, pi]
|
|
157
|
+
x_err[8] = (x_err[8] + np.pi) % (2.0 * np.pi) - np.pi
|
|
158
|
+
|
|
159
|
+
# LQR feedback: delta wrench
|
|
160
|
+
u_delta = -self.K @ x_err # [delta_T, tau_phi, tau_theta, tau_psi]
|
|
161
|
+
|
|
162
|
+
# Absolute thrust with hover compensation
|
|
163
|
+
T = float(np.clip(m * g + u_delta[0], 0.2 * m * g, 2.0 * m * g))
|
|
164
|
+
tau_max = np.array([0.25, 0.25, 0.08])
|
|
165
|
+
torques = np.clip(u_delta[1:], -tau_max, tau_max)
|
|
166
|
+
|
|
167
|
+
wrench = np.array([T, torques[0], torques[1], torques[2]])
|
|
168
|
+
|
|
169
|
+
allocator = ControlAllocator(
|
|
170
|
+
self.quad.allocation_matrix(),
|
|
171
|
+
min_speed=self.quad.min_motor_speed,
|
|
172
|
+
max_speed=self.quad.max_motor_speed,
|
|
173
|
+
)
|
|
174
|
+
return allocator.allocate(wrench).motor_speeds
|