Skip to content

mixer: thrust linearization, ported from Betaflight - #12121

Open
MrScothh wants to merge 12 commits into
iNavFlight:next-10.1from
MrScothh:feature/thrust-linearization
Open

MrScothh wants to merge 12 commits into
iNavFlight:next-10.1from
MrScothh:feature/thrust-linearization

Conversation

@MrScothh

@MrScothh MrScothh commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Builds on #12120 (throttle boost), which builds on #12118 (afatfs_poll out of ITCM). The settings of both mixer PRs
share one layout in motorConfig_t.

Betaflight's thrust linearization, for multirotors. A propeller's thrust grows faster than linearly with the motor
command, so the same PID correction moves the quad less at low throttle than at high throttle. thrust_linear
(0-100, default 0, off) raises each motor output and lowers the throttle before the mix by the inverse amount, so the
response is more even across the throttle range while hover stays where it was.

The formulas are Betaflight 2026.6's (betaflight/betaflight#15226), a second-order expansion of ArduPilot's MOT_THST_EXPO
model. The value is therefore about MOT_THST_EXPO times 100, and ArduPilot's per-propeller values carry over. With
e = thrust_linear / 100, on 0-1 over the throttle range (idle to max):

  • each motor output u becomes u * (1 + e * (1 - u) * (1 + e * (1 - 2u))), the curve on each motor before the
    clamps, in the same place as Betaflight;
  • the throttle t becomes t * (1 - e * (1 - t)), the inverse of the curve to second order. Hover moves by at most
    8 us up to 50, 23 us at 70 and 80 us at 100, at worst near 15 % throttle.

Differences from Betaflight:

  • Range 0-100. Betaflight allows 150. Above 100 its compensation goes negative at low throttle, which puts the
    motors at idle around a third of the stick, and from about 130 the curve is no longer monotonic.
  • Outputs at or below idle are left alone. INAV's failsafe clamp goes down to min_command, and the curve would
    push a below-idle output further down.
  • Only the motors get the lowered throttle. OSD, telemetry and servos keep showing the throttle that was asked
    for.
  • Global, not per PID profile. It lives in motorConfig_t, so a profile switch cannot change it in flight.
  • Off on VTOL setups (any mixer profile that is not a multirotor). Switching it off and on at transitions would
    step the motors.
  • Only with a throttle weight of 1 on every motor. INAV multiplies the absolute command by each motor's mixer
    weight, so with any other weight the compensation is not the curve's inverse. The standard multirotor mixes all
    use 1.
  • It is also off with reversible motors and with the logic-condition throttle override.

The formulas are in flight/thrust_linear.h, as two pure inline functions with their own unit test:

  • off is the identity;
  • hand-worked Betaflight values;
  • the ends are kept, the curve is monotonic and only ever raises an output;
  • the compensation is never negative;
  • hover is kept within the tolerances above.

The throttle compensation runs in the throttle boost's out-of-line call before the motor loop. The curve is one
out-of-line call per motor, to keep mixTable() small in ITCM.

Settings storage

thrust_linear is appended after the throttle boost settings without a version bump, at offset 15 of 16 on every
target (checked in the debug info of F7, F405 and H7 builds). That is the tail padding of a record saved with #12120,
stored as zero, or past the end of an older one, and its default is 0.

Bench

Flywoo F722 Pro V2 (FLYWOOF722PRO image), no battery, so the motors could not spin. I armed it over MSP RC with a
QUADX mixer, throttle_boost 0, and held the throttle at three levels. The mean motor output matches the formulas:

thrust_linear throttle 1182 1589 1793
0 1182 (formula 1182.0) 1589 (1589.0) 1793 (1793.0)
50 1175 (1174.9) 1596 (1596.0) 1793 (1793.0)
100 1112 (1113.4) 1633 (1632.8) 1809 (1808.8)

At 1182, about 11 % throttle, the compensation lowers a little more than the curve raises: that is the worst corner
of the second-order inverse, as in Betaflight. With 0 the motors get exactly the throttle, as before.

Size, over the throttle boost PR

