Skip to content

Commit 135afd1

Browse files
committed
Fix thrust clipping
1 parent db6f943 commit 135afd1

5 files changed

Lines changed: 61 additions & 16 deletions

File tree

‎crazyflow/sim/sim.py‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,16 +158,21 @@ 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 and state (RPM or thrust, see ``rotor_vel_limits``) within the
162+
# physical limits. We need both: the command clip models the motor saturation and is the
163+
# only limit for models without a rotor state (so_rpy), the state clip catches states set
164+
# from outside the pipeline and integrator overshoot.
165+
lower, upper = rotor_vel_limits(self.dynamics, self.drone)
166+
clip_cmd_fn = partial(clip_rotor_vel_cmd, lower=lower, upper=upper, dynamics=self.dynamics)
167+
append_fn(self.step_pipeline, clip_cmd_fn, name="clip_rotor_vel_cmd")
161168
integrate_fn = select_integrate_fn(self.integrator, select_dynamics_fn(self.dynamics))
162169
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)
165170
clip_fn = partial(clip_rotor_vel, lower=lower, upper=upper)
166171
append_fn(self.step_pipeline, clip_fn, name="clip_rotor_vel")
167-
append_fn(self.step_pipeline, increment_steps)
168172
# We never drop below -0.001 (drones can't pass through the floor). We use -0.001 to
169173
# enable checks for negative z sign
170174
append_fn(self.step_pipeline, clip_floor_pos)
175+
append_fn(self.step_pipeline, increment_steps)
171176

172177
self._reset = self.build_reset_fn()
173178
self._step = self.build_step_fn()
@@ -724,6 +729,18 @@ def clip_rotor_vel(data: SimData, lower: Array | float, upper: Array | float) ->
724729
return data.replace(states=data.states.replace(rotor_vel=rotor_vel))
725730

726731

732+
def clip_rotor_vel_cmd(
733+
data: SimData, lower: Array | float, upper: Array | float, dynamics: Dynamics
734+
) -> SimData:
735+
"""Clip the rotor command (RPM for first principles, collective thrust otherwise)."""
736+
if dynamics == Dynamics.first_principles:
737+
rotor_vel = jnp.clip(data.controls.rotor_vel, lower, upper)
738+
return data.replace(controls=data.controls.replace(rotor_vel=rotor_vel))
739+
attitude = data.controls.attitude
740+
cmd = attitude.cmd.at[..., -1].set(jnp.clip(attitude.cmd[..., -1], lower, upper))
741+
return data.replace(controls=data.controls.replace(attitude=attitude.replace(cmd=cmd)))
742+
743+
727744
@partial(jax.jit, static_argnames="device")
728745
def seed_sim(data: SimData, seed: int, device: Device) -> SimData:
729746
"""JIT-compiled seeding function."""

‎docs/examples/index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Because the simulator is built entirely from JAX operations, `jax.grad` can diff
6464

6565
## Gradients and state 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 state clips such as the `clip_rotor_vel` stage zero the gradients while the state is saturated. This example removes the `clip_rotor_vel_cmd` stage, 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/pipelines.md‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,23 @@ 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`
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. **Rotor clip** (`clip_rotor_vel`) — clip the rotor state to the same limits
2828
5. **Floor clip** (`clip_floor_pos`) — prevent drones from passing through the floor
29+
6. **Step counter** (`increment_steps`) — increment `data.core.steps`
2930

3031
```pycon
3132
>>> from crazyflow.sim import Sim
3233
>>> sim = Sim()
3334
>>> print(tuple(sim.step_pipeline.keys()))
34-
('attitude_controller', 'force_torque_controller', 'integration', 'clip_rotor_vel', 'increment_steps', 'clip_floor_pos')
35+
('attitude_controller', 'force_torque_controller', 'clip_rotor_vel_cmd', 'integration', 'clip_rotor_vel', 'clip_floor_pos', 'increment_steps')
3536

3637
```
3738

39+
!!! note "Why two rotor clips?"
40+
`clip_rotor_vel_cmd` models the motor saturation and is the only limit for models without a rotor state (`so_rpy`). `clip_rotor_vel` catches states set from outside the default pipeline, such as randomizations, and integrator overshoot. Since it runs after the integration, higher order integrators like RK4 can transiently exceed the limits within a step.
41+
3842
## The reset pipeline
3943

