Skip to content

Commit 2275cce

Browse files
committed
[WIP] Continue fixing tests
1 parent 4870ed8 commit 2275cce

6 files changed

Lines changed: 122 additions & 48 deletions

File tree

drone_models/models/first_principles.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@
22

33
from __future__ import annotations
44

5+
import warnings
56
from typing import TYPE_CHECKING
67

7-
import array_api_extra as xpx
88
import casadi as cs
99
from array_api_compat import array_namespace
1010
from scipy.spatial.transform import Rotation as R
1111

1212
import drone_models.models.symbols as symbols
1313
from drone_models.models.utils import supports
14-
from drone_models.transform import motor_force2rotor_speed
1514
from drone_models.utils import rotation
1615

1716
if TYPE_CHECKING:
@@ -43,7 +42,7 @@ def dynamics(
4342
quat: Quaternion of the drone (xyzw).
4443
vel: Velocity of the drone (m/s).
4544
ang_vel: Angular velocity of the drone (rad/s).
46-
cmd: Roll pitch yaw (rad) and collective thrust (N) command.
45+
cmd: Motor speeds (rad/s).
4746
constants: Containing the constants of the drone.
4847
rotor_vel: Angular velocity of the 4 motors (rad/s). If None, the commanded thrust is
4948
directly applied. If value is given, thrust dynamics are calculated.
@@ -64,9 +63,10 @@ def dynamics(
6463
if rotor_vel is None:
6564
rotor_vel_dot = None
6665
rotor_vel = cmd
66+
warnings.warn("Rotor velocity is not provided, using commanded rotor velocity directly.")
6767
else:
6868
rotor_vel_dot = (
69-
1 / constants.ROTOR_TAU * (cmd - rotor_vel) - 1 / constants.ROTOR_D * rotor_vel**2
69+
1 / constants.THRUST_TAU * (cmd - rotor_vel) - 1 / constants.KM * rotor_vel**2
7070
)
7171
# Creating force and torque vector
7272
forces_motor = constants.KF * rotor_vel**2

drone_models/models/identified/so_rpy.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,15 @@ def dynamics(
3838
cmd: Roll pitch yaw (rad) and collective thrust (N) command.
3939
constants: Containing the constants of the drone.
4040
rotor_vel: Speed of the 4 motors (rad/s). If None, the commanded thrust is directly
41-
applied. If a value is given, the rotor speed dynamics are calculated.
41+
applied. If a value is given, the function raises an error.
4242
dist_f: Disturbance force (N) acting on the CoM.
4343
dist_t: Disturbance torque (Nm) acting on the CoM.
4444
4545
Returns:
4646
The derivatives of all state variables.
4747
"""
4848
xp = array_namespace(pos)
49-
cmd_rotor_vel = cmd[..., -1]
50-
cmd_f = xp.sum(constants.KF * cmd_rotor_vel**2, axis=-1)
49+
cmd_f = cmd[..., -1]
5150
cmd_rpy = cmd[..., 0:3]
5251
rot = R.from_quat(quat)
5352
euler_angles = rot.as_euler("xyz")

drone_models/models/identified/so_rpy_rotor.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import warnings
56
from typing import TYPE_CHECKING
67

78
from array_api_compat import array_namespace
@@ -48,7 +49,7 @@ def dynamics(
4849
"""
4950
xp = array_namespace(pos)
5051
cmd_f = cmd[..., -1]
51-
cmd_rotor_vel = motor_force2rotor_speed(cmd_f, constants.KF)
52+
cmd_rotor_vel = motor_force2rotor_speed(cmd_f / 4, constants.KF)
5253
cmd_rpy = cmd[..., 0:3]
5354
rot = R.from_quat(quat)
5455
euler_angles = rot.as_euler("xyz")
@@ -57,19 +58,11 @@ def dynamics(
5758
if rotor_vel is None:
5859
rotor_vel_dot = None
5960
rotor_vel = cmd_rotor_vel
61+
warnings.warn("Rotor velocity is not provided, using commanded rotor velocity directly.")
6062
else:
6163
rotor_vel_dot = (
62-
1 / constants.ROTOR_TAU * (cmd_rotor_vel - rotor_vel)
63-
- 1 / constants.ROTOR_D * rotor_vel**2
64+
1 / constants.DI_D_ACC[2] * (cmd_rotor_vel - rotor_vel) - constants.KM * rotor_vel**2
6465
)
65-
66-
# Note: Due to the structure of the integrator, we split the commanded thrust into
67-
# four equal parts and later apply the sum as total thrust again. Those four forces
68-
# are not the true forces of the motors, but the sum is the true total thrust.
69-
rotor_vel_dot = (
70-
xp.asarray(1 / constants.DI_D_ACC[2] * (cmd_rotor_vel - rotor_vel))
71-
- constants.KM * rotor_vel**2
72-
)
7366
forces_motor = xp.sum(constants.KF * rotor_vel**2, axis=-1)
7467
forces_sum = xp.sum(forces_motor, axis=-1)
7568
thrust = constants.DI_D_ACC[0] + constants.DI_D_ACC[1] * forces_sum

drone_models/models/identified/so_rpy_rotor_drag.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import warnings
56
from typing import TYPE_CHECKING
67

78
from array_api_compat import array_namespace
@@ -38,8 +39,8 @@ def dynamics(
3839
ang_vel: Angular velocity of the drone (rad/s).
3940
command: Roll pitch yaw (rad) and collective thrust (N) command.
4041
constants: Containing the constants of the drone.
41-
rotor_vel: Thrust of the 4 motors in N. If None, the commanded thrust is directly applied.
42-
If a value is given, thrust dynamics are calculated.
42+
rotor_vel: Speed of the 4 motors (rad/s). If None, the commanded thrust is directly
43+
applied (not recommended). If value is given, rotor dynamics are calculated.
4344
dist_f: Disturbance force (N) acting on the CoM.
4445
dist_t: Disturbance torque (Nm) acting on the CoM.
4546
@@ -48,16 +49,20 @@ def dynamics(
4849
"""
4950
xp = array_namespace(pos)
5051
cmd_f = command[..., -1]
51-
cmd_rotor_vel = motor_force2rotor_speed(cmd_f, constants.KF)
52+
cmd_rotor_vel = motor_force2rotor_speed(cmd_f / 4, constants.KF)
5253
cmd_rpy = command[..., 0:3]
5354
rot = R.from_quat(quat)
5455
euler_angles = rot.as_euler("xyz")
5556
rpy_rates = rotation.ang_vel2rpy_rates(quat, ang_vel)
5657

57-
rotor_vel_dot = (
58-
xp.asarray(1 / constants.DI_DD_ACC[2] * (cmd_rotor_vel / 4 - rotor_vel))
59-
- constants.DI_DD_ACC[3] * rotor_vel**2
60-
)
58+
if rotor_vel is None:
59+
rotor_vel_dot = None
60+
rotor_vel = cmd_rotor_vel
61+
warnings.warn("Rotor velocity is not provided, using commanded rotor velocity directly.")
62+
else:
63+
rotor_vel_dot = (
64+
1 / constants.DI_DD_ACC[2] * (cmd_rotor_vel - rotor_vel) - constants.KM * rotor_vel**2
65+
)
6166
forces_motor = xp.sum(constants.KF * rotor_vel**2, axis=-1)
6267
forces_sum = xp.sum(forces_motor, axis=-1)
6368
thrust = constants.DI_DD_ACC[0] + constants.DI_DD_ACC[1] * forces_sum

drone_models/utils/rotation.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,52 @@
66

77
from __future__ import annotations
88

9-
import os
10-
11-
# TODO raise error if scipy is already loaded and the feature flag is not set
12-
os.environ["SCIPY_ARRAY_API"] = "1" # Feature flag to activate Array API in scipy
139
import re
1410
from typing import TYPE_CHECKING
1511

1612
import casadi as cs
1713
from array_api_compat import array_namespace
14+
from jax.scipy.spatial.transform import Rotation as JR
1815
from scipy.spatial.transform import Rotation as R
1916

2017
if TYPE_CHECKING:
2118
from array_api_strict import Array
2219

2320

21+
def from_quat(quat: Array, scalar_first: bool = False) -> R:
22+
"""Creates a rotation object compatible with the type of the given quat."""
23+
if isinstance(quat, jp.ndarray):
24+
if scalar_first:
25+
raise ValueError("scalar_first is not supported by jax rotations")
26+
return JR.from_quat(quat)
27+
else:
28+
return R.from_quat(quat, scalar_first=scalar_first)
29+
30+
31+
def from_rotvec(rotvec: Array, degrees: bool = False) -> R:
32+
"""Creates a rotation object compatible with the type of the given rotvec."""
33+
if isinstance(rotvec, jp.ndarray):
34+
return JR.from_rotvec(rotvec, degrees)
35+
else:
36+
return R.from_rotvec(rotvec, degrees)
37+
38+
39+
def from_euler(seq: str, angles: Array, degrees: bool = False) -> R:
40+
"""Creates a rotation object compatible with the type of the given angles."""
41+
if isinstance(angles, jp.ndarray):
42+
return JR.from_euler(seq, angles, degrees)
43+
else:
44+
return R.from_euler(seq, angles, degrees)
45+
46+
47+
def from_matrix(matrix: Array) -> R:
48+
"""Creates a rotation objecte compatible with the type of the given rotation matrix."""
49+
if isinstance(matrix, jp.ndarray):
50+
return JR.from_matrix(matrix)
51+
else:
52+
return R.from_matrix(matrix)
53+
54+
2455
def ang_vel2quat_dot(quat: Array, ang_vel: Array) -> Array:
2556
"""Calculates the quaternion derivative based on an angular velocity."""
2657
xp = array_namespace(quat)

tests/unit/test_models.py

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ def create_rnd_commands(shape: tuple[int, ...] = (), dim: int = 4) -> Array:
4242

4343
def skip_models_without_features(model: Callable, features: list[str]):
4444
"""Skip the model if it does not have the required features."""
45-
for feature in model_features(model):
46-
if feature not in features:
47-
pytest.skip(f"Model {model.__name__} does not have the required features.")
45+
for feature in features:
46+
if not model_features(model)[feature]:
47+
pytest.skip(f"Model {model.__name__} does not have the feature '{feature}'.")
4848

4949

5050
@pytest.mark.unit
@@ -54,25 +54,76 @@ def test_model_features(model_name: str, model: Callable):
5454
assert hasattr(model, "__drone_model_features__"), (
5555
f"Model function {model_name} does not have __drone_model_features__ attribute"
5656
)
57-
features = getattr(model, "__drone_model_features__")
57+
features = model_features(model)
5858
assert isinstance(features, dict), (
59-
f"__drone_model_features__ should be a dict, got {type(features)} for {model_name}"
59+
f"model features should be a dict, got {type(features)} for {model_name}"
6060
)
6161
assert "rotor_dynamics" in features, (
62-
f"__drone_model_features__ should contain 'rotor_dynamics' key for {model_name}"
62+
f"model features should contain 'rotor_dynamics' key for {model_name}"
6363
)
6464

6565

6666
@pytest.mark.unit
6767
@pytest.mark.parametrize("model_name, model", available_models.items())
6868
@pytest.mark.parametrize("drone_name", Constants.available_configs)
69-
def test_model_single_input_no_rotor_dynamics(model_name: str, model: Callable, drone_name: str):
69+
def test_model_single_no_rotor_dynamics(model_name: str, model: Callable, drone_name: str):
7070
pos, quat, vel, ang_vel, _, _, _ = create_rnd_states()
71-
commands = create_rnd_commands(dim=4) # TODO make dependent on model
71+
cmd = create_rnd_commands(dim=4) # TODO make dependent on model
72+
if model_features(model)["rotor_dynamics"]:
73+
with pytest.warns(UserWarning, match="Rotor velocity is not provided"):
74+
dpos, dquat, dvel, dang_vel, drotor_vel = model(
75+
pos, quat, vel, ang_vel, cmd, Constants.from_config(drone_name, xp), rotor_vel=None
76+
)
77+
else:
78+
dpos, dquat, dvel, dang_vel, drotor_vel = model(
79+
pos, quat, vel, ang_vel, cmd, Constants.from_config(drone_name, xp), rotor_vel=None
80+
)
81+
assert drotor_vel is None, "Model should not return rotor velocities without rotor_vel input"
82+
# Check if the output is on the correct device, has the correct type and shape
83+
for dx, x in zip([dpos, dquat, dvel, dang_vel], [pos, quat, vel, ang_vel], strict=True):
84+
assert isinstance(dx, type(x))
85+
assert xp_device(dx) == xp_device(x)
86+
assert dx.shape == x.shape
87+
88+
89+
@pytest.mark.unit
90+
@pytest.mark.parametrize("model_name, model", available_models.items())
91+
@pytest.mark.parametrize("drone_name", Constants.available_configs)
92+
def test_model_single_rotor_dynamics(model_name: str, model: Callable, drone_name: str):
93+
skip_models_without_features(model, ["rotor_dynamics"])
94+
95+
pos, quat, vel, ang_vel, rotor_vel, _, _ = create_rnd_states()
96+
cmd = create_rnd_commands(dim=4) # TODO make dependent on model
7297
dpos, dquat, dvel, dang_vel, drotor_vel = model(
73-
pos, quat, vel, ang_vel, commands, Constants.from_config(drone_name, xp), rotor_vel=None
98+
pos, quat, vel, ang_vel, cmd, Constants.from_config(drone_name, xp), rotor_vel=rotor_vel
7499
)
75100
# Check if the output is on the correct device, has the correct type and shape
101+
for dx, x in zip(
102+
[dpos, dquat, dvel, dang_vel, drotor_vel], [pos, quat, vel, ang_vel, rotor_vel], strict=True
103+
):
104+
assert isinstance(dx, type(x))
105+
assert xp_device(dx) == xp_device(x)
106+
assert dx.shape == x.shape
107+
108+
109+
@pytest.mark.unit
110+
@pytest.mark.parametrize("model_name, model", available_models.items())
111+
@pytest.mark.parametrize("drone_name", Constants.available_configs)
112+
def test_model_batched_no_rotor_dynamics(model_name: str, model: Callable, drone_name: str):
113+
batch_shape = (10,)
114+
pos, quat, vel, ang_vel, _, _, _ = create_rnd_states(batch_shape)
115+
cmd = create_rnd_commands(batch_shape, dim=4) # TODO make dependent on model
116+
if model_features(model)["rotor_dynamics"]:
117+
with pytest.warns(UserWarning, match="Rotor velocity is not provided"):
118+
dpos, dquat, dvel, dang_vel, drotor_vel = model(
119+
pos, quat, vel, ang_vel, cmd, Constants.from_config(drone_name, xp), rotor_vel=None
120+
)
121+
else:
122+
dpos, dquat, dvel, dang_vel, drotor_vel = model(
123+
pos, quat, vel, ang_vel, cmd, Constants.from_config(drone_name, xp), rotor_vel=None
124+
)
125+
assert drotor_vel is None, "Model should not return rotor velocities without rotor_vel input"
126+
# Check if the output is on the correct device, has the correct type and shape
76127
for dx, x in zip([dpos, dquat, dvel, dang_vel], [pos, quat, vel, ang_vel], strict=True):
77128
assert isinstance(dx, type(x))
78129
assert xp_device(dx) == xp_device(x)
@@ -82,19 +133,14 @@ def test_model_single_input_no_rotor_dynamics(model_name: str, model: Callable,
82133
@pytest.mark.unit
83134
@pytest.mark.parametrize("model_name, model", available_models.items())
84135
@pytest.mark.parametrize("drone_name", Constants.available_configs)
85-
def test_model_single_input_rotor_dynamics(model_name: str, model: Callable, drone_name: str):
136+
def test_model_batched_rotor_dynamics(model_name: str, model: Callable, drone_name: str):
86137
skip_models_without_features(model, ["rotor_dynamics"])
87138

88-
pos, quat, vel, ang_vel, rotor_vel, _, _ = create_rnd_states()
89-
commands = create_rnd_commands(dim=4) # TODO make dependent on model
139+
batch_shape = (10,)
140+
pos, quat, vel, ang_vel, rotor_vel, _, _ = create_rnd_states(batch_shape)
141+
cmd = create_rnd_commands(batch_shape, dim=4) # TODO make dependent on model
90142
dpos, dquat, dvel, dang_vel, drotor_vel = model(
91-
pos,
92-
quat,
93-
vel,
94-
ang_vel,
95-
commands,
96-
Constants.from_config(drone_name, xp),
97-
rotor_vel=rotor_vel,
143+
pos, quat, vel, ang_vel, cmd, Constants.from_config(drone_name, xp), rotor_vel=rotor_vel
98144
)
99145
# Check if the output is on the correct device, has the correct type and shape
100146
for dx, x in zip(

0 commit comments

Comments
 (0)