Skip to content

Commit a36d70b

Browse files
committed
Add symbolic thrust model. Improve symbolic_from_sim. Add tests
1 parent 8b11476 commit a36d70b

File tree

8 files changed

+168
-27
lines changed

8 files changed

+168
-27
lines changed

crazyflow/sim/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from crazyflow.sim.physics import Physics
22
from crazyflow.sim.sim import Sim
3-
from crazyflow.sim.symbolic import symbolic
3+
from crazyflow.sim.symbolic import symbolic_attitude, symbolic_from_sim, symbolic_thrust
44

5-
__all__ = ["Sim", "Physics", "symbolic"]
5+
__all__ = ["Sim", "Physics", "symbolic_attitude", "symbolic_from_sim", "symbolic_thrust"]

crazyflow/sim/physics.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from crazyflow.control.control import KF, KM
1111

1212
SYS_ID_PARAMS = {
13-
"acc": jnp.array([20.91, 3.65]),
13+
"acc": jnp.array([20.907574256269616, 3.653687545690674]),
1414
"roll_acc": jnp.array([-130.3, -16.33, 119.3]),
1515
"pitch_acc": jnp.array([-99.94, -13.3, 84.73]),
1616
"yaw_acc": jnp.array([0.0, 0.0, 0.0]),

crazyflow/sim/symbolic.py

+89-8
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
from casadi import MX
2020
from numpy.typing import NDArray
2121

22-
from crazyflow.constants import GRAVITY
22+
from crazyflow.constants import ARM_LEN, GRAVITY, SIGN_MIX_MATRIX
23+
from crazyflow.control.control import KF, KM, Control
2324
from crazyflow.sim import Sim
2425

2526

@@ -142,9 +143,11 @@ def setup_linearization(self):
142143
self.loss = cs.Function("loss", l_inputs, l_outputs, l_inputs_str, l_outputs_str)
143144

144145

145-
def symbolic(mass: float, J: NDArray, dt: float) -> SymbolicModel:
146+
def symbolic_attitude(dt: float) -> SymbolicModel:
146147
"""Create symbolic (CasADi) models for dynamics, observation, and cost of a quadcopter.
147148
149+
This model is based on the identified model derived from real-world data of the Crazyflie 2.1.
150+
148151
Returns:
149152
The CasADi symbolic model of the environment.
150153
"""
@@ -177,13 +180,15 @@ def symbolic(mass: float, J: NDArray, dt: float) -> SymbolicModel:
177180
params_acc = [20.907574256269616, 3.653687545690674]
178181
params_roll_rate = [-130.3, -16.33, 119.3]
179182
params_pitch_rate = [-99.94, -13.3, 84.73]
180-
# params_yaw_rate = [0, 0, 0], because we always keep yaw as 0 when we identified the parameters.
181-
# We introduce a small negative offset here to make sure that we could get result of LQR ect..
182-
# TODO: identify params_yaw_rate
183+
# The identified model sets params_yaw_rate to [0, 0, 0], because the training data did not
184+
# contain any data with yaw != 0. Therefore, it cannot infer the impact of setting the yaw
185+
# attitude to a non-zero value on the dynamics. However, using a zero vector will make the
186+
# system matrix ill-conditioned for control methods like LQR. Therefore, we introduce a small
187+
# spring-like term to the yaw dynamics that leads to a non-singular system matrix.
188+
# TODO: identify proper parameters for yaw_rate from real data.
183189
params_yaw_rate = [-0.01, 0, 0]
184190

185191
# Define dynamics equations.
186-
# TODO: create a parameter for the new quad model
187192
X_dot = cs.vertcat(
188193
x_dot,
189194
(params_acc[0] * T + params_acc[1])
@@ -213,6 +218,76 @@ def symbolic(mass: float, J: NDArray, dt: float) -> SymbolicModel:
213218
return SymbolicModel(dynamics=dynamics, cost=cost, dt=dt)
214219

215220

221+
def symbolic_thrust(mass: float, J: NDArray, dt: float) -> SymbolicModel:
222+
"""Create symbolic (CasADi) models for dynamics, observation, and cost of a quadcopter.
223+
224+
This model is based on the analytical model of Luis, Carlos, and Jérôme Le Ny. "Design of a
225+
trajectory tracking controller for a nanoquadcopter." arXiv preprint arXiv:1608.05786 (2016).
226+
227+
Returns:
228+
The CasADi symbolic model of the environment.
229+
"""
230+
# Define states.
231+
z = MX.sym("z")
232+
z_dot = MX.sym("z_dot")
233+
234+
# Set up the dynamics model for a 3D quadrotor.
235+
nx, nu = 12, 4
236+
Ixx, Iyy, Izz = J.diagonal()
237+
J = cs.blockcat([[Ixx, 0.0, 0.0], [0.0, Iyy, 0.0], [0.0, 0.0, Izz]])
238+
Jinv = cs.blockcat([[1.0 / Ixx, 0.0, 0.0], [0.0, 1.0 / Iyy, 0.0], [0.0, 0.0, 1.0 / Izz]])
239+
gamma = KM / KF
240+
# System state variables
241+
x, y = MX.sym("x"), MX.sym("y")
242+
x_dot, y_dot = MX.sym("x_dot"), MX.sym("y_dot")
243+
phi, theta, psi = MX.sym("phi"), MX.sym("theta"), MX.sym("psi")
244+
p, q, r = MX.sym("p"), MX.sym("q"), MX.sym("r")
245+
# Rotation matrix transforming a vector in the body frame to the world frame. PyBullet Euler
246+
# angles use the SDFormat for rotation matrices.
247+
Rob = csRotXYZ(phi, theta, psi)
248+
# Define state variables.
249+
X = cs.vertcat(x, x_dot, y, y_dot, z, z_dot, phi, theta, psi, p, q, r)
250+
# Define inputs.
251+
f1, f2, f3, f4 = MX.sym("f1"), MX.sym("f2"), MX.sym("f3"), MX.sym("f4")
252+
U = cs.vertcat(f1, f2, f3, f4)
253+
254+
# Defining the dynamics function.
255+
# We are using the velocity of the base wrt to the world frame expressed in the world frame.
256+
# Note that the reference expresses this in the body frame.
257+
oVdot_cg_o = Rob @ cs.vertcat(0, 0, f1 + f2 + f3 + f4) / mass - cs.vertcat(0, 0, GRAVITY)
258+
pos_ddot = oVdot_cg_o
259+
pos_dot = cs.vertcat(x_dot, y_dot, z_dot)
260+
# We use the spin directions (signs) from the mix matrix used in the simulation.
261+
sx, sy, sz = SIGN_MIX_MATRIX[..., 0], SIGN_MIX_MATRIX[..., 1], SIGN_MIX_MATRIX[..., 2]
262+
Mb = cs.vertcat(
263+
ARM_LEN / cs.sqrt(2.0) * (sx[0] * f1 + sx[1] * f2 + sx[2] * f3 + sx[3] * f4),
264+
ARM_LEN / cs.sqrt(2.0) * (sy[0] * f1 + sy[1] * f2 + sy[2] * f3 + sy[3] * f4),
265+
gamma * (sz[0] * f1 + sz[1] * f2 + sz[2] * f3 + sz[3] * f4),
266+
)
267+
rate_dot = Jinv @ (Mb - (cs.skew(cs.vertcat(p, q, r)) @ J @ cs.vertcat(p, q, r)))
268+
ang_dot = cs.blockcat(
269+
[
270+
[1, cs.sin(phi) * cs.tan(theta), cs.cos(phi) * cs.tan(theta)],
271+
[0, cs.cos(phi), -cs.sin(phi)],
272+
[0, cs.sin(phi) / cs.cos(theta), cs.cos(phi) / cs.cos(theta)],
273+
]
274+
) @ cs.vertcat(p, q, r)
275+
X_dot = cs.vertcat(
276+
pos_dot[0], pos_ddot[0], pos_dot[1], pos_ddot[1], pos_dot[2], pos_ddot[2], ang_dot, rate_dot
277+
)
278+
279+
Y = cs.vertcat(x, x_dot, y, y_dot, z, z_dot, phi, theta, psi, p, q, r)
280+
281+
# Define cost (quadratic form).
282+
Q, R = MX.sym("Q", nx, nx), MX.sym("R", nu, nu)
283+
Xr, Ur = MX.sym("Xr", nx, 1), MX.sym("Ur", nu, 1)
284+
cost_func = 0.5 * (X - Xr).T @ Q @ (X - Xr) + 0.5 * (U - Ur).T @ R @ (U - Ur)
285+
# Define dynamics and cost dictionaries.
286+
dynamics = {"dyn_eqn": X_dot, "obs_eqn": Y, "vars": {"X": X, "U": U}}
287+
cost = {"cost_func": cost_func, "vars": {"X": X, "U": U, "Xr": Xr, "Ur": Ur, "Q": Q, "R": R}}
288+
return SymbolicModel(dynamics=dynamics, cost=cost, dt=dt)
289+
290+
216291
def symbolic_from_sim(sim: Sim) -> SymbolicModel:
217292
"""Create a symbolic model from a Sim instance.
218293
@@ -226,8 +301,14 @@ def symbolic_from_sim(sim: Sim) -> SymbolicModel:
226301
The model is expected to deviate from the true dynamics when the sim parameters are
227302
randomized.
228303
"""
229-
mass, J = sim.default_data.params.mass[0, 0], sim.default_data.params.J[0, 0]
230-
return symbolic(mass, J, 1 / sim.freq)
304+
match sim.control:
305+
case Control.attitude:
306+
return symbolic_attitude(1 / sim.freq)
307+
case Control.thrust:
308+
mass, J = sim.default_data.params.mass[0, 0], sim.default_data.params.J[0, 0]
309+
return symbolic_thrust(mass, J, 1 / sim.freq)
310+
case _:
311+
raise ValueError(f"Unsupported control type for symbolic model: {sim.control}")
231312

232313

233314
def csRotXYZ(phi: float, theta: float, psi: float) -> MX:

examples/symbolic.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
from crazyflow.constants import MASS, J
22
from crazyflow.sim import Sim
3-
from crazyflow.sim.symbolic import symbolic, symbolic_from_sim
3+
from crazyflow.sim.symbolic import symbolic_attitude, symbolic_from_sim, symbolic_thrust
44

55

66
def main():
7-
# We can create a symbolic model without the simulation
7+
# We can create a symbolic attitude control model without the simulation
88
dt = 1 / 500
9-
symbolic_model = symbolic(MASS, J, dt)
9+
symbolic_model = symbolic_attitude(dt)
10+
# We can also create a symbolic thrust control model
11+
symbolic_model = symbolic_thrust(MASS, J, dt)
1012

1113
# Or we can create a symbolic model directly from the simulation. Note that this will use the
12-
# nominal parameters of the simulation.
14+
# nominal parameters of the simulation and choose the control type based on the simulation.
1315
sim = Sim()
1416
symbolic_model = symbolic_from_sim(sim)
1517
assert symbolic_model.nx == 12 # 3 for pos, 3 for orient, 3 for vel, 3 for ang vel

tests/integration/test_symbolic.py

+45
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,48 @@ def test_attitude_symbolic():
6464
err_msg = "Symbolic and simulation prediction do not match approximately"
6565
assert np.allclose(x_sym_log, x_sim_log, rtol=1e-2, atol=1e-2), err_msg
6666
sim.close()
67+
68+
69+
@pytest.mark.integration
70+
def test_thrust_symbolic():
71+
sim = Sim(physics="analytical", control="thrust")
72+
sym = symbolic_from_sim(sim)
73+
74+
x0 = np.zeros(12)
75+
76+
# Simulate with both models for 0.5 seconds
77+
t_end = 0.5
78+
dt = 1 / sim.freq
79+
steps = int(t_end / dt)
80+
81+
# Track states over time
82+
x_sym_log = []
83+
x_sim_log = []
84+
85+
# Initialize logs with initial state
86+
x_sym = x0.copy()
87+
x_sim = x0.copy()
88+
x_sym_log.append(x_sym)
89+
x_sim_log.append(x_sim)
90+
91+
rng = np.random.default_rng(seed=42)
92+
93+
# Run simulation
94+
for _ in range(steps):
95+
u_rand = rng.uniform(MIN_THRUST, MIN_THRUST, 4)
96+
# Simulate with symbolic model
97+
res = sym.fd_func(x0=x_sym, p=u_rand)
98+
x_sym = res["xf"].full().flatten()
99+
x_sym_log.append(x_sym)
100+
# Simulate with attitude controller
101+
sim.thrust_control(u_rand.reshape(1, 1, 4))
102+
sim.step(sim.freq // sim.control_freq)
103+
x_sim_log.append(sim_state2symbolic_state(sim.data.states))
104+
105+
x_sym_log = np.array(x_sym_log)
106+
x_sim_log = np.array(x_sim_log)
107+
108+
# Check if states match throughout simulation
109+
err_msg = "Symbolic and simulation prediction do not match approximately"
110+
assert np.allclose(x_sym_log, x_sim_log, rtol=1e-2, atol=1e-3), err_msg
111+
sim.close()

tests/unit/test_symbolic.py

+20-6
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
11
import pytest
22

33
from crazyflow.constants import MASS, J
4+
from crazyflow.control import Control
45
from crazyflow.sim import Sim
5-
from crazyflow.sim.symbolic import symbolic, symbolic_from_sim
6+
from crazyflow.sim.symbolic import symbolic_attitude, symbolic_from_sim, symbolic_thrust
67

78

89
@pytest.mark.unit
9-
def test_symbolic_model_creation():
10-
"""Test creating symbolic model directly."""
10+
def test_symbolic_attitude_model_creation():
11+
"""Test creating symbolic attitude model directly."""
1112
dt = 0.01
12-
model = symbolic(mass=MASS, J=J, dt=dt)
13+
model = symbolic_attitude(dt)
14+
15+
assert model.nx == 12 # State dimension
16+
assert model.nu == 4 # Input dimension
17+
assert model.ny == 12 # Output dimension
18+
assert model.dt == dt
19+
20+
21+
@pytest.mark.unit
22+
def test_symbolic_thrust_model_creation():
23+
"""Test creating symbolic thrust model directly."""
24+
dt = 0.01
25+
model = symbolic_thrust(mass=MASS, J=J, dt=dt)
1326

1427
assert model.nx == 12 # State dimension
1528
assert model.nu == 4 # Input dimension
@@ -19,9 +32,10 @@ def test_symbolic_model_creation():
1932

2033
@pytest.mark.unit
2134
@pytest.mark.parametrize("n_worlds", [1, 2])
22-
def test_symbolic_from_sim(n_worlds: int):
35+
@pytest.mark.parametrize("control", [Control.attitude, Control.thrust])
36+
def test_symbolic_from_sim(n_worlds: int, control: Control):
2337
"""Test creating symbolic model from sim instance."""
24-
sim = Sim(n_worlds=n_worlds, n_drones=1)
38+
sim = Sim(n_worlds=n_worlds, n_drones=1, control=control)
2539
model = symbolic_from_sim(sim)
2640

2741
assert model.nx == 12

tutorials/LQR_ILQR.ipynb

+3-3
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@
3232
"from numpy.typing import NDArray\n",
3333
"from scipy.spatial.transform import Rotation as R\n",
3434
"\n",
35-
"from crazyflow.constants import GRAVITY, MASS, J\n",
35+
"from crazyflow.constants import GRAVITY, MASS\n",
3636
"from crazyflow.control import Control\n",
3737
"from crazyflow.sim.physics import Physics\n",
38-
"from crazyflow.sim.symbolic import symbolic"
38+
"from crazyflow.sim.symbolic import symbolic_from_sim"
3939
]
4040
},
4141
{
@@ -161,7 +161,7 @@
161161
"outputs": [],
162162
"source": [
163163
"dt = 1 / envs.sim.freq\n",
164-
"symbolic_model = symbolic(MASS, J, dt)\n",
164+
"symbolic_model = symbolic_from_sim(envs.unwrapped.sim)\n",
165165
"\n",
166166
"nx = 12 # dimension of state vector\n",
167167
"nu = 4 # dimension of input vector\n",

tutorials/compare_sim_and_symbolic.ipynb

+2-3
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,9 @@
1313
"from numpy.typing import NDArray\n",
1414
"from scipy.spatial.transform import Rotation as R\n",
1515
"\n",
16-
"from crazyflow.constants import MASS, J\n",
1716
"from crazyflow.control import Control\n",
1817
"from crazyflow.sim.physics import Physics\n",
19-
"from crazyflow.sim.symbolic import symbolic"
18+
"from crazyflow.sim.symbolic import symbolic_from_sim"
2019
]
2120
},
2221
{
@@ -82,7 +81,7 @@
8281
"outputs": [],
8382
"source": [
8483
"dt = 1/envs.sim.freq\n",
85-
"symbolic_model = symbolic(MASS, J, dt)"
84+
"symbolic_model = symbolic_from_sim(envs.unwrapped.sim)"
8685
]
8786
},
8887
{

0 commit comments

Comments
 (0)