4044
`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 +135,6 @@ remove_fn(sim.step_pipeline, "clip_floor_pos")
131135
sim.build_step_fn()
132136
```
133137

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-
137138
## Writing a custom stage
138139

139140
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.

‎examples/jax/gradient_clipping.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ def acc_z(cmd: jax.Array, data: SimData) -> tuple[jax.Array, SimData]:
4747
def main(plot: bool = False):
4848
sim = Sim(control=Control.rotor_vel)
4949
lower, upper = rotor_vel_limits(sim.dynamics, sim.drone)
50+
# Let the command reach the dynamics unclipped so that only the state clip affects the gradients
51+
remove_fn(sim.step_pipeline, "clip_rotor_vel_cmd")
5052
# Start in the air so that the drone never reaches the floor, where the floor clipping would
5153
# zero the velocity and kill the gradients (see gradient.py)
5254
sim.data = sim.data.replace(

‎tests/unit/test_sim.py‎

Lines changed: 30 additions & 5 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 and rotor state are clipped to the limits."""
454454
sim = Sim(
455455
dynamics=Dynamics.first_principles,
456456
control=Control.rotor_vel,
@@ -460,11 +460,22 @@ 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+
# Commands outside the limits are clipped before they enter the dynamics
465+
sim.reset()
466466
sim.rotor_vel_control(np.full((1, 1, 4), target))
467+
sim.step()
468+
rotor_vel_ref = sim.data.states.rotor_vel
469+
sim.reset()
470+
sim.rotor_vel_control(np.full((1, 1, 4), value))
471+
sim.step()
472+
assert jnp.all(sim.data.controls.rotor_vel == target)
473+
assert jnp.allclose(sim.data.states.rotor_vel, rotor_vel_ref)
474+
# States outside the limits are clipped after the integration
475+
sim.reset()
476+
states = sim.data.states.replace(rotor_vel=jnp.full_like(sim.data.states.rotor_vel, value))
467477
sim.data = sim.data.replace(states=states)
478+
sim.rotor_vel_control(np.full((1, 1, 4), target))
468479
sim.step()
469480
assert jnp.all(sim.data.states.rotor_vel >= lower)
470481
assert jnp.all(sim.data.states.rotor_vel <= upper)
@@ -477,15 +488,29 @@ def test_rotor_vel_clip(integrator: Integrator):
477488
)
478489
@pytest.mark.parametrize("integrator", Integrator)
479490
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."""
491+
"""Test that the so_rpy thrust command and thrust state are clipped to the limits."""
481492
sim = Sim(dynamics=dynamics, control=Control.attitude, integrator=integrator, device="cpu")
482493
lower, upper = rotor_vel_limits(dynamics, sim.drone)
483494
assert 0.0 < lower < upper
484495

485496
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))
497+
# Commands outside the limits are clipped before they enter the dynamics. so_rpy has no
498+
# thrust state and applies the command directly, so we compare the velocity as well
499+
sim.reset()
487500
sim.attitude_control(np.array([[[0.0, 0.0, 0.0, target]]]))
501+
sim.step()
502+
vel_ref, rotor_vel_ref = sim.data.states.vel, sim.data.states.rotor_vel
503+
sim.reset()
504+
sim.attitude_control(np.array([[[0.0, 0.0, 0.0, value]]]))
505+
sim.step()
506+
assert jnp.all(sim.data.controls.attitude.cmd[..., -1] == target)
507+
assert jnp.allclose(sim.data.states.vel, vel_ref)
508+
assert jnp.allclose(sim.data.states.rotor_vel, rotor_vel_ref)
509+
# States outside the limits are clipped after the integration
510+
sim.reset()
511+
states = sim.data.states.replace(rotor_vel=jnp.full_like(sim.data.states.rotor_vel, value))
488512
sim.data = sim.data.replace(states=states)
513+
sim.attitude_control(np.array([[[0.0, 0.0, 0.0, target]]]))
489514
sim.step()
490515
assert jnp.all(sim.data.states.rotor_vel >= lower)
491516
assert jnp.all(sim.data.states.rotor_vel <= upper)

0 commit comments

Comments
 (0)