Skip to content

Commit 16d031b

Browse files
committed
Add proper parametrization of controllers
1 parent 89964d9 commit 16d031b

7 files changed

Lines changed: 220 additions & 126 deletions

File tree

drone_models/controller/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from typing import Callable
1010

11+
from drone_models.controller.core import parametrize
1112
from drone_models.controller.mellinger import (
1213
attitude2force_torque as mellinger_attitude2force_torque,
1314
)
@@ -17,3 +18,5 @@
1718
"mellinger_state2attitude": mellinger_state2attitude,
1819
"mellinger_attitude2force_torque": mellinger_attitude2force_torque,
1920
}
21+
22+
__all__ = ["parametrize"]

drone_models/controller/core.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from __future__ import annotations
2+
3+
from functools import partial
4+
from typing import Any, Callable, ParamSpec, Protocol, TypeVar, runtime_checkable
5+
6+
P = ParamSpec("P")
7+
R = TypeVar("R")
8+
9+
10+
controller_parameter_registry: dict[str, type[ControllerParams]] = {}
11+
12+
13+
def parametrize(fn: Callable[P, R], drone_model: str) -> Callable[P, R]:
14+
"""Parametrize a controller function with the default controller parameters for a drone model.
15+
16+
Args:
17+
fn: The controller function to parametrize.
18+
drone_model: The drone model to use.
19+
20+
Example:
21+
>>> from drone_models.controller import parametrize
22+
>>> from drone_models.controller.mellinger import state2attitude
23+
>>> controller_fn = parametrize(state2attitude, drone_model="cf2x_L250")
24+
>>> command_rpyt, int_pos_err = controller_fn(
25+
... pos=pos,
26+
... quat=quat,
27+
... vel=vel,
28+
... ang_vel=ang_vel,
29+
... cmd=cmd,
30+
... ctrl_errors=(int_pos_err,),
31+
... ctrl_freq=100,
32+
... )
33+
34+
Returns:
35+
The parametrized controller function with all keyword argument only parameters filled in.
36+
"""
37+
controller_id = fn.__module__.split(".")[-1] + fn.__name__
38+
try:
39+
params = controller_parameter_registry[controller_id].load(drone_model)
40+
except KeyError:
41+
raise KeyError(f"Controller {controller_id} does not exist in the parameter registry")
42+
except ValueError:
43+
raise ValueError(f"Drone model {drone_model} not supported for {fn.__name__}")
44+
return partial(fn, **params._asdict())
45+
46+
47+
@runtime_checkable
48+
class ControllerParams(Protocol):
49+
"""Protocol for controller parameters."""
50+
51+
@staticmethod
52+
def load(drone_model: str) -> ControllerParams:
53+
"""Load the parameters from the config file."""
54+
55+
def _asdict(self) -> dict[str, Any]:
56+
"""Convert the parameters to a dictionary."""
57+
58+
59+
def register_controller_parameters(
60+
params: ControllerParams | type[ControllerParams],
61+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
62+
"""Register the default controller parameters for this controller.
63+
64+
Warning:
65+
The controller parameters **must** be a named tuple with a function `load` that takes in the
66+
drone model name and returns an instance of itself, or a class that implements the
67+
ControllerParams protocol.
68+
69+
Args:
70+
params: The controller parameter type.
71+
72+
Returns:
73+
A decorator function that registers the parameters and returns the function unchanged.
74+
"""
75+
if not isinstance(params, ControllerParams):
76+
raise ValueError(f"{params} does not implement the ControllerParams protocol")
77+
78+
def decorator(fn: Callable[P, R]) -> Callable[P, R]:
79+
controller_id = fn.__module__.split(".")[-1] + fn.__name__
80+
if controller_id in controller_parameter_registry:
81+
raise ValueError(f"Controller {controller_id} already registered")
82+
controller_parameter_registry[controller_id] = params
83+
return fn
84+
85+
return decorator

drone_models/controller/data/mellinger.toml

Whitespace-only changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from drone_models.controller.mellinger.control import (
2+
attitude2force_torque,
3+
force_torque2rotor_vel,
4+
state2attitude,
5+
)
6+
7+
__all__ = ["state2attitude", "attitude2force_torque", "force_torque2rotor_vel"]

drone_models/controller/mellinger.py renamed to drone_models/controller/mellinger/control.py

Lines changed: 6 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING, NamedTuple
5+
from typing import TYPE_CHECKING
66

77
import array_api_extra as xpx
8-
import numpy as np
98
from array_api_compat import array_namespace
109
from scipy.spatial.transform import Rotation as R
1110

11+
from drone_models.controller.core import register_controller_parameters
12+
from drone_models.controller.mellinger.params import AttitudeParams, ForceTorqueParams, StateParams
1213
from drone_models.transform import force2pwm, motor_force2rotor_vel, pwm2force
1314

1415
if TYPE_CHECKING:
1516
from array_api_typing import Array
1617

1718

19+
@register_controller_parameters(StateParams)
1820
def state2attitude(
1921
pos: Array,
2022
quat: Array,
@@ -130,6 +132,7 @@ def state2attitude(
130132
return command_rpyt, int_pos_err
131133

132134

135+
@register_controller_parameters(AttitudeParams)
133136
def attitude2force_torque(
134137
pos: Array,
135138
quat: Array,
@@ -259,6 +262,7 @@ def force_torque_pwms2pwms(force_pwm: Array, torque_pwm: Array, mixing_matrix: A
259262
return force_pwm[..., None] + (mixing_matrix @ torque_pwm[..., None])[..., 0]
260263

261264

265+
@register_controller_parameters(ForceTorqueParams)
262266
def force_torque2rotor_vel(
263267
force: Array,
264268
torque: Array,
@@ -300,127 +304,3 @@ def force_torque2rotor_vel(
300304
motor_forces = xp.where(xp.all(force == 0), 0.0, xp.clip(motor_forces, thrust_min, thrust_max))
301305
# Assume perfect battery compensation and calculate the desired motor speeds directly
302306
return motor_force2rotor_vel(motor_forces, KF)
303-
304-
305-
class MellingerStateParams(NamedTuple):
306-
"""Parameters for the Mellinger state controller."""
307-
308-
mass: float
309-
kp: Array
310-
kd: Array
311-
ki: Array
312-
gravity_vec: Array
313-
mass_thrust: float
314-
int_err_max: Array
315-
thrust_max: float
316-
pwm_max: float
317-
318-
@staticmethod
319-
def load(drone_model: str) -> MellingerStateParams:
320-
"""Load the parameters from the config file."""
321-
params = MellingerStateParams._load_fake_model()
322-
return MellingerStateParams(**params)
323-
324-
@staticmethod
325-
def _load_fake_model() -> dict[str, Array | float]:
326-
"""Load the parameters from the config file."""
327-
# TODO: Remove this once we can load proper params
328-
return {
329-
"mass": 0.032999999821186066,
330-
"mass_thrust": 132000 * 0.034 / 0.027,
331-
"kp": np.array([0.4, 0.4, 1.25]),
332-
"kd": np.array([0.2, 0.2, 0.5]),
333-
"ki": np.array([0.05, 0.05, 0.05]),
334-
"int_err_max": np.array([2.0, 2.0, 0.4]),
335-
# TODO: Double-check if we want this
336-
"gravity_vec": np.array([0.0, 0.0, -9.8100004196167]),
337-
"thrust_max": 0.1125,
338-
"pwm_max": 65535.0,
339-
}
340-
341-
342-
class MellingerAttitudeParams(NamedTuple):
343-
"""Parameters for the Mellinger attitude controller."""
344-
345-
kR: Array
346-
kw: Array
347-
ki_m: Array
348-
kd_omega: Array
349-
int_err_max: Array
350-
torque_pwm_max: Array
351-
thrust_max: float
352-
pwm_min: float
353-
pwm_max: float
354-
L: float
355-
KF: float
356-
KM: float
357-
mixing_matrix: Array
358-
359-
@staticmethod
360-
def load(drone_model: str) -> MellingerAttitudeParams:
361-
"""Load the parameters from the config file."""
362-
params = MellingerAttitudeParams._load_fake_model()
363-
return MellingerAttitudeParams(**params)
364-
365-
@staticmethod
366-
def _load_fake_model() -> dict[str, Array | float]:
367-
"""Load the parameters from the config file."""
368-
# TODO: Remove this once we can load proper params
369-
# fmt: off
370-
mixing_matrix = np.array([[-1.0, -1.0, -1.0],
371-
[-1.0, 1.0, 1.0],
372-
[ 1.0, 1.0, -1.0],
373-
[ 1.0, -1.0, 1.0]]
374-
)
375-
# fmt: on
376-
return {
377-
"kR": np.array([70_000.0, 70_000.0, 60_000.0]),
378-
"kw": np.array([20_000.0, 20_000.0, 12_000.0]),
379-
"ki_m": np.array([0.0, 0.0, 500.0]),
380-
"kd_omega": np.array([200.0, 200.0, 0.0]),
381-
"int_err_max": np.array([1.0, 1.0, 1500.0]),
382-
"torque_pwm_max": np.array([32_000.0, 32_000.0, 32_000.0]),
383-
"thrust_max": 0.1125,
384-
"pwm_min": 20_000.0,
385-
"pwm_max": 65_535.0,
386-
"L": 0.03253,
387-
"KF": 8.7e-10,
388-
"KM": 7.94e-12,
389-
"mixing_matrix": mixing_matrix,
390-
}
391-
392-
393-
class MellingerForceTorqueParams(NamedTuple):
394-
"""Parameters for the Mellinger force torque controller."""
395-
396-
thrust_min: float
397-
thrust_max: float
398-
L: float
399-
KF: float
400-
KM: float
401-
mixing_matrix: Array
402-
403-
@staticmethod
404-
def load(drone_model: str) -> MellingerForceTorqueParams:
405-
"""Load the parameters from the config file."""
406-
params = MellingerForceTorqueParams._load_fake_model()
407-
return MellingerForceTorqueParams(**params)
408-
409-
@staticmethod
410-
def _load_fake_model() -> dict[str, Array | float]:
411-
"""Load the parameters from the config file."""
412-
# fmt: off
413-
mixing_matrix = np.array([[-1.0, -1.0, -1.0],
414-
[-1.0, 1.0, 1.0],
415-
[ 1.0, 1.0, -1.0],
416-
[ 1.0, -1.0, 1.0]]
417-
)
418-
# fmt: on
419-
return {
420-
"thrust_min": 0.02,
421-
"thrust_max": 0.1125,
422-
"L": 0.03253,
423-
"KF": 8.701227710666256e-10,
424-
"KM": 7.94e-12,
425-
"mixing_matrix": mixing_matrix,
426-
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Parameters for the Mellinger controller."""
2+
3+
from __future__ import annotations
4+
5+
import tomllib
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING, NamedTuple
8+
9+
import numpy as np
10+
11+
if TYPE_CHECKING:
12+
from array_api_typing import Array
13+
14+
15+
class StateParams(NamedTuple):
16+
"""Parameters for the Mellinger state controller."""
17+
18+
kp: Array
19+
kd: Array
20+
ki: Array
21+
int_err_max: Array
22+
mass: float
23+
gravity_vec: Array
24+
mass_thrust: float
25+
thrust_max: float
26+
pwm_max: float
27+
28+
@staticmethod
29+
def load(drone_model: str) -> StateParams:
30+
"""Load the parameters from the config file."""
31+
with open(Path(__file__).parent / "params.toml", "rb") as f:
32+
params = tomllib.load(f)
33+
if drone_model not in params:
34+
raise KeyError(f"Drone model `{drone_model}` not found in params.toml")
35+
params = params[drone_model]["state2attitude"] | params[drone_model]["core"]
36+
params = {k: np.asarray(v) for k, v in params.items() if k in StateParams._fields}
37+
return StateParams(**params)
38+
39+
40+
class AttitudeParams(NamedTuple):
41+
"""Parameters for the Mellinger attitude controller."""
42+
43+
kR: Array
44+
kw: Array
45+
ki_m: Array
46+
kd_omega: Array
47+
int_err_max: Array
48+
torque_pwm_max: Array
49+
thrust_max: float
50+
pwm_min: float
51+
pwm_max: float
52+
L: float
53+
KF: float
54+
KM: float
55+
mixing_matrix: Array
56+
57+
@staticmethod
58+
def load(drone_model: str) -> AttitudeParams:
59+
"""Load the parameters from the config file."""
60+
with open(Path(__file__).parent / "params.toml", "rb") as f:
61+
params = tomllib.load(f)
62+
if drone_model not in params:
63+
raise KeyError(f"Drone model `{drone_model}` not found in params.toml")
64+
params = params[drone_model]["attitude2force_torque"] | params[drone_model]["core"]
65+
params = {k: np.asarray(v) for k, v in params.items() if k in AttitudeParams._fields}
66+
return AttitudeParams(**params)
67+
68+
69+
class ForceTorqueParams(NamedTuple):
70+
"""Parameters for the Mellinger force torque controller."""
71+
72+
thrust_min: float
73+
thrust_max: float
74+
L: float
75+
KF: float
76+
KM: float
77+
mixing_matrix: Array
78+
79+
@staticmethod
80+
def load(drone_model: str) -> ForceTorqueParams:
81+
"""Load the parameters from the config file."""
82+
with open(Path(__file__).parent / "params.toml", "rb") as f:
83+
params = tomllib.load(f)
84+
if drone_model not in params:
85+
raise KeyError(f"Drone model `{drone_model}` not found in params.toml")
86+
params = params[drone_model]["core"]
87+
params = {k: np.asarray(v) for k, v in params.items() if k in ForceTorqueParams._fields}
88+
return ForceTorqueParams(**params)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
[cf2x_L250.core]
2+
mass = 0.033
3+
mass_thrust = 132000
4+
thrust_min = 0.02
5+
thrust_max = 0.1125
6+
pwm_min = 20000.0
7+
pwm_max = 65535.0
8+
torque_pwm_max = [32000.0, 32000.0, 32000.0]
9+
L = 0.03253
10+
KF = 8.7e-10
11+
KM = 7.94e-12
12+
mixing_matrix = [
13+
[-1.0, -1.0, -1.0],
14+
[-1.0, 1.0, 1.0],
15+
[ 1.0, 1.0, -1.0],
16+
[ 1.0, -1.0, 1.0]
17+
]
18+
gravity_vec = [0.0, 0.0, -9.81]
19+
20+
[cf2x_L250.state2attitude]
21+
kp = [0.4, 0.4, 1.25]
22+
kd = [0.2, 0.2, 0.5]
23+
ki = [0.05, 0.05, 0.05]
24+
int_err_max = [2.0, 2.0, 0.4]
25+
26+
[cf2x_L250.attitude2force_torque]
27+
kR = [70000.0, 70000.0, 60000.0]
28+
kw = [20000.0, 20000.0, 12000.0]
29+
ki_m = [0.0, 0.0, 500.0]
30+
kd_omega = [200.0, 200.0, 0.0]
31+
int_err_max = [1.0, 1.0, 1500.0]

0 commit comments

Comments
 (0)