#15562·betaflight

runaway_takeoff_prevention: the continuous-hold condition never exceeded 15 ms in three oscillatory ground-event logs

Author: danusha2345Created Aug 11, 2026Updated Sep 12, 2026

Summary

runaway_takeoff_prevention (default ON) disarms a craft that arms into a runaway: any-axis |pidSum| ≥ 600 together with a gyro rate above 15 °/s (roll/pitch) or 50 °/s (yaw), held continuously for 75 ms. Abridged, annotated excerpt (fc/core.c, subTaskPidController(); the surrounding guard conditions and setArmingDisabled() are omitted here):

c
if (((fabsf(pidData[FD_PITCH].Sum) >= RUNAWAY_TAKEOFF_PIDSUM_THRESHOLD) || ...)
    && ((gyroAbsRateDps(FD_PITCH) > RUNAWAY_TAKEOFF_GYRO_LIMIT_RP) || ...)) {
    if (runawayTakeoffTriggerUs == 0) {
        runawayTakeoffTriggerUs = currentTimeUs + RUNAWAY_TAKEOFF_ACTIVATE_DELAY;   // 75 ms
    } else if (currentTimeUs > runawayTakeoffTriggerUs) {
        disarm(DISARM_REASON_RUNAWAY_TAKEOFF);
    }
} else {
    runawayTakeoffTriggerUs = 0;                                                    // <-- resets
}

The detector requires the combined condition to remain continuously true. A periodic signal can reset the timer whenever all qualifying axes dip below the thresholds — it is not inherently unable to trigger on every oscillation (an axis hand-off or a DC-biased oscillation could hold it), but in the three complete-term ground-event logs we have, the reconstructed condition was bounded to at most 14.5, 13.9 and 15.0 ms against the 75 ms requirement, while motors reached the top of their output range. This is a detector-design question backed by an ADRC test-campaign corpus, not a classic-PID reproducer.

Evidence

Measured with rtp_oscillation_check.py on three published logs that record every axis term (P, I, D, F). "run ≤" is a conservative upper bound on any continuous stretch of the full trigger condition: it spans from the saved frame before each qualifying run to the saved frame after it, so PID iterations blackbox did not save are inside the bound. "accum" is the total condition-time over the whole log (sample-and- hold). "max gap" is the largest inter-frame interval anywhere in the log — the ceiling on anything decimation could hide entirely.

log span reconstructed |Sum| max frames ≥ 600 run ≤ accum max gap 75 ms reached?
Air65 ground runaway, arm 1 0.71 s 838 42 (7.3 %) 14.5 ms 51.9 ms 1.3 ms no
Air65 ground runaway, arm 2 0.68 s 896 65 (12.2 %) 13.9 ms 81.0 ms 18.3 ms no
5" ground loop, log 2 (6.25 s record) 6.25 s 5638 208 (3.3 %) 15.0 ms 208.0 ms 19.0 ms no
96.5 s flight, same craft as rows 1–2 (negative control) 96.51 s 665 6 (0.0 %) 8.9 ms 7.6 ms 32.3 ms no

A 596/600/604 threshold sweep leaves all three event-row bounds unchanged (the negative-control bound moves 8.9 → 7.6 ms at 604), so per-term lrintf quantisation in the reconstruction does not matter to the conclusion. For scale: the Air65 arms oscillate at about 21 Hz (three estimators, printed by the published analysis scripts beside those logs), and half a period at 21 Hz is 23.8 ms.

Honest limitations

  • All three events come from an ADRC test campaign (the fork behind draft PR #15400). The controller fills pidData[].P/I/D differently, but the quantity RTP reads is the shared, unclamped pidData[].Sum assigned before the mixer's pidSumLimit clamp (= P+I+D+F+S; S is zero on a multirotor, and all three logs carry use_integrated_yaw:0, the one opt-in that changes the formula). The USE_RUNAWAY_TAKEOFF block is byte-identical between that firmware and current master. We do not have a classic-PID oscillatory ground log; the failure class RTP exists for (wrong props, damaged frame, misconfiguration) can plausibly oscillate under classic PID too, but that is an inference, not a measurement.
  • Whether the check was still enabled during these events is not recoverable from a log (neither the setting nor runawayTakeoffCheckDisabled is recorded). The point is about the trigger condition itself: even enabled, it could not have confirmed on these signals.
  • This is a measurement about three events, not a theorem about all oscillations.
  • Two further campaign events are excluded because their logs lack axisD[2] (blackbox gates it on the legacy yaw D-gain, zero in those profiles), and a missing signed term makes the reconstructed sum neither a lower nor an upper bound.

Why we are not proposing a specific fix

The obvious patch — accumulate condition-time instead of requiring continuity — is not a shown fix either: the "accum" column is what a zero-leak accumulator would collect over the whole record, and for one of the three events that total is 51.9 ms — below the 75 ms requirement with no leak at all. Leaky variants are not modelled here. A workable detector likely needs a rectified envelope or band-limited power of pidSum/gyro rather than the instantaneous signed value, plus a false-positive sweep. The negative-control row says only that this boolean condition accumulates 7.6 ms across one 96.5 s flight; whether an envelope or band-power criterion separates events from normal flight as cleanly is exactly what such a sweep would have to establish. We can contribute the corpus and a sweep harness if a direction is agreed.

Question

Is the continuous-hold shape of the trigger a deliberate design constraint (accepting that oscillatory ground events are out of scope), or would an oscillation-capable criterion be considered? We are happy to test candidate criteria against the corpus above and against normal flight logs before any code is proposed.

Reproduction

Fetch-and-decode recipe, pinned decoder commit, and expected output: docs/upstream-issue-fixtures/rtp-oscillation/.