Skip to content

Commit 7afd420

Browse files
committed
Improve API and add docs
1 parent 309d469 commit 7afd420

17 files changed

Lines changed: 866 additions & 20 deletions

File tree

crazyflow/control/core.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,9 @@ def load_params(controller: str, drone: str) -> dict[str, dict]:
6161
"""Load the raw parameter table for a controller and drone.
6262
6363
Reads ``crazyflow/control/<controller>/params.toml`` and returns the drone's table, nested by
64-
section (``"core"`` and one per controller function). Use [load_fn_params][load_fn_params] to
65-
select and convert the parameters a specific controller function accepts.
64+
section (``"core"`` and one per controller function). Use
65+
[load_fn_params][crazyflow.control.core.load_fn_params] to select and convert the parameters a
66+
specific controller function accepts.
6667
6768
Args:
6869
controller: Name of the controller sub-package, e.g. ``"mellinger"``.

crazyflow/control/mellinger/control.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def state2attitude(
3636
quat: Array,
3737
vel: Array,
3838
cmd: Array,
39-
ctrl_errors: tuple[Array, ...] | None = None,
39+
pos_err_i: Array | None = None,
4040
ctrl_freq: float = 100,
4141
*,
4242
mass: float,
@@ -60,8 +60,8 @@ def state2attitude(
6060
vel: Drone velocity with shape (..., 3).
6161
cmd: Full state command in SI units and rad with shape (..., 13). The entries are
6262
[x, y, z, vx, vy, vz, ax, ay, az, yaw, roll_rate, pitch_rate, yaw_rate].
63-
ctrl_errors: Tuple of integral errors. For state2attitude, the tuple contains a single array
64-
(..., 3) for the position integral error or is None.
63+
pos_err_i: Position integral error (..., 3) from the previous call. If None, it is
64+
initialised to zero.
6565
ctrl_freq: Control frequency in Hz
6666
mass: Drone mass used for calculations in the controller in kg.
6767
kp: Proportional gain for the position controller with shape (3,).
@@ -90,7 +90,7 @@ def state2attitude(
9090
pos_err = setpoint_pos - pos # l. 145 Position Error (ep)
9191
vel_err = setpoint_vel - vel # l. 148 Velocity Error (ev)
9292
# l.151 ff Integral Error
93-
int_pos_err = xp.zeros_like(pos) if ctrl_errors is None else ctrl_errors[0]
93+
int_pos_err = xp.zeros_like(pos) if pos_err_i is None else pos_err_i
9494
int_pos_err = xp.clip(int_pos_err + pos_err * dt, -int_err_max, int_err_max)
9595
# l. 161 Desired thrust [F_des]
9696
# => only one case here, since setpoint is always in absolute mode
@@ -147,7 +147,7 @@ def attitude2force_torque(
147147
ang_vel: Array,
148148
cmd: Array,
149149
prev_ang_vel: Array | None = None,
150-
ctrl_errors: tuple[Array, ...] | None = None,
150+
r_int_error: Array | None = None,
151151
ctrl_freq: int = 500,
152152
*,
153153
kR: Array,
@@ -173,8 +173,8 @@ def attitude2force_torque(
173173
quat: Drone orientation as xyzw quaternion with shape (..., 4).
174174
ang_vel: Drone angular drone velocity in rad/s with shape (..., 3).
175175
cmd: Commanded attitude (roll, pitch, yaw) and total thrust [rad, rad, rad, N].
176-
ctrl_errors: Tuple of integral errors. For attitude2force_torque, the tuple contains a
177-
single array (..., 3) for the angular velocity integral error or is None.
176+
r_int_error: Angular velocity integral error (..., 3) from the previous call. If None, it
177+
is initialised to zero.
178178
ctrl_freq: Control frequency in Hz
179179
kR: Proportional gain for the rotation error with shape (3,).
180180
kw: Proportional gain for the angular velocity error with shape (3,).
@@ -221,7 +221,7 @@ def attitude2force_torque(
221221
ang_vel_d_err = xpx.at(ang_vel_d_err)[..., 2].set(0)
222222

223223
# l. 268 ff Integral Error
224-
r_int_error = xp.zeros_like(ang_vel) if ctrl_errors is None else ctrl_errors[0]
224+
r_int_error = xp.zeros_like(ang_vel) if r_int_error is None else r_int_error
225225
r_int_error = r_int_error - eR * dt
226226
r_int_error = xp.clip(r_int_error, -int_err_max, int_err_max)
227227
# l. 278 ff Moment:
@@ -244,8 +244,8 @@ def attitude2force_torque(
244244
# TODO: Long-term, the Mellinger controller should use the new power distribution which
245245
# calculates motor forces in Newtons. However, for now the firmware uses the legacy power
246246
# distribution, so we keep it here for compatibility. To have a single consistent interface for
247-
# controllers within drone_models, we still want to return SI forces and torques. We thus need
248-
# to convert the legacy output to SI units.
247+
# controllers, we still want to return SI forces and torques. We thus need to convert the legacy
248+
# output to SI units.
249249
# l. 310 ff
250250
torque_des = (mixing_matrix @ motor_forces[..., None])[..., 0] * xp.stack([L, L, thrust2torque])
251251
force_des = xp.sum(motor_forces, axis=-1)[..., None]
@@ -420,7 +420,7 @@ def sim_state2attitude(data: SimData) -> SimData:
420420
states.quat,
421421
states.vel,
422422
state_ctrl.cmd,
423-
ctrl_errors=(state_ctrl.pos_err_i,),
423+
pos_err_i=state_ctrl.pos_err_i,
424424
ctrl_freq=state_ctrl.freq,
425425
**state_ctrl.params,
426426
)
@@ -440,7 +440,7 @@ def sim_attitude2force_torque(data: SimData) -> SimData:
440440
states.quat,
441441
states.ang_vel,
442442
attitude_ctrl.cmd,
443-
ctrl_errors=(attitude_ctrl.r_int_error,),
443+
r_int_error=attitude_ctrl.r_int_error,
444444
ctrl_freq=attitude_ctrl.freq,
445445
prev_ang_vel=attitude_ctrl.last_ang_vel,
446446
**attitude_ctrl.params,

docs/gen_ref_pages.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@
5959
* [dynamics.symbols](crazyflow/dynamics/symbols.md)
6060
* Control
6161
* [control](crazyflow/control/index.md)
62-
* [control.mellinger](crazyflow/control/mellinger.md)
62+
* [control.control](crazyflow/control/control.md)
63+
* [control.core](crazyflow/control/core.md)
64+
* [control.transform](crazyflow/control/transform.md)
65+
* [control.mellinger](crazyflow/control/mellinger/index.md)
66+
* [control.mellinger.control](crazyflow/control/mellinger/control.md)
6367
* Environments
6468
* [envs](crazyflow/envs/index.md)
6569
* [envs.drone_env](crazyflow/envs/drone_env.md)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Batching
2+
3+
All controllers are built on Array API operations that broadcast over leading dimensions. Add a leading batch dimension to your state and command arrays and the controller evaluates all instances in a single call, with no loops and no special API.
4+
5+
```python
6+
import numpy as np
7+
from crazyflow.control import parametrize
8+
from crazyflow.control.mellinger import state2attitude
9+
10+
ctrl = parametrize(state2attitude, "cf2x_L250")
11+
12+
N = 100
13+
pos = np.zeros((N, 3))
14+
quat = np.tile(np.array([0.0, 0.0, 0.0, 1.0]), (N, 1))
15+
vel = np.zeros((N, 3))
16+
cmd = np.zeros((N, 13))
17+
18+
rpyt, int_pos_err = ctrl(pos, quat, vel, cmd)
19+
rpyt.shape # (100, 4)
20+
```
21+
22+
## Higher-dimensional batches
23+
24+
Any number of leading dimensions works. A common pattern is a grid of environments, each containing multiple drones:
25+
26+
```python
27+
import numpy as np
28+
from crazyflow.control import parametrize
29+
from crazyflow.control.mellinger import state2attitude
30+
31+
ctrl = parametrize(state2attitude, "cf2x_L250")
32+
33+
# 10 environments, 5 drones each
34+
pos = np.zeros((10, 5, 3))
35+
quat = np.broadcast_to(np.array([0.0, 0.0, 0.0, 1.0]), (10, 5, 4)).copy()
36+
vel = np.zeros((10, 5, 3))
37+
cmd = np.zeros((10, 5, 13))
38+
39+
rpyt, _ = ctrl(pos, quat, vel, cmd)
40+
rpyt.shape # (10, 5, 4)
41+
```
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Controllers
2+
3+
A controller is a function that maps the current drone state and a command to actuator outputs. Every controller in `crazyflow.control` is:
4+
5+
- **A pure function**: no hidden state; integral errors are explicit return values you pass back on the next call
6+
- **Array-API compatible**: works identically with NumPy, JAX, PyTorch, or any compliant library
7+
- **Batchable**: add leading dimensions to any input array and the function evaluates all instances at once
8+
9+
## The Mellinger pipeline
10+
11+
The Mellinger controller [[1]](#references) is split into three stages that form a pipeline. Each stage can be used on its own, or all three can be chained to convert a full-state setpoint into individual motor speeds.
12+
13+
| Stage | Function | Takes | Produces |
14+
|---|---|---|---|
15+
| 1 | [`state2attitude`](mellinger.md#state-to-attitude) | State + 13-element setpoint | RPYT command + position integral error |
16+
| 2 | [`attitude2force_torque`](mellinger.md#attitude-to-force-torque) | Attitude + RPYT command | Collective force, body torques + angular velocity integral error |
17+
| 3 | [`force_torque2rotor_vel`](mellinger.md#force-torque-to-rotor-velocities) | Force + torques | 4 motor speeds [RPM] |
18+
19+
## Available controllers
20+
21+
| Module | Controller | Stages |
22+
|---|---|---|
23+
| `crazyflow.control.mellinger` | Mellinger | `state2attitude`, `attitude2force_torque`, `force_torque2rotor_vel` |
24+
25+
## References
26+
27+
[1] D. Mellinger and V. Kumar, "Minimum snap trajectory generation and control for quadrotors," ICRA 2011, doi: 10.1109/ICRA.2011.5980409.
Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,18 @@ Each control mode has its own update rate. The dynamics tick (`freq`) is always
162162

163163
The simulator applies a new command only when the control tick fires. Between ticks, the previous command is held. The number of dynamics steps per control tick is `freq // control_freq`.
164164

165+
## Using the controllers standalone
166+
167+
The control modes above are how the simulator drives the onboard controllers. Those controllers also live in `crazyflow.control` as a self-contained library of pure functions, usable on their own for control design, learning-based policies, or as a reference implementation, independent of `Sim`. The following guides cover that standalone API:
168+
169+
- [Controllers](controllers.md): the controller interface and the Mellinger pipeline
170+
- [Mellinger controller](mellinger.md): the three stages, their inputs and outputs
171+
- [Parametrization](parametrize.md): binding a controller to a drone configuration
172+
- [Integral errors](integral-errors.md): carrying controller state across calls
173+
- [Batching](batching.md): evaluating many drones at once
174+
- [JIT compilation](jit.md): compiling controllers with `jax.jit`
175+
165176
## Next steps
166177

167-
- [Functional API](functional-api.md) running control inside JIT with `F.controllable`
168-
- [Dynamics](dynamics/index.md) compatibility between dynamics and control modes
178+
- [Functional API](../functional-api.md): running control inside JIT with `F.controllable`
179+
- [Dynamics](../dynamics/index.md): compatibility between dynamics and control modes
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Integral errors
2+
3+
The Mellinger controller uses integral terms to reject steady-state errors. Because every controller in `crazyflow.control` is a **pure function**, integral state cannot be stored inside the function. It is an explicit return value that you pass back on the next call.
4+
5+
## Initialisation
6+
7+
You have two ways to start the integral error at zero:
8+
9+
1. Pass `None` (or omit the argument). The controller then creates the zero array internally.
10+
2. Create the zero array yourself and pass it explicitly. The integral error has the same shape as the position (for `pos_err_i`) or angular velocity (for `r_int_error`), so a zero array of that shape works.
11+
12+
```python
13+
import numpy as np
14+
from crazyflow.control import parametrize
15+
from crazyflow.control.mellinger import state2attitude
16+
17+
ctrl = parametrize(state2attitude, "cf2x_L250")
18+
pos = np.zeros(3)
19+
quat = np.array([0.0, 0.0, 0.0, 1.0])
20+
vel = np.zeros(3)
21+
cmd = np.zeros(13)
22+
23+
# Option 1: let the controller initialise the integral error.
24+
rpyt, pos_err_i = ctrl(pos, quat, vel, cmd, pos_err_i=None)
25+
26+
# Option 2: initialise it yourself.
27+
rpyt, pos_err_i = ctrl(pos, quat, vel, cmd, pos_err_i=np.zeros(3))
28+
```
29+
30+
Under `jax.jit`, prefer option 2. Passing `None` on the first call and an array on the following calls changes the argument structure, so JAX traces and compiles the function twice. Passing a zero array from the start keeps the input structure constant and compiles only once.
31+
32+
## Carrying errors across timesteps
33+
34+
Pass the returned error straight back as `pos_err_i` on the next call:
35+
36+
```python
37+
import numpy as np
38+
from crazyflow.control import parametrize
39+
from crazyflow.control.mellinger import state2attitude
40+
41+
ctrl = parametrize(state2attitude, "cf2x_L250")
42+
pos = np.zeros(3)
43+
quat = np.array([0.0, 0.0, 0.0, 1.0])
44+
vel = np.zeros(3)
45+
cmd = np.zeros(13)
46+
cmd[0] = 1.0 # 1 m setpoint error in x
47+
48+
pos_err_i = None
49+
for _ in range(10):
50+
rpyt, pos_err_i = ctrl(pos, quat, vel, cmd, pos_err_i=pos_err_i) # carry forward
51+
52+
# After 10 steps at 100 Hz, integral error is approximately 10 * 0.01 = 0.1 m.
53+
```
54+
55+
## Both stages have integral errors
56+
57+
`state2attitude` tracks position error via `pos_err_i`. `attitude2force_torque` tracks angular velocity error via `r_int_error`. Manage them independently:
58+
59+
```python
60+
import numpy as np
61+
from crazyflow.control import parametrize
62+
from crazyflow.control.mellinger import attitude2force_torque, state2attitude
63+
64+
state_ctrl = parametrize(state2attitude, "cf2x_L250")
65+
att_ctrl = parametrize(attitude2force_torque, "cf2x_L250")
66+
67+
pos = np.zeros(3)
68+
quat = np.array([0.0, 0.0, 0.0, 1.0])
69+
vel = np.zeros(3)
70+
ang_vel = np.zeros(3)
71+
cmd = np.zeros(13)
72+
73+
pos_err_i = None
74+
r_int_error = None
75+
76+
for _ in range(5):
77+
rpyt, pos_err_i = state_ctrl(pos, quat, vel, cmd, pos_err_i=pos_err_i)
78+
force, torque, r_int_error = att_ctrl(quat, ang_vel, rpyt, r_int_error=r_int_error)
79+
```

docs/user-guide/control/jit.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# JIT compilation
2+
3+
Every controller is a pure function with no hidden state or side effects. In addition, they are implemented exclusively with Array API operations that are compatible with lazy JIT frameworks. Together, these two properties mean that every controller can be JIT compiled without any modification.
4+
5+
```python
6+
import jax
7+
import jax.numpy as jnp
8+
from crazyflow.control import parametrize
9+
from crazyflow.control.mellinger import state2attitude
10+
11+
ctrl = parametrize(state2attitude, "cf2x_L250", xp=jnp)
12+
jit_ctrl = jax.jit(ctrl)
13+
14+
pos = jnp.zeros(3)
15+
quat = jnp.array([0.0, 0.0, 0.0, 1.0])
16+
vel = jnp.zeros(3)
17+
cmd = jnp.zeros(13)
18+
19+
rpyt, int_pos_err = jit_ctrl(pos, quat, vel, cmd)
20+
```
21+
22+
## Integral errors under JIT
23+
24+
Integral errors are regular arrays and are handled as JAX pytree leaves, so they pass through `jax.jit` without any special treatment.
25+
26+
```python
27+
import jax
28+
import jax.numpy as jnp
29+
from crazyflow.control import parametrize
30+
from crazyflow.control.mellinger import state2attitude
31+
32+
ctrl = parametrize(state2attitude, "cf2x_L250", xp=jnp)
33+
jit_ctrl = jax.jit(ctrl)
34+
35+
pos = jnp.zeros(3)
36+
quat = jnp.array([0.0, 0.0, 0.0, 1.0])
37+
vel = jnp.zeros(3)
38+
cmd = jnp.zeros(13)
39+
40+
pos_err_i = jnp.zeros(3) # initialise to zero, so the function compiles only once
41+
for _ in range(10):
42+
rpyt, pos_err_i = jit_ctrl(pos, quat, vel, cmd, pos_err_i=pos_err_i)
43+
```
44+
45+
## Batched JIT
46+
47+
Batching and JIT compose directly. Add leading dimensions to the state arrays and the same compiled function handles the entire batch.
48+
49+
```python
50+
import jax
51+
import jax.numpy as jnp
52+
from crazyflow.control import parametrize
53+
from crazyflow.control.mellinger import state2attitude
54+
55+
ctrl = parametrize(state2attitude, "cf2x_L250", xp=jnp)
56+
jit_ctrl = jax.jit(ctrl)
57+
58+
N = 1_000
59+
pos = jnp.zeros((N, 3))
60+
quat = jnp.broadcast_to(jnp.array([0.0, 0.0, 0.0, 1.0]), (N, 4))
61+
vel = jnp.zeros((N, 3))
62+
cmd = jnp.zeros((N, 13))
63+
64+
rpyt, _ = jit_ctrl(pos, quat, vel, cmd)
65+
rpyt.shape # (1000, 4)
66+
```

0 commit comments

Comments
 (0)