Making Refloat's control loop frequency-independent

Note: This post was written with the intention to be a general report on the work being done and also provide some technical details for developers and technically inclined audience. Feel free to skip over the technical sections.

Over the past several months, I’ve been working on a significant overhaul of the timing and synchronization infrastructure in the Refloat package. This work addresses fundamental issues with how the package handles different loop frequencies and ultimately moves the core balancing control directly into the IMU callback, eliminating a source of timing jitter.

Background: timing issues in the control loop

This work addresses two main timing deficiencies:

  • IMU sampling and package loops running slower than expected due to high CPU load and scheduling realities
  • Timing jitter from CPU load, scheduling, and lack of synchronization between IMU and package loops

Loop frequency degradation

The VESC microcontroller runs under significant load from two sources:

  • Motor modulation (load proportional to Zero Vector Frequency)
  • Inefficient IMU communication (load proportional to IMU Sample Rate)

When users set their package Loop Hertz to 832 Hz, it is, due to the CPU load and scheduling, actually running closer to 720 Hz (it can range roughly between 690 and 750 Hz depending on configuration). The IMU situation is even worse—on current firmware, configuring 833 Hz for the LSM6DS3 IMU (used in controllers like Thors, Little FOCers v3.1+, and JetFleet) results in roughly 550-650 Hz due to implementation issues. I’m partially addressing these in parallel firmware work, though a full fix will eventually require hardware changes.

This discrepancy between expected and actual frequency causes filters (always configured against loop frequency) and other frequency-dependent calculations to produce slightly different results proportional to the gap between expected and actual frequencies.

Timing jitter

Timing jitter is an omnipresent problem in control systems—essentially a random timing variation introduced by discretization in digital processing. One of the goals of a good control system is to minimize it. To give an idea, here’s a sample plot illustrating the current state of the timing of the IMU and package loops:

The plot shows the dt and frequency values for the IMU and Refloat Main threads. dt is the time between loop iterations in milliseconds and frequency is a filtered reciprocal of that, in Hertz. The jitter (spikes) you see in the main thread won’t affect motor control after the PID calculation has been moved into the IMU callback.

The road to Loop Hertz independence

The solution to both issues is to make the code resilient against timing discrepancies. A lot of algorithms throughout the package needed changes to become loop-hertz-independent. Some had to be tweaked in various ways, Reverse Stop needed a complete rewrite and some general mechanisms needed to be adopted across the codebase:

Delta time calculations

Many of the simple fixes amounted to using dt (delta time) in calculations where the Loop Hertz value was previously used. This makes them naturally frequency-independent and resilient against jitter.

Frequency tracking and filter reconfiguration

While most algorithms can be made loop-hertz-independent fairly easily, filters can’t in practice. They can theoretically adapt to varying dt, but it’s more computationally intensive and adds significant complexity for some filter types.

To handle this, I added frequency trackers for both the main loop and IMU loop. They use slow EMAs to filter instantaneous frequency measurements and trigger filter reconfiguration when the frequency drifts more than 3% from the configured value.

Reconfiguration is throttled to once per second and only while riding, since current modulation when engaged creates enough CPU load to affect how scheduling lines up, so its impact on actual frequencies
can be to an extent arbitrary and bigger than expected.

Here are a couple of examples of how filters are now handled:

Exponential moving average filters

Exponential moving average (EMA) filters are by far the most used filters throughout the package. This simple filter is calculated like this:

value += 0.01 * (new_value - value);

The 0.01 is the alpha constant. It has loop frequency baked in—the more often the calculation runs, the faster it filters.

Now, we replace the 0.01 constant with a cutoff frequency constant (say, 1 Hz), then calculate alpha from the cutoff frequency and the actual loop frequency. For EMA, this is the calculation (it’s not the exact formula, but a much faster approximation):

float omega = 2π * cutoff_freq / update_freq;
alpha = omega - (omega * omega) / 2;

With this, a 1 Hz cutoff behaves like 1 Hz whether the loop runs at 720 Hz or 832 Hz. Balance current smoothing, duty cycle filtering, battery current averaging—they all now use properly configured EMAs that adapt to the actual loop frequency.

Simple (arithmetic) moving average filter

Despite its name, this is the trickiest filter in the codebase. It’s used for smoothing measured motor acceleration.

It is implemented by storing a window of the last N samples and calculating their average every cycle. N determines filter speed and must be adjusted when frequency changes. This means the window (an array) must shrink or grow while keeping the calculations correct.

To achieve this, the implementation allocates the array with 20% headroom so N can increase without reallocation if frequency rises. When N needs to change, it waits until the circular buffer wraps, then smoothly transitions by adjusting the live value and filling new slots.

Moving PID control to the IMU callback

The biggest architectural change is moving PID (balancing) control into the IMU callback. This removes a discontinuity in the signal path—previously the Main thread would wake at random intervals, unsynchronized with incoming IMU data.

The flow before (↯ represents discontinuity):
IMU Sample
 ↯
