What this is: A field report on tracking down and fixing two flight-control bugs in our simulated drone — one that made it lunge when repositioning, and one that made it wobble itself into a crash when moving at an angle.
Why it’s here: Both bugs were caught while running our reinforcement-learning training loop and watching the replays. The fixes are a good lesson in why how you tell a drone to move matters as much as where you tell it to go.
Date: 2026-06-10 Ticket: v2 takeoff + model integration
Glossary
- Velocity setpoint — Instead of telling the drone “go to that spot over there,” you tell it “move in this direction at this speed.” It’s the difference between handing someone a destination address versus saying “walk north, slowly.” The second instruction is gentler and easier to follow without overshooting.
- Reposition — Moving the drone back to a known starting spot between attempts, like resetting a chess board before the next game.
- The “carrot” — A guidance idea, like the proverbial carrot dangled in front of a horse. Rather than aiming the drone straight at a far-off target (which makes it bolt), we place a nearby, gently-moving goal just ahead of it. The drone always chases something close, so it approaches smoothly instead of lunging.
- Tilt / lean — How far the drone is banked from level. A small lean is normal for any moving multirotor; a large, growing lean is trouble.
- Off-axis — A heading that doesn’t line up neatly with the cardinal grid (not exactly north/south/east/west). These angles turned out to be where the trouble hid.
- Cross-coupling — When a control meant to affect one thing accidentally spills into another. Here, a sideways correction bled into both the forward and the side tilt at once, feeding a wobble.
- Underdamped oscillation — A wobble that grows instead of settling, like pushing a swing at exactly the wrong rhythm until it goes too high.
- the forward-move action — In our training setup, this is the discrete command that tells the drone to scoot forward a fixed amount. Most of this report is about making that single command behave.
1. Two bugs, spotted two different ways
We found these while running our RL training loop. One showed up in the numbers; the other was first noticed by eye in the replay videos.
Bug 1 — the reposition lunge. When the drone repositioned between attempts, the code aimed it directly at the far target across the room, with no smoothing in between. The result was a hard step input: the drone snapped toward the goal and banked to a 16° tilt with a double wobble. On video it looked like a “weird, lurching start with a roll.”
Bug 2 — the off-axis wobble that became a crash. The crash always came from one specific situation: the drone moving forward on an off-axis heading (about −74°, well off the clean grid) while being guided by a position-only target. Because that target was expressed in the world frame, the position error split across both the forward lean and the side lean at once. The two corrections cross-coupled and started feeding each other. The roll crept up steadily — roughly 2°, then 6°, 11°, 14° over about seven seconds — while altitude held fine. Then it let go all at once: the lean jumped from 14° to 64° in about a third of a second, and the drone dropped out of the sky.
The important diagnosis here: this was not an altitude problem. The drone wasn’t sinking and then tipping — it was wobbling itself unstable, and the fall was a symptom. On clean grid-aligned headings the same move stayed under 2°, because the smooth guidance kept it damped.
2. The fixes
Bug 1. We replaced the direct “snap to the far target” call with a smooth, carrot-guided approach moving at a gentle 0.3 m/s, holding altitude steadily along the way. With the carrot doing the leading, the reposition tilt dropped from 16° to 2.0°. Fixed.
Bug 2. This one needed a deeper rethink. We changed the forward-move action so it no longer chases a fixed point in the world. Instead it commands a velocity setpoint — a direction and a speed — so there’s no world-frame position target to split across the two lean axes, and therefore nothing to cross-couple.
Getting the reference frame right was the subtle part:
- Our first attempt used a body-relative frame, as the original spec suggested. But in that frame the heading is interpreted as a relative offset, so an off-axis absolute heading was misread — the drone thought it had arrived and just kept rotating in place.
- The fix was to command the motion in the world frame, computing the forward and sideways speed components directly from the heading, paired with an absolute heading. With that, even off-axis commands fly to where they should.
- We also added a fallback so the movement speed always has a sane default, and kept a short acceleration ramp to soften the start and stop of each move.
The “arrived” check was likewise rebuilt around velocity motion: the drone moves at the commanded speed, tracks distance travelled from its own odometry, and on arrival switches to a position-hold, lets itself settle, and snaps cleanly to its target heading.
Smoke-test results (six headings, including the nasty off-axis ones at −45°, −73°, −70°):
| Heading | Before | After |
|---|---|---|
| Off-axis −73° (the crash case) | 60° tumble, fell | 8.0°, arrived cleanly |
| All six headings | mixed / crash | all arrived cleanly |
The crash is gone. The off-axis heading that used to tumble now flies at a steady ~8° lean and lands its move just like the grid-aligned ones — no wobble, no let-go, altitude held the whole time.
About that 8° lean: we deliberately accepted it (Aleks, 2026-06-10). For a velocity setpoint at 0.3 m/s, an 8° lean is just the normal cruise tilt a multirotor takes on to move — that’s how the underlying velocity controller works. It isn’t a bug and shouldn’t be “fixed.” The acceleration ramp smooths the start and stop, but it doesn’t (and shouldn’t) flatten that cruise lean — that’s healthy flight dynamics, not a defect.
With both bugs closed, the fix is ready for the next long training run.
Artifacts: a dedicated smoke-test script that runs a reposition plus all six off-axis headings while sampling tilt; replay videos; and runtime logs.
3. Stack lifecycle and teardown
On Aleks’s direction we documented how the simulation stack lives and dies (now written up in the simulation README under “Stack lifecycle”):
- The full stack — Gazebo, SITL, mavros, and ROS2 (Jazzy) — launches into a detached tmux session. It runs independently of the Python client and keeps running after the launch script exits, by design.
- There is no automatic teardown. The stack only goes down via an explicit kill script, which shuts everything down cleanly. Closing the client only tears down the client’s own ROS node — it leaves the stack untouched.
- We keep the stack alive between test iterations on purpose: repeatedly killing and relaunching Gazebo in a loop degrades the NVIDIA EGL graphics layer over time. Leaving it up avoids that wear.
- The rule that follows: always run the kill script when you finish, before you walk away, or before handing the stack off to another agent.