runaway_takeoff_prevention: the continuous-hold condition never exceeded 15 ms in three oscillatory ground-event logs
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):
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/Ddifferently, but the quantity RTP reads is the shared, unclampedpidData[].Sumassigned before the mixer'spidSumLimitclamp (= P+I+D+F+S; S is zero on a multirotor, and all three logs carryuse_integrated_yaw:0, the one opt-in that changes the formula). TheUSE_RUNAWAY_TAKEOFFblock 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
runawayTakeoffCheckDisabledis 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/.
Source: betaflight/betaflight