IMU thread: update balance filter
 ↯
Refloat Main thread: calculate PID → request current
 ↯
Modulation interrupt: apply current

After the move:
IMU Sample
 ↯
IMU thread: update balance filter → calculate PID → request current
 ↯
Modulation interrupt: apply current

Note the two remaining discontinuities. The second one can’t be avoided but matters little—the modulation runs at more than an order of magnitude higher frequency. The first matters and to a big extent should be mitigated by my firmware IMU work (again, so far only for the LSM6DS3 IMU). A complete fix requires hardware changes.

Practical impact and configuration migration

In general I expect an increase in consistency when applying tune settings on different boards. Setting a particular value on different boards should now lead to a more consistent change in behavior.

Consequently, tunes should now transfer more cleanly between boards. Barring different motors (but see below) and different IMU configurations, sharing a config should now mean the other person gets the same behavior as you (or at least much closer).

Vibration rejection

I’ve performed the following test to scientifically determine the effect of the changes on vibration rejection:

The video mainly advertises a massive improvement in the feedback loop vibration with my firmware IMU handling improvements (see below), but we can see the package change also lessens the vibrations by about 10%.

Nosehunting

I believe the vibration experiment can be an indicator of similar improvements in the nosehunting territory. That is, a potential minor improvement from the package changes. Testing nosehunting is hard and I suspect it has multiple distinct causes. The firmware improvement did fix one clear case of nose hunting for me (though it was a one-off test which was not entirely conclusive and needs confirmation), more on that in another post.

Configured angle rates

All configured angle rates (all Tilts, nose angling, and pushback speeds) are affected by these changes, because their calculation was based on configured Loop Hertz. The actual frequency was lower, so the rates were running about 13% (on average, the difference can be anything between 9-17% depending on configuration) slower than configured, which is now fixed. While this could be compensated internally in the package, it would mean keeping the actual numbers in the config wrong, so I’m not goning to do that.

Instead, the rate numbers will be corrected in users’ configs during config migration. Config migration occurs only in AppUI-based config handling—this includes automatic config restore after package updates and all AppUI tunes and backups (except tune archive tune application). Restoring XMLs and the Start Page “Restore Configs” does not migrate the package config to the new version. Tunes in third-party apps like Floaty and Float Control will also be affected (you’ll need to fix them manually).

Update: I decided not to correct the angle rates. Main reason is, for the Tilts, where it’s having most impact, more advanced smoothing will be introduced. The change in the rates is an effective speedup and the better smoothing will naturally allow for faster rates, while slowing the transitions down a bit in itself, so in a way they’ll partially cancel each other out. OTOH, the migration would make working with XML backups between versions harder, it doesn’t seem worth it.

Complementary firmware improvements

These changes tie into improvements I’m making on the firmware side for IMU communication. Current firmware has issues that cap the effective IMU sample rate well below configuration. My firmware work allows sustained sampling around 1250 Hz for the LSM6DS3 IMU—between 2x and 3x what most users run today.

Higher IMU sample rates mean lower latency (arguably below noticeable level on a Onewheel) and better rejection of vibration noise. The package improvements outlined in this post provide immediate benefits on current firmware while laying the foundation to take full advantage of higher sample rates.

I’m going to post about the IMU improvements soon and I’ll be making my own (6.06) firmware release with this and a few more Onewheel-specific goodies.

Looking forward

The changes are almost ready for mainlining, they just need a more thourough testing and tying up a few loose ends. There’s one more change I’d like to add:

On firmware 6.06+, the package has access to the Motor Config Flux Linkage value. This parameter expresses the motor torque/current relationship. I want to normalize all current values to torque values for the particular motor, making tunes even more consistent across boards with different motors. For firmware 6.05 and older, a compatibility constant will be used to preserve old behavior.

This should be it for Refloat 1.3, which I’d like to release as soon as possible. The release time now depends on how quickly the changes can be tested and validated across a wider range of setups. 1.3 will be a major unification release, at the cost of minor behavior changes. After this, all calculations should be cleaned up and much closer to the theoretical ideal.

The code is available right now in the testing branch of the Refloat repository: GitHub - lukash/refloat at testing

I will provide a package build soon. At the moment I’m still undecided whether I’ll do a “Feature Preview” like I did in the past for some features, or if I’ll go straight for a 1.3 beta.

If you’d like to help testing these changes, please get in touch here or on Discord. Any testing is welcome and appreciated.

Support the project

I invest a lot of time and energy into developing and testing of these improvements. If you would like to support Refloat development, here’s a few options to do so.

6 Likes

A minor update, I decided not to correct the angle rates. Main reason is, for the Tilts, where it’s having most impact, more advanced smoothing will be introduced. The change in the rates is an effective speedup and the better smoothing will naturally allow for faster rates, while slowing the transitions down a bit in itself, so in a way they’ll partially cancel each other out. OTOH, the migration would make working with XML backups between versions harder, it doesn’t seem worth it.