Skip to content

Commit 5e53832

Browse files
authored
Fix thrust clipping (#120)
1 parent db6f943 commit 5e53832

8 files changed

Lines changed: 59 additions & 50 deletions

File tree

‎crazyflow/sim/integration.py‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
"""Numerical integrators for the simulation dynamics.
2-
3-
Note:
4-
State limits are enforced by clipping after the integration (see e.g.
5-
[clip_rotor_vel][crazyflow.sim.sim.clip_rotor_vel]). The derivatives are unclipped. Higher order
6-
integration methods like RK4 evaluate the dynamics at intermediate states, so derivatives seen
7-
by the model can transiently exceed the limits within a single step.
8-
"""
1+
"""Numerical integrators for the simulation dynamics."""
92

103
from enum import StrEnum
114
from functools import partial

‎crazyflow/sim/sim.py‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -158,16 +158,16 @@ def __init__(
158158
# simulation pipeline.
159159
for name, fn in build_control_fns(self.control, self.dynamics):
160160
append_fn(self.step_pipeline, fn, name=name)
161+
# Keep the rotor command (RPM or thrust, see ``rotor_vel_limits``) within the motor limits
162+
lower, upper = rotor_vel_limits(self.dynamics, self.drone)
163+
clip_fn = partial(clip_rotor_vel_cmd, lower=lower, upper=upper, dynamics=self.dynamics)
164+
append_fn(self.step_pipeline, clip_fn, name="clip_rotor_vel_cmd")
161165
integrate_fn = select_integrate_fn(self.integrator, select_dynamics_fn(self.dynamics))
162166
append_fn(self.step_pipeline, integrate_fn, name="integration")
163-
# Keep the rotor state (RPM or thrust, see ``rotor_vel_limits``) within its physical limits
164-
lower, upper = rotor_vel_limits(self.dynamics, self.drone)
165-
clip_fn = partial(clip_rotor_vel, lower=lower, upper=upper)
166-
append_fn(self.step_pipeline, clip_fn, name="clip_rotor_vel")
167-
append_fn(self.step_pipeline, increment_steps)
168167
# We never drop below -0.001 (drones can't pass through the floor). We use -0.001 to
169168
# enable checks for negative z sign
170169
append_fn(self.step_pipeline, clip_floor_pos)
170+
append_fn(self.step_pipeline, increment_steps)
171171

172172
self._reset = self.build_reset_fn()
173173
self._step = self.build_step_fn()
@@ -718,10 +718,16 @@ def rotor_vel_limits(dynamics: Dynamics, drone: str) -> tuple[float, float]:
718718
return 4 * thrust_min, 4 * thrust_max
719719

720720

721-
def clip_rotor_vel(data: SimData, lower: Array | float, upper: Array | float) -> SimData:
722-
"""Clip ``rotor_vel`` to ``[lower, upper]``."""
723-
rotor_vel = jnp.clip(data.states.rotor_vel, lower, upper)
724-
return data.replace(states=data.states.replace(rotor_vel=rotor_vel))
721+
def clip_rotor_vel_cmd(
722+
data: SimData, lower: Array | float, upper: Array | float, dynamics: Dynamics
723+
) -> SimData:
724+
"""Clip the rotor command (RPM for first principles, collective thrust otherwise)."""
725+
if dynamics == Dynamics.first_principles:
726+
rotor_vel = jnp.clip(data.controls.rotor_vel, lower, upper)
727+
return data.replace(controls=data.controls.replace(rotor_vel=rotor_vel))
728+
attitude = data.controls.attitude
729+
cmd = attitude.cmd.at[..., -1].set(jnp.clip(attitude.cmd[..., -1], lower, upper))
730+
return data.replace(controls=data.controls.replace(attitude=attitude.replace(cmd=cmd)))
725731

726732

727733
@partial(jax.jit, static_argnames="device")

‎docs/examples/index.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ Because the simulator is built entirely from JAX operations, `jax.grad` can diff
6262

6363
---
6464

65-
## Gradients and state clipping
65+
## Gradients and command clipping
6666

67-
Hard state clips such as the `clip_rotor_vel` stage zero the gradients while the state is saturated. This example ramps a motor command beyond the rotor limits and compares the rotor state and the gradient of the vertical acceleration w.r.t. the command for three options: the default clip, a straight-through clip (clipped forward pass, unclipped gradients), and no clip. Replacing the default clip with the straight-through variant can help gradient-based methods such as trajectory optimization or policy learning, which would otherwise receive zero gradients whenever the motors saturate.
67+
Hard clips such as the `clip_rotor_vel_cmd` stage zero the gradients while the command is saturated. This example ramps a motor command beyond the rotor limits and compares the rotor state and the gradient of the vertical acceleration w.r.t. the command for three options: the default clip, a straight-through clip (clipped forward pass, unclipped gradients), and no clip. Replacing the default clip with the straight-through variant can help gradient-based methods such as trajectory optimization or policy learning, which would otherwise receive zero gradients whenever the motors saturate.
6868

6969
<!-- notest: imported script, covered by tests/integration/test_examples.py -->
7070
```{ .python notest }

‎docs/user-guide/oo-api.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ Key constructor arguments:
4444

4545
See [`Sim`][crazyflow.sim.Sim] in the API reference for the defaults and the full argument list.
4646

47+
!!! warning "Low simulation frequencies"
48+
Explicit integrators are only stable if the step is small compared to the fastest time constant of the dynamics. For the drone models this is the rotor dynamics with time constants down to 20 ms. Euler overshoots once the step exceeds the time constant and diverges beyond twice the time constant. Keep `freq` well above 100 Hz.
49+
4750
## Control methods
4851

4952
All control methods take an array of shape `(n_worlds, n_drones, command_dim)` and stage it for the next `step` call.

‎docs/user-guide/pipelines.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,22 @@ Both pipelines are constructed at `Sim` initialisation and compiled into a singl
2222
`sim.step_pipeline` contains multiple stages by default:
2323

2424
1. **Control functions** — convert the staged command through the control hierarchy (state → attitude → force/torque → rotor velocities, depending on the selected mode)
25-
2. **Integrator** (`integration`) — advance the ODE one dynamics step (Euler, RK4, or symplectic Euler)
26-
3. **Rotor clip** (`clip_rotor_vel`) — clip the motor speeds (first principles) or the collective thrust (fitted models) to the physical limits of the motors
27-
4. **Step counter** (`increment_steps`) — increment `data.core.steps`
28-
5. **Floor clip** (`clip_floor_pos`) — prevent drones from passing through the floor
25+
2. **Rotor command clip** (`clip_rotor_vel_cmd`) — clip the commanded motor speeds (first principles) or collective thrust (so_rpy models) to the physical limits of the motors
26+
3. **Integrator** (`integration`) — advance the ODE one dynamics step (Euler, RK4, or symplectic Euler)
27+
4. **Floor clip** (`clip_floor_pos`) — prevent drones from passing through the floor
28+
5. **Step counter** (`increment_steps`) — increment `data.core.steps`
2929

3030
```pycon
3131
>>> from crazyflow.sim import Sim
3232
>>> sim = Sim()
3333
>>> print(tuple(sim.step_pipeline.keys()))
34-
('attitude_controller', 'force_torque_controller', 'integration', 'clip_rotor_vel', 'increment_steps', 'clip_floor_pos')
34+
('attitude_controller', 'force_torque_controller', 'clip_rotor_vel_cmd', 'integration', 'clip_floor_pos', 'increment_steps')
3535

3636
```
3737

38+
!!! note
39+
We only clip the command, not the rotor state. Real motors deviate from their nominal thrust curve, and randomized motor parameters model exactly that, so the state has to be free to exceed the nominal limits. The command is always saturated, as it is on the real drone.
40+
3841
## The reset pipeline
3942

4043
`sim.reset_pipeline` holds a single `reset` stage that restores `SimData` to the default state. Every stage appended after it runs in order on the restored data. Each reset stage has the signature `(data: SimData, default_data: SimData, mask: Array | None) -> SimData`. The `default_data` argument holds the freshly-restored default state, which is useful for selectively reverting fields.
@@ -131,9 +134,6 @@ remove_fn(sim.step_pipeline, "clip_floor_pos")
131134
sim.build_step_fn()
132135
```
133136

134-
!!! note
135-
The rotor limits are enforced by the `clip_rotor_vel` stage, which clips the rotor state after each integration step. Higher order integration methods like RK4 evaluate the dynamics at intermediate, unclipped states, so the `rotor_vel` seen by the model can transiently exceed the limits within a single step.
136-
137137
## Writing a custom stage
138138

139139
A step pipeline function must have the signature `(SimData) -> SimData`. A reset pipeline function must have the signature `(SimData, SimData, Array | None) -> SimData` where the second argument is the default (freshly-restored) data. Both must be pure JAX functions with no Python-level side effects, so they can be traced and compiled.

‎docs/user-guide/sim-overview.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ sim.step(sim.freq // sim.control_freq) # 500 // 100 = 5 dynamics steps, control
8383

8484
## The step and reset pipelines
8585

86-
Each call to `sim.step()` runs `sim.step_pipeline`, an ordered collection of named, pure JAX functions that transform `SimData`. By default it contains the control conversion functions, the numerical integrator, a clip of the rotor state to the physical motor limits, a step counter, and a floor clip. Similarly, `sim.reset_pipeline` is applied during `sim.reset()` and is empty by default.
86+
Each call to `sim.step()` runs `sim.step_pipeline`, an ordered collection of named, pure JAX functions that transform `SimData`. By default it contains the control conversion functions, a clip of the rotor command to the physical motor limits, the numerical integrator, a floor clip, and a step counter. Similarly, `sim.reset_pipeline` is applied during `sim.reset()` and is empty by default.
8787

8888
Both pipelines can be extended with custom functions for disturbances, domain randomization, or logging without modifying the core simulator.
8989

‎examples/jax/gradient_clipping.py‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313
from crazyflow.sim.sim import rotor_vel_limits
1414

1515

16-
def clip_rotor_vel_nonblocking(data: SimData, lower: float, upper: float) -> SimData:
16+
def clip_rotor_vel_cmd_nonblocking(data: SimData, lower: float, upper: float) -> SimData:
1717
# Straight-through estimator: x + stop_gradient(clip(x) - x) evaluates to clip(x) in the
1818
# forward pass, while its derivative w.r.t. x is 1 in the backward pass
19-
rotor_vel = data.states.rotor_vel
20-
rotor_vel = rotor_vel + jax.lax.stop_gradient(jnp.clip(rotor_vel, lower, upper) - rotor_vel)
21-
return data.replace(states=data.states.replace(rotor_vel=rotor_vel))
19+
cmd = data.controls.rotor_vel
20+
cmd = cmd + jax.lax.stop_gradient(jnp.clip(cmd, lower, upper) - cmd)
21+
return data.replace(controls=data.controls.replace(rotor_vel=cmd))
2222

2323

2424
def rollout(sim: Sim, cmds: NDArray) -> tuple[NDArray, NDArray]:
@@ -59,17 +59,17 @@ def main(plot: bool = False):
5959
cmds = np.concatenate([np.linspace(0, ramp, n), np.full(n, ramp), np.linspace(ramp, 0, n)])
6060

6161
# Option 1: keep the clipping as is. The rotor state respects the limits, but the gradient is
62-
# zero while the state is saturated
62+
# zero while the command is saturated
6363
results = {"clip (default)": rollout(sim, cmds)}
6464

65-
# Option 2: clip the state in the forward pass, but keep the gradients flowing in the backward
66-
# pass (straight-through estimator)
67-
clip_fn = partial(clip_rotor_vel_nonblocking, lower=lower, upper=upper)
68-
replace_fn(sim.step_pipeline, clip_fn, "clip_rotor_vel")
65+
# Option 2: clip the command in the forward pass, but keep the gradients flowing in the
66+
# backward pass (straight-through estimator)
67+
clip_fn = partial(clip_rotor_vel_cmd_nonblocking, lower=lower, upper=upper)
68+
replace_fn(sim.step_pipeline, clip_fn, "clip_rotor_vel_cmd")
6969
results["nonblocking clip"] = rollout(sim, cmds)
7070

7171
# Option 3: remove the clipping. Gradients always flow, but the state can leave the limits
72-
remove_fn(sim.step_pipeline, "clip_rotor_vel")
72+
remove_fn(sim.step_pipeline, "clip_rotor_vel_cmd")
7373
results["no clip"] = rollout(sim, cmds)
7474

7575
sim.close()
@@ -106,7 +106,7 @@ def plot_results(
106106
ax.set_xlabel("Time (s)")
107107
ax.legend()
108108
ax.grid(True)
109-
fig.suptitle("Rotor state clipping and its effect on gradients")
109+
fig.suptitle("Rotor command clipping and its effect on gradients")
110110
plt.tight_layout()
111111
plt.show()
112112

‎tests/unit/test_sim.py‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ def test_floor_penetration(dynamics: Dynamics):
450450
@pytest.mark.unit
451451
@pytest.mark.parametrize("integrator", Integrator)
452452
def test_rotor_vel_clip(integrator: Integrator):
453-
"""Test that the first-principles rotor state saturates at its physical limits."""
453+
"""Test that the first principles rotor command is clipped to the motor limits."""
454454
sim = Sim(
455455
dynamics=Dynamics.first_principles,
456456
control=Control.rotor_vel,
@@ -460,14 +460,16 @@ def test_rotor_vel_clip(integrator: Integrator):
460460
lower, upper = rotor_vel_limits(Dynamics.first_principles, sim.drone)
461461
assert 0.0 < lower < upper
462462

463-
# States outside the limits are clipped after the integration
464463
for value, target in ((2 * upper, upper), (-upper, lower)):
465-
states = sim.data.states.replace(rotor_vel=jnp.full_like(sim.data.states.rotor_vel, value))
464+
sim.reset()
466465
sim.rotor_vel_control(np.full((1, 1, 4), target))
467-
sim.data = sim.data.replace(states=states)
468466
sim.step()
469-
assert jnp.all(sim.data.states.rotor_vel >= lower)
470-
assert jnp.all(sim.data.states.rotor_vel <= upper)
467+
rotor_vel_ref = sim.data.states.rotor_vel
468+
sim.reset()
469+
sim.rotor_vel_control(np.full((1, 1, 4), value))
470+
sim.step()
471+
assert jnp.all(sim.data.controls.rotor_vel == target)
472+
assert jnp.allclose(sim.data.states.rotor_vel, rotor_vel_ref)
471473
sim.close()
472474

473475

@@ -477,18 +479,23 @@ def test_rotor_vel_clip(integrator: Integrator):
477479
)
478480
@pytest.mark.parametrize("integrator", Integrator)
479481
def test_thrust_clip(dynamics: Dynamics, integrator: Integrator):
480-
"""Test that the thrust state of every so_rpy model is clipped to its physical limits."""
482+
"""Test that the so_rpy thrust command is clipped to the motor limits."""
481483
sim = Sim(dynamics=dynamics, control=Control.attitude, integrator=integrator, device="cpu")
482484
lower, upper = rotor_vel_limits(dynamics, sim.drone)
483485
assert 0.0 < lower < upper
484486

485487
for value, target in ((2 * upper, upper), (-upper, lower)):
486-
states = sim.data.states.replace(rotor_vel=jnp.full_like(sim.data.states.rotor_vel, value))
488+
# so_rpy has no thrust state and applies the command directly, so we compare the velocity
489+
sim.reset()
487490
sim.attitude_control(np.array([[[0.0, 0.0, 0.0, target]]]))
488-
sim.data = sim.data.replace(states=states)
489491
sim.step()
490-
assert jnp.all(sim.data.states.rotor_vel >= lower)
491-
assert jnp.all(sim.data.states.rotor_vel <= upper)
492+
vel_ref, rotor_vel_ref = sim.data.states.vel, sim.data.states.rotor_vel
493+
sim.reset()
494+
sim.attitude_control(np.array([[[0.0, 0.0, 0.0, value]]]))
495+
sim.step()
496+
assert jnp.all(sim.data.controls.attitude.cmd[..., -1] == target)
497+
assert jnp.allclose(sim.data.states.vel, vel_ref)
498+
assert jnp.allclose(sim.data.states.rotor_vel, rotor_vel_ref)
492499
sim.close()
493500

494501

0 commit comments

Comments
 (0)