### Summary
**AI-assistance disclosure (AGENTS.md):** this contribution was AI-…assisted. The design, the log analysis it came from, the SITL and replay testing, and the decision to submit are mine, and I take full responsibility for it; the code, comments and autotest were written with Claude, as the `Co-Authored-By` trailers on both commits record.
Adds an opt-in limit on how fast a QuadPlane may fly backwards in VTOL modes, to keep it out of the regime where the flow over the tail reverses and separates and produces a large uncommanded nose-down pitch. Disabled by default; a log-only mode is provided so the behaviour can be characterised on an airframe before anything is armed.
### Classification & Testing (check all that apply and add your own)
- [ ] Checked by a human programmer
- [ ] Non-functional change
- [ ] No-binary change
- [ ] Infrastructure change (e.g. unit tests, helper scripts)
- [x] Automated test(s) verify changes (e.g. unit test, autotest)
- [x] Tested manually, description below (e.g. SITL)
- [ ] Tested on hardware
- [ ] Logs attached
- [x] Logs available on request
**This has never been flown, or run on hardware at all.** It is SITL and offline log analysis only. It is off by default and I would not suggest anyone set `Q_BKF_ENABLE=2` without working through the tuning procedure below first.
What was actually done:
* **New autotest**, `QuadPlane.BackwardFlowLimit`. Flies the same QLOITER profile three times, once per `Q_BKF_ENABLE` mode, and asserts against the `BAKF` dataflash records. The log-only run is the control: it establishes the backward speed reached with nothing intervening and fails if that is not comfortably past the limit, so the comparison cannot pass vacuously. Latest run on this branch: 2.25 m/s unrestrained against 1.36 m/s limited, `Q_BKF_SPD_MAX` 1.5.
* **SITL, seven VTOL frames** (`quadplane`, `-tri`, `-tilt`, `-tilttri`, `-tilttrivec`, `-tilthvec`, `-cl84`), identical ~250 s profile in a 4 m/s wind with `Q_BKF_ENABLE=1`, to measure how often the limiter would engage in ordinary flying. Frames that translate by leaning sit at 11-12% of VTOL time with the squeeze active; tilt-rotor frames peak at 1.4 m/s of backward airspeed and would never reach the defaults.
* **SITL wind path**, with `EK3_DRAG_BCOEF_X/Y` set so the wind stays observable in the hover. Holding station in a 6 m/s tailwind the estimate reached 7 m/s of backward airspeed while the *ground* speed component was negative, ie the vehicle was moving forwards over the ground. That is the case a groundspeed-only check cannot see.
* **Pre-arm check** verified in SITL for valid, inverted, equal and disabled parameter combinations.
* **Offline replay against one real flight log** (ArduPlane 4.7.0, TRI quadplane, an aircraft that suffered this event). Please read this as weaker evidence than it sounds: it is the same arithmetic reimplemented in numpy over the logged `XKF1`/`XKF2`/`ATT`, not the firmware executing. It does not prove the code is right, only that the signal is there and the thresholds are plausible.
* Builds clean for SITL and CubeOrange. Parameter and logger metadata both regenerate without errors. `flake8` clean.
**SITL cannot validate the aerodynamics here and I have not tried to make it.** `SIM_Plane` clamps alpha in the lift curve, computes dynamic pressure from an airspeed magnitude, and extrapolates a linear `c_m_a` to alpha near 180 degrees in reversed flow. It will happily produce a nose-down divergence flying backwards that looks like a reproduction of the real event and means nothing. All the SITL work above is about the cost and the plumbing, never the threshold.
### Description
#### The problem
Backing a QuadPlane up into a tailwind while descending can drive the flow over a conventional tail past separation. The tail loses its damping and the aircraft pitches hard nose-down with the VTOL rate controller saturated. In the source log the pitch went from +1 to -62 degrees in 0.4 s with the demand unchanged at +1 to +3 degrees, `ATT` and `AHR2` agreeing throughout, and the rate loop demanding +75 deg/s of nose-up while the measured rate ran to -211 deg/s the other way.
Nothing was logged when it happened.
#### Why the existing signals do not cover it
`AOA` in a Plane log is not a vane, and there is no AOA sensor in ArduPilot. It comes from `AP_AHRS::update_AOA_SSA()`, derived from the same EKF velocity and wind, and it contains:
```c
if (aoa_velocity.x > 0) { _AOA = degrees(atanf(z/x)); } else { _AOA = 0; }
```
so it is pinned to exactly zero whenever the body-forward airspeed is negative. Of the 2799 samples reading exactly 0.0 in the source log, 87% have negative body-forward airspeed and the rest are the separate 2 m/s magnitude cutoff in the same function; 10 samples out of 20351 disagree. It reports that reversed flow is *present* and discards how fast it is. Its jump to +71.6 degrees is the moment the aircraft stopped flying backwards, ie a consequence of the nose dropping rather than a warning of it. A pitot cannot sense direction at all and reads near zero flying tail-first.
So the detection resolves the EKF air velocity onto the body-forward axis, which recovers the signed magnitude neither exposes. Body axis rather than horizontal heading, so that a descent with the nose up is counted: that puts flow on the tail from behind with no horizontal motion at all, and reduced throttle with the stick held back produces exactly that combination.
#### Why this limits speed and not lean angle
I built the lean-angle version first and it does not work. Replayed against the source log, a limiter that squeezed the pitch-back angle towards a positive floor was active for **0.0%** of the six-second build-up and cut **0.0 degrees**, because the commanded lean was only 3 to 7 degrees the whole time while the backward airspeed went from 0.02 to 4.39 m/s. The lean angle was never the problem; the time integral was.
The ceiling on the pitch demand therefore follows the speed:
```
below Q_BKF_SPD_MIN ceiling = Q_A_ANGLE_MAX, ie no restriction
up to Q_BKF_SPD_MAX ceiling falls linearly from Q_A_ANGLE_MAX to zero
above Q_BKF_SPD_MAX ceiling = -(excess * Q_BKF_GAIN), to -Q_BKF_ANG_MAX
```
Continuous across both joins. Past the limit the ceiling goes negative, so nose-down is commanded and the backward motion is actually arrested rather than merely not commanded to grow. Replayed with the defaults, this first cuts the demand 3.1 s before the divergence and first commands nose-down 2.8 s before it, cutting up to 9.8 degrees and reaching 6.9 degrees of nose-down.
#### Parameters
| | default | |
|---|---|---|
| `Q_BKF_ENABLE` | 0 | 0 off, 1 log only, 2 log and limit |
| `Q_BKF_SPD_MIN` | 1.5 m/s | where the squeeze and the logging start |
| `Q_BKF_SPD_MAX` | 3.0 m/s | the backward airspeed held |
| `Q_BKF_GAIN` | 5 deg/m/s | nose-down commanded per m/s past the limit |
| `Q_BKF_ANG_MAX` | 10 deg | the most nose-down it may command |
An inverted band is rejected at arming rather than silently reinterpreted. New `BAKF` log message at 5 Hz above `Q_BKF_SPD_MIN` and whenever the limiter intervenes, plus a one-shot `STATUSTEXT` the first time the threshold is crossed in a flight.
Not applied to tailsitters, QACRO or QAUTOTUNE. Also deliberately not applied during `QPOS_AIRBRAKE`, where the demand comes from TECS while still decelerating out of forward flight and airbrake needs its nose-up authority, nor to the SystemID attitude offset, which is added after this runs and is meant to bypass constraints. The limit is dropped entirely while `force_fw_control_recovery` is set: that triggers at twice `Q_A_ANGLE_MAX`, which is the attitude a separation event produces if it happens anyway, and the recovery needs nose-up authority while this limiter commands nose-down.
#### Caveats that need to go in the wiki before this is usable
1. **The wind half of the estimate depends on having flown forward.** EKF3 only makes the wind states observable in forward flight, or in VTOL if `EK3_DRAG_BCOEF_X/Y` are set. Before the first transition of a sortie there is nothing cached and the calculation falls back to backward groundspeed. Across all seven SITL frames, a vehicle holding station in a 4 m/s tailwind during its initial VTOL climb genuinely sees 4.0 m/s over the tail and the estimate reads **0.00**. A VTOL takeoff, or a sortie that never transitions, is unprotected. EKF3 also re-seeds the wind states when it re-enables estimation, which on one frame left a legitimately-valid-but-wrong 0.00 cached for the whole VTOL phase. After a transition it tracks to about 0.5 m/s conservative. `BAKF.WSpd` logs the value actually used so which case applied is always visible.
2. **The fallback is wrong in both directions.** Without a wind estimate it under-reads in a tailwind (no protection) and over-reads backing into a headwind (limits when it need not).
3. **This caps how fast the aircraft can be flown backwards at all.** In a tailwind stronger than `Q_BKF_SPD_MAX` it cannot hold position and will drift downwind. In SITL with a 6 m/s tailwind and weathervaning off, QLOITER drifted at 3-4 m/s. Weathervaning turns the tailwind into a headwind and remains the primary defence; `Q_WVANE_ENABLE` should be left on.
4. **Interaction with `Q_FWD_THR_USE`.** `assign_tilt_to_fwd_thr()` runs immediately after this limiter in every mode it is wired into, and clamps the same `nav_pitch_cd` from both sides.
* *Below*, by `Q_FWD_PIT_LIM`: with forward-throttle-for-pitch active the nose-down demand is bounded and the forward motor does the work instead. That composes correctly but changes the character of the intervention, and the effective nose-down authority is then the smaller of the two.
* *Above*, by `Q_BCK_PIT_LIM`: that is a second ceiling on the pitch-back demand, so the two compose as the minimum and the squeeze may in practice be provided by whichever is lower. It is not a substitute for this limiter and does not overlap with what it protects against: `Q_BCK_PIT_LIM` scales off `ahrs.airspeed_EAS()`, a pitot magnitude, so flying tail-first it reads near zero, the `(AIRSPEED_MIN/IAS)^2` scaler blows up, and the limit clamps to `Q_A_ANGLE_MAX`, ie no restriction at all in exactly the regime this PR is about. Note also that #34177 fixes a units mixup that left that filter effectively unfiltered; with it applied the ceiling lags rather than tracking the raw scaled limit, which is more restrictive on pitch-back in the transient out of forward flight, ie the same direction this limiter pushes. The two PRs touch different functions and apply cleanly over each other.
5. **The defaults come from one event.** In the source log the divergence peaked at 4.39 m/s where the whole flight peaked at 4.92 m/s. There is no clean separation between the event and ordinary backward flight in that data. `Q_BKF_SPD_MAX` is an envelope limit, not an event discriminator.
#### Refining the parameters by flight test
Nobody should take the defaults on trust. Suggested procedure:
**Phase 1, characterise, `Q_BKF_ENABLE=1`.** Nothing intervenes. Set `Q_BKF_SPD_MIN` low, 0.5 m/s or so, to log widely, and set `EK3_DRAG_BCOEF_X/Y` if the VTOL phases matter. Fly several normal sorties and graph `BAKF.BSpd` against `BAKF.BGnd` and `BAKF.WSpd`. Two things come out: the p95 and peak backward airspeed in ordinary flying, which is what determines how intrusive any given limit will be, and whether `WSpd` is sane during the VTOL phases or has been lost to one of the cases in caveat 1.
**Phase 2, find the onset.** At altitude with plenty of margin, in QLOITER, back up progressively faster in stages and watch the pitch tracking error. The number wanted is the `BSpd` at which the aircraft first shows an uncommanded nose-down excursion. In the source log that was about 4.4 m/s. Approach it in small steps and stop at the first sign, rather than trying to reproduce a full divergence.
**Phase 3, choose values.**
* `Q_BKF_SPD_MAX` at roughly 60-70% of the onset speed from phase 2. 3.0 against an onset of 4.4 is the ratio the defaults use.
* `Q_BKF_SPD_MIN` at about the p90-p95 of the normal-operations `BSpd` from phase 1, so the squeeze rarely bites in ordinary flying. If `BAKF.Lim` is set during routine manoeuvring, it is too low.
* `Q_BKF_GAIN` 5 to start. Higher recovers the limit faster and feels more abrupt.
* `Q_BKF_ANG_MAX` 10 to start, and keep it small. A large commanded nose-down in reversed flow is the attitude this feature exists to avoid.
**Phase 4, arm it, `Q_BKF_ENABLE=2`.** At altitude with margin. Confirm the backward speed is actually held near `Q_BKF_SPD_MAX` and that the intervention is acceptable to fly. Then check QLAND and QRTL in wind specifically, since the limit reduces the ability to hold the landing point in a tailwind and that is the case most likely to bite operationally.
Iterating on further logs is expected. I would not describe the numbers here as more than a starting point.