Flash +312 to +352 B (MATEKF722SE +336 B, 98.66 %). ITCM +32 B on F722 targets, +40 B on KAKUTEH7MINI, 0 on
KAKUTEF7/KAKUTEF7HDV (15 360 / 15 480 B). RAM: 4 B of FASTRAM.

Testing

The unit tests pass, the new ones included. Not flown. Flight tests would be welcome: a quad flown with 0 and then with its propellers' MOT_THST_EXPO x 100,
looking at how it holds attitude at low throttle and on throttle chops.

Log header

The header also carries thrust_linear, under Betaflight's name (b2fd306). The same log on MAMBAF722_2022B with it
at 30: H thrust_linear:30. +32 B of flash on MATEKF722SE over #12120 (98.68 %). All 237 targets build with both changes, and the unit tests pass (620 of 620 on #12120, 625 of 625 here).

taskRunRealtimeCallbacks() calls afatfs_poll() from scheduler(), which is
FAST_CODE, and LTO inlines the file system's state machine into it. On
targets with an SD card that puts 800 to 980 bytes of afatfs code in ITCM,
depending on the target, where KAKUTEF7 and KAKUTEF7HDV are at 99%. The
poll does a little work at most once per scheduler loop, so it runs from
flash like the rest of afatfs.
Adds a high-pass filtered copy of the throttle on top of it, so the motors
follow quick throttle moves sooner: throttle_boost (0-100, the boost is
the value / 10 times the high-passed throttle) and throttle_boost_cutoff
(5-50 Hz, 15 by default), as in Betaflight. Off by default.

Only the motors get the boost: mixerThrottleCommand, which OSD, telemetry
and servos read, stays the throttle that was asked for. Multirotor only,
and not while navigation controls the throttle, during a VTOL transition,
with reversible motors or with the logic-condition throttle override.
After the mixer skipped it for more than 100 ms (disarmed, motors stopped,
navigation in control), the filter restarts from the current throttle.

Both settings are appended to motorConfig_t without a version bump: the
first one lands in the old record's tail padding, stored as zero, which is
its default, and the cutoff starts past the end of any stored record, so
it keeps its default of 15 Hz.
A propeller's thrust grows faster than linearly with the motor command, so
a multirotor responds less to the same correction at low throttle.
thrust_linear (0-100, off by default) raises each motor output and lowers
the throttle before the mix by the inverse amount, with the formulas of
Betaflight 2026.6 (#15226): a second-order expansion of ArduPilot's
MOT_THST_EXPO model, so the value is about MOT_THST_EXPO * 100 and hover
stays where it was (within 8 us up to 50, 80 us at 100). Betaflight allows
150, but above 100 its compensation goes negative at low throttle and from
about 130 the curve stops being monotonic, so the range stops at 100.

The formulas live in flight/thrust_linear.h with their own unit test.
Unlike Betaflight, outputs at or below idle are left alone, so the
failsafe clamp to min_command is not pushed further down, and the setting
is global rather than per PID profile. As with the throttle boost, only
the motors get the lowered throttle.

Multirotor only. Off on VTOL setups (any mixer profile that is not a
multirotor), so it never switches at a transition; also off with
reversible motors and with the logic-condition throttle override.

Built on the throttle boost commit, so the three settings share one
layout in motorConfig_t: thrustLinear lands in the tail padding of that
record, stored as zero, which is its default.
@qodo-code-review

Copy link
Copy Markdown
Contributor

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add multirotor throttle boost and thrust linearization

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add optional throttle boost so multirotor motors respond sooner to rapid throttle changes.
• Add thrust linearization to improve low-throttle correction while approximately preserving hover
 throttle.
• Keep displayed throttle unchanged and limit the effects to supported motor configurations.
Diagram

graph TD
    R["Requested throttle"] --> B["Throttle boost"] --> G{"Linear allowed?"}
    G -->|yes| C["Throttle inverse"] --> M["Motor mix"] --> L["Output curve"] --> O["Motor outputs"]
    G -->|no| M
    M -->|off| O
    R --> D["Displays and servos"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use an exact inverse for throttle compensation
  • ➕ Could preserve hover throttle more closely at high linearization settings.
  • ➖ Adds computation or a lookup table to a resource-constrained mixer path.
  • ➖ Departs from the referenced Betaflight behavior.

Recommendation: Keep the Betaflight-derived approximation and out-of-line mixer helpers: they limit execution-memory growth and have bounded, tested hover error. An exact inverse is worth reconsidering only if flight testing shows that the residual hover shift matters.

Files changed (7) +264 / -2

Enhancement (3) +126 / -1
mixer.cApply motor-only throttle boost and thrust shaping +90/-1

Apply motor-only throttle boost and thrust shaping

• Adds filtered throttle boost and compensates throttle before mixing, then curves eligible motor outputs before clamping. Preserves the original throttle command for other consumers, disables effects in incompatible modes, and keeps added work largely outside the ITCM mixer body.

src/main/flight/mixer.c

mixer.hStore new controls in motor configuration +3/-0

Store new controls in motor configuration

• Appends three byte-sized settings to motorConfig_t. The zero defaults for boost and linearization support compatibility with previously stored tail padding.

src/main/flight/mixer.h

thrust_linear.hDefine thrust curve and throttle compensation +33/-0

Define thrust curve and throttle compensation

• Adds pure inline functions for the Betaflight-derived motor-output curve and its second-order throttle compensation.

src/main/flight/thrust_linear.h

Tests (1) +88 / -0
thrust_linear_unittest.ccTest thrust-curve behavior and hover error +88/-0

Test thrust-curve behavior and hover error

• Tests disabled behavior, reference values, endpoints, monotonicity, nonnegative compensation, and bounded hover-throttle error across supported settings.

src/test/unit/thrust_linear_unittest.cc

Documentation (1) +30 / -0
Settings.mdDocument throttle boost and thrust linearization +30/-0

Document throttle boost and thrust linearization

• Adds user-facing descriptions, ranges, and defaults for throttle boost, its cutoff, and thrust linearization. Documents applicability limits and the approximate hover-throttle behavior.

docs/Settings.md

Other (2) +20 / -1
settings.yamlExpose three motor settings +18/-0

Expose three motor settings

• Registers throttle boost, boost cutoff, and thrust linearization in the motor settings group with their bounds and disabled-by-default behavior.

src/main/fc/settings.yaml

asyncfatfs.cKeep filesystem polling out of ITCM +2/-1

Keep filesystem polling out of ITCM

• Marks afatfs_poll as non-inline so link-time optimization does not pull its state machine into the fast-code scheduler path.

src/main/io/asyncfatfs/asyncfatfs.c

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Custom motor mixes can lose throttle ✓ Resolved
Description
compensateThrustLinearization() lowers the common throttle before the motor loop multiplies it by
each motor's throttle coefficient, so the compensation is not the inverse of the curve applied to
that motor. With a valid coefficient of 0.8, a 1500 command, a 1000–2000 range, no attitude
correction, and thrust_linear at 100, the motor reaches the 1000 idle clamp instead of its
previous 1200 output.
Code

src/main/flight/mixer.c[940]

+        float motorThrottle = motorThrottleCommand * currentMixer[i].throttle;
Evidence
The CLI accepts custom throttle coefficients, which the motor loop multiplies against the
already-compensated command. At the stated inputs, the compensation formula gives 1250; multiplying
by 0.8 gives 1000, while the setting-off path gives 1200. With zero attitude mix, the throttle lower
bound is 1000, and the curve leaves an output at or below that bound unchanged.

src/main/fc/cli.c[1220-1228]
src/main/flight/mixer.c[713-723]
src/main/flight/mixer.c[918-940]
src/main/flight/mixer.c[725-733]
src/main/flight/thrust_linear.h[29-33]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The common throttle is compensated before a custom motor's throttle coefficient is applied. For coefficients below one, this can move an otherwise active motor to idle.
## Fix Focus Areas
- src/main/flight/mixer.c[713-739]
- src/main/flight/mixer.c[937-977]
## Recommended Fix
Apply inverse compensation in each motor's throttle coordinate space after its coefficient is applied, before adding attitude correction and applying the output curve. Preserve the existing exclusions and verify custom coefficients below one against the setting-off motor output.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Motor restarts can jump in throttle ✓ Resolved
Description
applyThrottleBoost() retains its filter state and resets it only when more than 100 ms have
elapsed since its last call. Disarming or stopping the motors returns from mixTable() before that
call, so restarting within 100 ms computes a boost or cut against the throttle from before the stop.
Code

src/main/flight/mixer.c[R685-689]

+    const timeMs_t nowMs = millis();
+    if (nowMs - lastBoostMs > THROTTLE_BOOST_RESTART_MS) {
+        pt1FilterInit(&throttleLpf, motorConfig()->throttleBoostCutoff, dT);
+        pt1FilterReset(&throttleLpf, throttle);
+    }
Evidence
The early return prevents the filter from seeing a stopped iteration. Its static timestamp and
filter state persist, while the elapsed-time check is the only reset before the next high-pass
calculation.

src/main/flight/mixer.c[668-693]
src/main/flight/mixer.c[754-765]
src/main/flight/mixer.c[911-914]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A disarmed or motor-stopped mixer iteration skips the boost filter, leaving its prior state available to a restart within 100 ms.
## Fix Focus Areas
- src/main/flight/mixer.c[668-693]
- src/main/flight/mixer.c[754-765]
## Recommended Fix
Invalidate the boost filter state on the disarmed and motor-stopped early-return paths, then initialize it from the current throttle on the first running iteration. Do not rely solely on elapsed time to recognize a motor restart.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can import rules and skills only from the repositories your team follows

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/main/flight/mixer.c
Comment thread src/main/flight/mixer.c Outdated
…change

The low-pass restarted only after 100 ms without the mixer, so a shorter
motor stop resumed against the throttle from before the stop, and a cutoff
changed while mixing kept the old one. Restart from the current throttle
whenever the gap is longer than any mixer period (20 ms; looptime is at
most 9 ms) or the cutoff differs from the one in use.
… motor

INAV multiplies the absolute throttle command by each motor's mixer
weight, so the throttle compensation, worked out on the common throttle,
is not the inverse of the curve for any other weight: with 0.8, a 1500
command and thrust_linear 100, a motor went from 1200 down to idle. The
standard multirotor mixes all use 1; with any other weight the feature
stays off.
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RAM / Flash usage vs. base commit 13ebed9 — commit aadd73f

Target Flash Δ RAM Δ
MATEKF405 +880 B (+0.12%) CCM: +8 B (+0.02%)
RAM: +32 B (+0.03%)
MATEKF722 +736 B (+0.15%) ITCM_RAM: +48 B (+0.41%)
RAM: +48 B (+0.06%)
TCM: +8 B (+0.03%)
MATEKF765 +888 B (+0.12%) DTCM_RAM: +8 B (+0.03%)
SRAM1: +48 B (+0.04%)
MATEKH743 +836 B (+0.10%) D2_RAM: ±0 B (±0.00%)
DTCM_RAM: ±0 B (±0.00%)
ITCM_RAM: +48 B (+0.31%)
RAM: +32 B (+0.02%)

See RAM/flash optimization guide for techniques to reduce usage.

@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Test firmware build ready — commit aadd73f

Download firmware for PR #12121

251 targets built. Find your board's .hex file by name on that page (e.g. MATEKF405SE.hex). Files are individually downloadable — no GitHub login required.

Development build for testing only. Use Full Chip Erase when flashing.

The throttle boost fields follow the SRXL2 ones, which existed only where
USE_MOTOR_SRXL2 is defined. A target that turned it on or off would then
read its stored boost cutoff as the SRXL2 reverse channel, or the other
way round, and a cutoff of 0 blocks arming as an invalid setting. With
the fields always there the layout is the same everywhere; on targets
without SRXL2 they stay zero and unused.
As Betaflight writes them, so a log says whether the boost was on and
how strong it was.
As Betaflight writes it, so a log says whether the linearization was on
and how strong it was.
# Conflicts:
#	src/main/io/asyncfatfs/asyncfatfs.c
@sensei-hacker sensei-hacker added this to the 10.1 milestone Oct 27, 2026
@sensei-hacker
sensei-hacker changed the base branch from maintenance-10.x to next-10.1 October 27, 2026 01:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants