Van's Air Force

The definitive Van's Aircraft support community! Buying, building or flying an RV? Join our exclusive family of mentors and enthusiasts!

DIY dynamic prop balancer?

I finally made it to the hangar and did a preliminary run. Ran into some problems with my tach signal. It appears the nuts and washers used to mount the prop are reflective enough to produce multiple false tach signals per revolution. I was able to mitigate this some by spacing the sensor out with washers below the angle, and decreasing the pulse width for LED modulation. This was enough to get me a clean tach signal at lower RPMs, but when I did a full throttle runup I would start having problems again.

View attachment 115381View attachment 115395

Likely will need to space this out just a bit further to get a clean signal. Until then, I think I can filter out the erroneous readings and perform some analysis on the data I was able to collect.
I struggled to get a clean tach signal as well - filtering and windowing finally got the signal cleaned up nicely. I'm still not getting repeatable results, but I have some code changes that I can test on Sunday.

Keep us posted - this is fun stuff!
 
The spectrum display is working, but I still need better underlying data - I'm working on it......

AP1GczMqneh16kGlkyP5mOBP0olttSI2Ev_9r7tj4_mLvXnWzB2ZLkI5zEq0Jg2goJYp0FGRXQUpE6c2-jYLesmWZko8kUCA-aOlJG9XHQO7EAf_i8hhJ5zqss1MkVaW21KKQF5SOiyajbVDdaSypn3FU7ui5w=w800
 
If anyone is still following along - I did an in flight baseline/trial weight balance today. I coded an autocapture to grab the best data from each flight, but still captured all data in the background. Here are the results I got:

2 best data points for rpm stability and phase std dev
1776645435892.png
Here is the raw data for the 2 best points:
Baseline

1776645800145.png
Trial Weight
1776645885153.png

I also looked at using more of the rest of the data including other high quality data points by confidence or std dev, and then just using all data points and got these results:

1776645656754.png
Direction looks solid - weight has some variation!

I've added 21.36g at hole 11 - hope to fly the solution tomorrow. If it works, the math says I'll show a predicted residual: ~0.047 IPS. I'm skeptical.

Let me know if any of this looks wrong or if I could use other methods. I got the lowest tach jitter in the 4 state area and can hold RPM to the tenth - so there is that! he RPM is an average over 128 revs - thus the decimal///////
 

Attachments

  • 1776645522197.png
    1776645522197.png
    12.3 KB · Views: 2
I struggled to get a clean tach signal as well - filtering and windowing finally got the signal cleaned up nicely. I'm still not getting repeatable results, but I have some code changes that I can test on Sunday.

Keep us posted - this is fun stuff!
I'm running into the same problems you have. In addition to tach timing issues, it's looking like I need to improve my sensor mount. As I vary the RPMs I'm showing a pretty strong resonance around 73 Hz. This is likely not being helped by the washers I used to space the sensor out further.

What are the maximum g values you're reading? I'm getting some that are maxing out my sensor.
 
I'm running into the same problems you have. In addition to tach timing issues, it's looking like I need to improve my sensor mount. As I vary the RPMs I'm showing a pretty strong resonance around 73 Hz. This is likely not being helped by the washers I used to space the sensor out further.

What are the maximum g values you're reading? I'm getting some that are maxing out my sensor.
I'm not logging g's, just the result ips in each direction. I'm not maxing anything out that I know of. The flight today was pretty messy data(it was really turbulent today) - I will try again tomorrow.

My mount is pretty robust, short and bolted directly to the case. That said - it still could be ringing.

The data is just far more variable than i expected. I'll keep trying.
 
For a clean tach signal, on a Lycoming... I'm now driving my JPI engine monitor with an OTS speedometer sensor!
So far is working fine. Has been installed for about 40 hours.

The Sender: https://intellitronixgauges.com/gm-mopar-vw-universal-speedometer-sending-unit/
Note that it has the correct 7/8 thread, but uses the square tang drive.
fortunately, GM to the rescue - part 25032338 is a 1970's era speedometer drive "key". Just a plastic part that slips over the square tang, which then drops right in to the Lycoming drive. THEN ... you get a clean hall effect pulse signal.

The GM part, perhaps your local GM/Chevy dealer can order and you can avoid shipping? Cheap part, I've seen it for $5.
The sender is $58. plus shipping.

versus the UMA tach sensor at $197. Plus shipping.
 
For a clean tach signal, on a Lycoming... I'm now driving my JPI engine monitor with an OTS speedometer sensor!
So far is working fine. Has been installed for about 40 hours.

The Sender: https://intellitronixgauges.com/gm-mopar-vw-universal-speedometer-sending-unit/
Note that it has the correct 7/8 thread, but uses the square tang drive.
fortunately, GM to the rescue - part 25032338 is a 1970's era speedometer drive "key". Just a plastic part that slips over the square tang, which then drops right in to the Lycoming drive. THEN ... you get a clean hall effect pulse signal.

The GM part, perhaps your local GM/Chevy dealer can order and you can avoid shipping? Cheap part, I've seen it for $5.
The sender is $58. plus shipping.

versus the UMA tach sensor at $197. Plus shipping.
Very interesting Bill! I wonder if this could be modified into an ignition trigger? Says 8 pulse generator - interesting. Would work for sure for what we need here.

I did some timing cleanup in my code tonight using hardware trigger vs software triggers - it should match the engine position to the vibe more accurately. Test tomorrow........ (I say that a lot)
 
I finally made it to the hangar and did a preliminary run. Ran into some problems with my tach signal. It appears the nuts and washers used to mount the prop are reflective enough to produce multiple false tach signals per revolution. I was able to mitigate this some by spacing the sensor out with washers below the angle, and decreasing the pulse width for LED modulation. This was enough to get me a clean tach signal at lower RPMs, but when I did a full throttle runup I would start having problems again.

View attachment 115381View attachment 115395

Likely will need to space this out just a bit further to get a clean signal. Until then, I think I can filter out the erroneous readings and perform some analysis on the data I was able to collect.
Made it back to the hangar for another test run. My main goal was to improve the tach signal. As I mentioned previously, I suspected the reflections off the prop nuts and washers were causing false tach signals. This is the RPM graph from my last run with two washers spacing the sensor out and the LED PWM still at 30%. The actual prop RPM is right around 1000. All those noisy peaks indicating higher RPMs are likely false reflections.

Screenshot 2026-04-22 at 10.49.17 PM.png

The solution I wanted to test was a redesigned aperture for the IR sensor. Here's a before and after cutaway view of the sensor mounted on my drill powered bench test. The ideal solution would be a lens to help focus this. But through careful design of the opening, I think we can get this close enough.
Screenshot 2026-04-22 at 10.13.16 PM.pngScreenshot 2026-04-22 at 10.13.36 PM.png

And here is the resulting RPM graph. Still at 30% PWM and no washer spacers.
Screenshot 2026-04-22 at 11.12.16 PM.png
Significant improvement. The lower RPMs are nice and clean. It's only at full throttle where I get a total of 5 false tach signals. I'm not sure why false signals seem to get worse at higher RPMs. But, I think if I tilt the LED and the receiver openings slightly I should be able to get rid of those too. If not, I'll adjust the LED PWM back down.

The good news is it appears to have excellent sunlight rejection. I was operating without the engine cowling and had sunlight directly on the spinner backplate. I tried multiple orientations and even moved into a shaded area without noticing any difference in performance.
 
Again my solution would be to move the opening as close as possible (fractions of an inch) to the back of the spinner.
any way you could put a tube in front of the opening and somehow support it all the way to the back of the spinner?

(I need to work on this myself but really need to stop finding excuses and go make that first flight since my forced landing 1 1/2 years ago and first flight in 5 years for the RV-3B.)
 

Attachments

  • P1030294.JPG
    P1030294.JPG
    31.6 KB · Views: 21
Trying to catch up. Got the ADXL355Z and ESP32DEVKITV1 mounted and wired on a bracket ready to bolt onto the tip of the gearbox and a RPR-220 PhotoInterrupter (6mm optimal distance) mounted in a 1/2" OD Pex tube (see photo in above post) about 1/16" from spinner back plate.

I like the idea of using my Android phone for control/display via BLE rather than running wires to cockpit, but Dang! that's a long road with a steep learning curve! Also, even though I found a dual-core example, I still have to figure out how to share variables between core1 and core2 code, as well as limiting BLE code to just one core.

Looked at video on designing an Android phone BLE app using MIT App Inventor. A lot of work, using that graphic interface. (Actually both there and in the Arduino code.) Each characteristic (piece of data to be transmitted) requires a UUID be defined and much more. But why keep it somple to transmit data when we can make it complicated? ;)

I'm using Arduino IDE. If you would post or send me your code that may be helpful (or maybe just overwhelming).

My first attempt at an algorithm will be this simple:
One interrupt routine will save tach interrupt time (in uS) and calculate one prop rotation period time and RPM.
The other interrupt will be from the ADXL355 data ready signal pin (one every 1/4000 = 250uS). It will save interrupt time (uS) and probably read accel data.
Testing shows that it takes about 50uS to read one sample from the ADXL355.
If RPM is within a specified narrow range it will add to a 360 slot array (I think 1 degree resolution will be OK for now), the slot selected by accel data ready time in relation to tach interrupt time and period time -- all in microseconds. So (x, y, NumberOfSamplesAdded) in each array slot:
if ((rpm > 1250) and (rpm < 1270)){
//add values to applicable slot in period
int index = 360 * int((sample_time_us - last_tach_time_us) / period_us);
sample_buffer[index].NumberOfSamples =+ 1;
sample_buffer[index].x =+ d.x_g;
sample_buffer[index].y =+ d.y_g;
}
Degree location should then be the slot with the highest accel value: sqrt(x*x+y*y), where x and y would be results of the accumulated value in the array divided by NumberOfSamples in that array slot. In theory, the longer you can stay in or pass through the specified RPM range, the more accurate the result should be,

Obviously this will be refined to select the desired RPM range from the phone, displaying results, resetting the array, etc.

Also, looking at the ADXL355 datasheet, the 0.63mS group delay and 3.63dB attenuation at the default 4,000Hz sample rate can probably be ignored for now.

Trying to keep the algorithm simple to begin with. Spectrum analyzer to come much later.
 
The solution I wanted to test was a redesigned aperture for the IR sensor. Here's a before and after cutaway view of the sensor mounted on my drill powered bench test. The ideal solution would be a lens to help focus this. But through careful design of the opening, I think we can get this close enough.
View attachment 115806View attachment 115807

And here is the resulting RPM graph. Still at 30% PWM and no washer spacers.
It looks to me that the aperture allows seeing things beyond (above) the perimeter of the spinner. Maybe it is seeing the prop blades or other things?
 
I had Claude write a driver for the ADXL355 interfaced to an ESP32 for the Arduino IDE and posted the Arduino library here. The example compiles, but it has not been tested with hardware. It might be of some use in speeding up development and testing for those who want to use an ESP32.
Works fine with hardware.

But for some odd reason this:
ADXL355Data d;
accel.readData(d);
causes ESP32 to reboot if inside an interrupt service routine.

Not a big deal. Will move it outside the ISR and run on core that's not running BLE once I figure out how,
 
I like the idea of using my Android phone for control/display via BLE rather than running wires to cockpit, but Dang! that's a long road with a steep learning curve! Also, even though I found a dual-core example, I still have to figure out how to share variables between core1 and core2 code, as well as limiting BLE code to just one core.

Looked at video on designing an Android phone BLE app using MIT App Inventor. A lot of work, using that graphic interface. (Actually both there and in the Arduino code.) Each characteristic (piece of data to be transmitted) requires a UUID be defined and much more. But why keep it somple to transmit data when we can make it complicated? ;)

I'm using Arduino IDE. If you would post or send me your code that may be helpful (or maybe just overwhelming).
I'm running a different processor and developing in a different IDE so I'm not sure how much help my low level code would be. At a higher level, here's a flowchart of how I'm dividing up things between the two cores.

Untitled 3.png
Core 0 configures the interrupts for the tach and accelerometer. Whenever one of these interrupts occurs, the ISR just records the time and sets a flag. The Core 0 main loop is continually checking the status of these flags and is responsible for retrieving the data from the accelerometer, performing basic calculations, and passing data off to Core 1.

Core 1 handles all the slower tasks that Core 0 can't. BLE, debug commands, SD card logging, spectrum analysis, and some of the more complex balancing calculations.

Works fine with hardware.

But for some odd reason this:
ADXL355Data d;
accel.readData(d);
causes ESP32 to reboot if inside an interrupt service routine.

Not a big deal. Will move it outside the ISR and run on core that's not running BLE once I figure out how,
I'm not familiar with the Arduino SPI libraries for an ESP32, but in general it's not a good idea to do SPI transfers within an interrupt. You could do like I am. Record the time and set a flag in the ISR, and then have a loop that polls it. Just remember you only have 250 uS before the next sample.

Although, I think the ESP32 on Arduino may be running FreeRTOS. That would give you much more powerful scheduling options. I'd look into using a semaphore from within your ISR for the ADXL355 read.
 
Works fine with hardware.

But for some odd reason this:
ADXL355Data d;
accel.readData(d);
causes ESP32 to reboot if inside an interrupt service routine.

Not a big deal. Will move it outside the ISR and run on core that's not running BLE once I figure out how,
Finn - i'm just getting back to working on mine. I'm using ESP-IDE but the ADXL355 is working great - My problem is phase smear, but I think we have id'd the pmag tach signal as the problem (1.4deg resolution) - I switched over to the square wave coming of my edis ignition and will test today. It looks promising.

Shoot me a PM if you want to look at my code - it may or may not help. It's been fun learning about this stuff!

Aircraft Prop Balancer – System Architecture


Code:
┌─────────────────────────────────────────────────────────────┐
│                    ESP32-S3 Dual Core                       │
├─────────────────────┬─────────────────────┬─────────────────┤
│     CORE 0          │     CORE 1          │   Peripherals   │
│ (WiFi + HTTP)       │ (Real-time DSP)     │                 │
├─────────────────────┼─────────────────────┼─────────────────┤
│                     │                     │                 │
│  • WiFi Access Point│                     │  ADXL355        │
│  • HTTP Server      │   Balancer Task     │   (SPI)         │
│  • JSON Endpoints   │   (High Priority)   │   4000 Hz DRDY  │
│  • Web UI Serving   │                     │                 │
│                     │                     │                 │
├─────────────────────┼─────────────────────┼─────────────────┤
│                     │   Tach ISRs         │  Tach Signals   │
│                     │   (Both Cores)      │                 │
│                     │  • P-Mag (GPIO4)    │  • P-Mag        │
│                     │  • EDIS (GPIO5)     │  • EDIS CTO     │
│                     │                     │                 │
├─────────────────────┼─────────────────────┼─────────────────┤
│                     │   Main Processing   │                 │
│                     │   Loop:             │                 │
│                     │  • SOAKING          │                 │
│                     │  • Stability Gate   │                 │
│                     │  • RUNNING (TSVA)   │                 │
│                     │  • DFT Accumulation │                 │
│                     │  • Notch Filter     │                 │
│                     │  • Window Complete  │                 │
│                     │                     │                 │
└─────────────────────┴─────────────────────┴─────────────────┘


Detailed Breakdown by Function​

ComponentCorePriorityWhat it does
WiFi + HTTP ServerCore 0NormalServes web UI, handles all /data, /cmd, downloads
Balancer TaskCore 1HighestReal-time DSP, DFT, phase calculation, windowing
DRDY ISRBothISR4000 Hz accelerometer samples → wakes Core 1
Tach ISRsBothISRP-Mag + EDIS edge detection & pulse width
ADC PollingCore 1HighSchmitt trigger & signal quality monitoring
NVS / LoggingCore 0/1BackgroundSave results, IC, settings


Data Flow Summary​


Code:
Sensors 
  ↓ (DRDY + Tach ISRs)
Core 1 → Balancer Task
  ↓ (every 250µs)
DFT Accumulation → Window Complete → Result
  ↓
Shared g_result (protected by mutex)
  ↓
Core 0 → HTTP Server → Phone/Tablet Web UI
 
I like the idea of using my Android phone for control/display via BLE rather than running wires to cockpit, but Dang! that's a long road with a steep learning curve! Also, even though I found a dual-core example, I still have to figure out how to share variables between core1 and core2 code, as well as limiting BLE code to just one core.
Maybe this would be easier: most ESP32's have WiFi, and can run a rudimentary webserver. Maybe do all the control and processing on the ESP, and access the settings and display the output on that webpage. Then connect using your phone over WiFi? Saves having to figure out the Bluetooth connection, and how to code an app for your phone.
 
Can someone confirm this for converting Gs to IPS:
V (ips) = 3690 * A (Gs) / RPM

So the only way in Arduino with DOIT ESP32DEVKITV1 to assign code (tasks) to core 0 and 1 is to use FreeRTOS?
Wondering what the overhead is in CPU cycles. (EDIT: No choice according to post #166: always runs under FreeRTOS.)
Reading ADXL355 data is only about 50uS and have 250uS to do it (and handle brief ADXL355 and Tach interrupt routines), so probably not an issue, but still curious.

Unfortunate that OP and Pete are using different development studios than my Arduino IDE.
Maybe I should switch over to the ESP32 IDE, but yet another new thing to learn.

Any recommendations on Android phone app development other than the MIT App Inventor I'm trying?
 
Last edited:
Can someone confirm this for converting Gs to IPS:
V (ips) = 3690 * A (Gs) / RPM
V (ips, peak) = [A (g) × 386.1] / [2π × RPM / 60]= A (g) × 386.1 × 60 / (2π × RPM)= A (g) × 3690 / RPM
  • This gives peak IPS — divide by √2 (≈ 1.414) to get RMS
  • It assumes sinusoidal vibration at a single frequency equal to shaft RPM (1× running speed)
  • "RPM" here is the frequency of the vibration, not just any shaft speed — if you're analyzing a 3× harmonic, use 3×RPM
 
Maybe this would be easier: most ESP32's have WiFi, and can run a rudimentary webserver. Maybe do all the control and processing on the ESP, and access the settings and display the output on that webpage. Then connect using your phone over WiFi? Saves having to figure out the Bluetooth connection, and how to code an app for your phone.
this is exactly what I am doing it works well
 
I got my teeth rattled flying this morning - but it was worth the data. I have the pmag and the EDIS both feeding tach info into the balancer. I can switch tach source via the UI on the ipad - the Pmag timing signal is good for revs, but stinks for precision - phase was all over the place. Switch to EDIS and the phase is rock solid - my UI shows the last 5 dots on the polar plot that fade with time - with edis they all end up nearly on top of each other. EDIS outputs a nice 512us square wave timing signal that was was to latch on to. Made a few code tweaks to the IPS calcs with the EDIS signal and will fly again tomorrow.

PMAG- notice the last hit will be where the red arrow is pointing......

pmag.png

EDIS - much more stable

EDIS.png
 
the Pmag timing signal is good for revs, but stinks for precision - phase was all over the place. Switch to EDIS and the phase is rock solid - my UI shows the last 5 dots on the polar plot that fade with time - with edis they all end up nearly on top of each other.
There is no requirement for either the EDIS nor the Pmag to report the tach pulse at any sort of angular precision. All that matters is the pulse time matches the current rpm. The EDIS could be reporting the RPM based on a timer and the pmag reporting it on a sensor on a tooth that could be anywhere in relation to TDC. Plus reclocking or variable timing in the pmag could easily change the phase of the rpm signal. And either or both could be smoothing and rounding or delaying the pulses.

There are a ton of variables out of your control if you use an external tach source. Human and technical.
 
There is no requirement for either the EDIS nor the Pmag to report the tach pulse at any sort of angular precision. All that matters is the pulse time matches the current rpm. The EDIS could be reporting the RPM based on a timer and the pmag reporting it on a sensor on a tooth that could be anywhere in relation to TDC. Plus reclocking or variable timing in the pmag could easily change the phase of the rpm signal. And either or both could be smoothing and rounding or delaying the pulses.

There are a ton of variables out of your control if you use an external tach source. Human and technical.
Oh, i'm fully aware - this is all purely for fun. i have discovered that the edis is far more accurate than the pmag. i have access to both signals and wanted to see what i could make work. I've learned a lot about both systems in the process.

Pmag timing is based off a spinning magnet. The Edis signalk I am tapping is based off the flyback of the ignition event. I account for ignition advance in my phase calcs. An optical sensor would be quick and easy, but what fun is that? It may end up that this does not work, but Ford's Electronic Distributorless Ignition System (EDIS) is renowned for providing highly precise timing, with accuracy often rated within 0.1deg to 0.2deg.

The plan will be to compare results with a commercial balance once I get all the bugs out. (so in about 5 years......) :p

I'm feeling better b/c in the EDIS pic above i have 15g on hole 3, so I think I 'm headed in the right direction. I have been very wrong before tho!
 
Maybe this would be easier: most ESP32's have WiFi, and can run a rudimentary webserver. Maybe do all the control and processing on the ESP, and access the settings and display the output on that webpage. Then connect using your phone over WiFi? Saves having to figure out the Bluetooth connection, and how to code an app for your phone.
I may have to learn how to set that up. Just about to give up.
I spent the whole day today trying to figure out why I can't get data from more than one characteristic from my ESP32 to my Android phone app. Still no joy. No error messages, but the subsequent two characteristics appear not to reach the app (using "BytesReceived"). Characteristics defined the same way and values set the same way. Sends RPM value OK to app (updates lblRMPValue text), but not Angle and IPS.

#define CHAR1_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8" //RPM
#define CHAR2_UUID "f1b230d2-acb9-4382-89d6-f2d00d9e5913" //Angle
...
pCharRPM = pService->createCharacteristic( //RPM
CHAR1_UUID,
BLECharacteristic::PROPERTY_NOTIFY
);
pCharAngle = pService->createCharacteristic( //Angle
CHAR2_UUID,
BLECharacteristic::PROPERTY_NOTIFY
);
...
pCharRPM->setValue(rpm);
pCharRPM->notify();
pCharAngle->setValue(iMax);
pCharAngle->notify();
...

I have triple checked the UUIDs and in any case should update lblIPSValue if no match.

A bug Arduino BLE or ...?
1778035347253.png
Have not figured out how to make Arduino BLE send integers, floats and strings -- appears can only send bytes?
 
Last edited:
I may have to learn how to set that up. Just about to give up.
I spent the whole day today trying to figure out why I can't get data from more than one characteristic from my ESP32 to my Android phone app. Still no joy. No error messages, but the subsequent two characteristics appear not to reach the app (using "BytesReceived"). Characteristics defined the same way and values set the same way. Sends RPM value OK to app (updates lblRMPValue text), but not Angle and IPS.

#define CHAR1_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8" //RPM
#define CHAR2_UUID "f1b230d2-acb9-4382-89d6-f2d00d9e5913" //Angle
...
pCharRPM = pService->createCharacteristic( //RPM
CHAR1_UUID,
BLECharacteristic::PROPERTY_NOTIFY
);
pCharAngle = pService->createCharacteristic( //Angle
CHAR2_UUID,
BLECharacteristic::PROPERTY_NOTIFY
);
...
pCharRPM->setValue(rpm);
pCharRPM->notify();
pCharAngle->setValue(iMax);
pCharAngle->notify();
...

I have triple checked the UUIDs and in any case should update lblIPSValue if no match.

A bug Arduino BLE or ...?
View attachment 116825

For initial BLE testing use a generic BLE app. nRF Connect and LightBlue are the two I use the most often. These are well tested apps and very helpful in determining if a problem is on the firmware or app side.

Some phones have problems caching BLE configuration. If you make changes to characteristics or UUIDs after you've previously connected to your phone, sometimes the phone won't update the configuration and won't be aware of your recent changes. In these cases cycling bluetooth off/on or restarting the phone will often fix the problem.

Have not figured out how to make Arduino BLE send integers, floats and strings -- appears can only send bytes?

I haven't ever used BLE with Arduino, but ultimately all data is bytes. Integers, floats and strings are all just a matter of how the bytes are interpreted.

Here's how I currently have my characteristics set up.

Screenshot 2026-05-05 at 11.33.00 PM.png
 
Last edited:
My bad. Should have looked at all the blocks in the BLE project I'd copied. Had only registered for the first characteristic.
Simplified by registering for a different data type for each characteristic.and works now!
1778068284858.png

Time to put it on the plane and test.
 
So tested it on airplane today Moving prop over trigger point (black/reflective) signal lloked good on the o'scope but apparently the ESP32 GPIO inputs have very low hysteresis. Since not expecting more than 2,600 RPM, this took care of that:
void IRAM_ATTR onTach() {
uint64_t now = esp_timer_get_time(); // microsecond resolution
uint64_t d = now - last_tach_time_us;
if (d > 23000) { // rotation period more than 23,000uS -- less than 2,600 RPM
period_us = d;
last_tach_time_us = now;
rpm = 60000000 / period_us;

}
}

During engine run, all samples ended up in the 0 degree slot or bin. Samples accumulate in one of 360 bins. Difference between
int index = MAX_SAMPLES * (delta / period_us);
and
int index = (MAX_SAMPLES * delta ) / period_us;
Where index is between 0 and 359.
int32_t delta = sample_time_us - last_tach_time_us;
int index = (MAX_SAMPLES * delta ) / period_us;
if ((index < MAX_SAMPLES) and (index > -1)) {
sample_buffer[index].NumberOfSamples =+ 1;
sample_buffer[index].x =+ d.x_g;
sample_buffer[index].y =+ d.y_g;
}


Now I'm wondering if I should accumulate raw accel data rather than floats (Gs) and convert to Gs and ips after determining max value?
20 bit integers could add up to huge numbers real quick at 4,000 samples per second.

Most recent code in attached zip file in case anyone are interested and wants to give suggestions.

Finn
 

Attachments

Guess I can provide a status update too. There have been some significant changes since my last post about my tach signal.

I did another test run yesterday. Since the last time, I have redesigned the housing for the LED emitter and receiver. The housing is now longer and the path for both the LED and receiver are angled horizontally. At first it was too selective and I was missing some tach pulses. However, after a few seconds with the Dremel it was working perfectly.
Screenshot 2026-05-06 at 9.44.22 PM.pngScreenshot 2026-05-06 at 10.25.52 PM.png

My previous tests also had a significant problem with clipping. I was using the ADXL345 (a +/- 16 g sensor), and in some of my testing nearly 5% of my samples were maxed out. To help address this problem I switched accelerometers to the ADXL355 (+/- 8 g), but more importantly changed how it was mounted.

With the improved Z axis performance of the 355 I decided I no longer needed the aluminum angle and could just mount the accelerometer directly on a flat aluminum bar. However the ADXL355 eval board I've got has several SMT components on the bottom side. So I needed to mill out some aluminum under the sensor. While I was at it, I cleared out enough room to run the wires under the PCB too. Although not shown in the below picture, some electronics safe RTV was added to the wires to prevent chafing the insulation and help isolate wire vibration.

Screenshot 2026-05-06 at 11.26.56 PM.pngScreenshot 2026-05-06 at 11.26.38 PM.png

Rather than completely redesign the enclosure, I decided to (temporarily) take the easy way and just give it some legs so it can be mounted above the accelerometer.

Screenshot 2026-05-06 at 11.37.55 PM.png

Another temporary change I made was provide room in the enclosure for mounting 2 AAA batteries and a power switch. One concern I had on my previous runs was if noise could be coming in over the power wire. So now I can quickly switch between external power and batteries to see if that makes any difference. By my calculations the 2 AAA batteries should provide just over 17 hours of runtime.

Now, from what I can tell, I'm finally getting good data. Examining the data from a full throttle run yielded the following: Over a duration of 17.48 seconds 35218 samples were taken. The measured sample rate was 2016.1 samples per second. Of the 35218 samples, 6 from the X axis were above 7.9 and suspected to have clipped. Zero from the Y and Z axis. Prop RPM varied from 2121.1 to 2155.4. There were 623 tach pulses. Zero suspected missed, and zero suspected extra tach signals.
 
When looking at the data from my 17 second full throttle run up, there's definitely a peak on the X axis at my prop RPMs. But it's quite small in comparison to the those from the engine and it's harmonics.

full throttle.png

Here's the same data again with the markers moved to match engine RPM.
full throttle engine rpms.png
 
Disappointed to find that I also hit the 2G accelerometer limit on a 1000 RPM ground engine runup.
Guess I need to try the 4G range, or maybe wait for a real smooth day to go flying to see what I get at cruise RPMs.
I mounted the accelerometer PCB vertically (PCB text readable in normal orientation) with Y vertically and X horizontally and figured I would get rid of the 1G gravity component by:
//get rid of gravity component (-1 G on Y-axis)
accel.setOffsetTrim(0,16000,0);

but that doesn't prevent hitting the 2G limit.

Probably means my algorithm won't work or need to figure out how to omit those high values.
I had counted on a lot of samples would average out noise and other vibration sources but that may not be true.
Instead of binning float samples I've changed to binning raw data, but still get high (9.93) ips values.
At at 4000 Hz sample rate and samples distributed in 360 bins, I guess it may take minutes of sampling to average down one high-G reading to make an actual imbalance of 0.003G show up as the highest value. Maybe I should try 72 5-degree bins?

Looking at the spectrum in post #181 above, it obviously requires quite a dynamic range to isolate the prop vibration, at least for us with gear boxes.

Finn
 
Last edited:
Got my first really repeatable results tonight. Low speed cruise config 2080 rpm. Tach signal from the EDIS ignition. I think I finally cracked the code on filtering out noise in the tach signal.

Here is the polar plot - this is with no weight on the flywheel.

Blue dots are the running 128rev captures that happen in the background and get logged, the 4 red stars each are three, 512rev captures vector averaged as part of the precision balance routine page(Influence Coefficient)

Vector Average Results:​

  • Overall Average (All points): 0.2232IPS @ 318.5
  • Precision 1-4 Average: 0.1801IPS @ 336.1

If the wx cooperates tomorrow, I'll put 15g at about 45 deg to see how the system reacts and calculate a solution


polar 5-8-26 EDIS.png
 
It's with shame I report that I was in fact not accumulating and thus not averaging. See if you can spot the errors:
// ============================================================================
void loop() { //executes on core 1 by default
if (bDataReady) {
bDataReady = false;
ADXL355Data d;
accel.readData(d);
if ((abs(d.x) < 128000) and (abs(d.y) < 128000)) {// max 1G
if ((period_us > 0) and (rpm > (TargetRPM - TargetRPMTolerance)) and (rpm < (TargetRPM + TargetRPMTolerance))){
//add values to one of 72 slots in rotation period
int32_t delta = sample_time_us - last_tach_time_us;
if ((delta > -1) and (delta < period_us)){
int index = (72 * delta) / period_us;
sample_buffer[index].NumberOfSamples =+1;
sample_buffer[index].x =+ d.x;
sample_buffer[index].y =+ d.y;
}
//location in buffer with highest value should be angle location
}
}
}
}

I fixed the three errors and cut slots or bins (sample buffer array) down from 360 to 72. In other words 5-degree wide bins. Also excluded samples larger than 1G. Did a new ground run and got much more sensible results. Probably will need to cut down the G limit further.

Edit: I should mention that I posted a silly question to the Arduino forum and someone there spotted the errors.
 
I thought that "=+" looked odd, in contrast to "+=". I think the compiler interprets that as "x = +y", not as "x = x + y". A little lysdexia, perhaps?
 
I thought that "=+" looked odd, in contrast to "+=". I think the compiler interprets that as "x = +y", not as "x = x + y". A little lysdexia, perhaps?
Well spotted! That what I got for trying to short-hand it. I used to repeat the variable and should have looked up the proper syntax before attempting shorthand.
Now I'm wondering if this would be valid syntax:
sample_buffer[index].NumberOfSamples++
instead of sample_buffer[index].NumberOfSamples +=1
which is sample_buffer[index].NumberOfSamples = sample_buffer[index].NumberOfSamples + 1
given that the variable is in a structure in an array.

I did the vast majority of my programming in assembly and VB6 and am new to C, so it takes a bit for me to get used to; the ++ just looks wrong! :)
 
Last edited:
It's with shame I report that I was in fact not accumulating and thus not averaging. See if you can spot the errors:
// ============================================================================
void loop() { //executes on core 1 by default
if (bDataReady) {
bDataReady = false;
ADXL355Data d;
accel.readData(d);
if ((abs(d.x) < 128000) and (abs(d.y) < 128000)) {// max 1G
if ((period_us > 0) and (rpm > (TargetRPM - TargetRPMTolerance)) and (rpm < (TargetRPM + TargetRPMTolerance))){
//add values to one of 72 slots in rotation period
int32_t delta = sample_time_us - last_tach_time_us;
if ((delta > -1) and (delta < period_us)){
int index = (72 * delta) / period_us;
sample_buffer[index].NumberOfSamples =+1;
sample_buffer[index].x =+ d.x;
sample_buffer[index].y =+ d.y;
}
//location in buffer with highest value should be angle location
}
}
}
}

I fixed the three errors and cut slots or bins (sample buffer array) down from 360 to 72. In other words 5-degree wide bins. Also excluded samples larger than 1G. Did a new ground run and got much more sensible results. Probably will need to cut down the G limit further.

Edit: I should mention that I posted a silly question to the Arduino forum and someone there spotted the errors.
No silly errors here - I've gotten more wrong than right, but that's how I learn. (Wife nods knowingly in the background......)

Glad you are making progress!
 
So tested it on airplane today Moving prop over trigger point (black/reflective) signal lloked good on the o'scope but apparently the ESP32 GPIO inputs have very low hysteresis. Since not expecting more than 2,600 RPM, this took care of that:
void IRAM_ATTR onTach() {
uint64_t now = esp_timer_get_time(); // microsecond resolution
uint64_t d = now - last_tach_time_us;
if (d > 23000) { // rotation period more than 23,000uS -- less than 2,600 RPM
period_us = d;
last_tach_time_us = now;
rpm = 60000000 / period_us;

}
}

During engine run, all samples ended up in the 0 degree slot or bin. Samples accumulate in one of 360 bins. Difference between
int index = MAX_SAMPLES * (delta / period_us);
and
int index = (MAX_SAMPLES * delta ) / period_us;
Where index is between 0 and 359.
int32_t delta = sample_time_us - last_tach_time_us;
int index = (MAX_SAMPLES * delta ) / period_us;
if ((index < MAX_SAMPLES) and (index > -1)) {
sample_buffer[index].NumberOfSamples =+ 1;
sample_buffer[index].x =+ d.x_g;
sample_buffer[index].y =+ d.y_g;
}


Now I'm wondering if I should accumulate raw accel data rather than floats (Gs) and convert to Gs and ips after determining max value?
20 bit integers could add up to huge numbers real quick at 4,000 samples per second.

Most recent code in attached zip file in case anyone are interested and wants to give suggestions.

Finn
If you haven't tried it yet, I highly suggest signing up for the free version of claude.ai. I attached your code and prompted it "Here's some code somebody posted to a forum. Can you examine it's intent, evaluate for bugs, and make suggestions on how to improve it?" Claude's response is below. I've hidden it inside a spoiler tag for those that wish to avoid "AI Slop".
I'll take a look at the code. Let me start by reading the skill for handling uploaded files, then examine the code.
Read the file-reading skill to know how to handle this upload

Read the file-reading skill to know how to handle this upload

An .ino file is Arduino source code — it's plain text. Let me check size and read it.
Ran a command, viewed a file

Ran a command, viewed a file

I have a clear picture now. Let me write up the analysis.


What this sketch is trying to do​

It's a propeller (or fan/rotor) balancer running on an ESP32 with an ADXL355 accelerometer and a tachometer pulse input. The intent:

  1. The prop spins. A tach pulse marks the start of each revolution (a once-per-rev signal — magnet + hall sensor or optical pickup).
  2. The ADXL355 streams acceleration samples at ~4 kHz via SPI, signaling each sample with DRDY.
  3. While RPM is within tolerance of a target, each sample is binned into one of 360 slots based on its angular position within the current revolution (delta / period × 360). The intent is to average x/y acceleration per angle slot over many revolutions, which integrates out random noise and leaves the once-per-rev imbalance signature.
  4. The slot with the highest vector magnitude √(x² + y²) is the angle of the heavy spot. Magnitude is converted from g to IPS (inches per second of vibration velocity) and pushed over BLE to a phone, along with RPM.
  5. Target RPM, tolerance, and a buffer-clear command are writable BLE characteristics.
The architecture is reasonable: ISRs do almost nothing, the loop() services accelerometer samples, and a separate FreeRTOS task pinned to core 0 handles BLE notifications every 500 ms.

Bugs​

These range from "will not work at all" to "subtle and intermittent."

1. =+ instead of += — this is the show-stopper. Lines 293–295:
=+ is not a compound-assignment operator — it parses as = followed by a unary +, so each line overwrites the slot with the latest value. NumberOfSamples becomes 1 every time, x and y become the most recent sample. No averaging happens; the whole binning scheme is defeated. This must be +=. This single typo is probably why the device doesn't appear to work the way the author expects.

2. MAXRPM is named backwards. The constant is 23000 µs and is used as a minimum period check (if (d &gt; MAXRPM)). 23,000 µs corresponds to ~2,600 RPM, so values above MAXRPM mean the rotor is going slower than ~2,600 RPM. The comment "2,600 max expected RPM" contradicts the variable name and contradicts the comment one line below it ("less than 2,600 RPM"). The check is actually doing debounce / glitch rejection — discarding tach edges that arrive impossibly close together. Rename to MIN_TACH_PERIOD_US or TACH_DEBOUNCE_US and fix the comments.

More importantly, this debounce caps the measurable RPM at ~2,600. If the user ever sets TargetRPM above 2,600, the tach ISR will reject every edge and period_us / rpm will never update. For a prop balancer that's probably fine; for general use it's a hidden limit.

3. Race conditions on multi-byte shared variables. period_us, last_tach_time_us, and sample_time_us are 64-bit and written in ISRs but read in non-ISR code without protection. ESP32 is 32-bit, so a 64-bit read is two loads and an interrupt can fire between them, giving a torn value. rpm is 32-bit so it's atomic on read, but period_us = d followed by rpm = 60000000 / period_us in the ISR means the loop can read rpm derived from the new period while still seeing the old last_tach_time_us (or vice versa). The delta = sample_time_us - last_tach_time_us calculation in loop() is the most exposed — if last_tach_time_us updates mid-calculation, delta can be wildly wrong. Also none of these are declared volatile except sample_time_us (and bDataReady isn't either, though it should be).

Fix: declare them all volatile, and read them inside a portENTER_CRITICAL/portEXIT_CRITICAL (or noInterrupts()/interrupts()) block, copying to local variables before use.

4. delta is signed 32-bit, period_us is unsigned 64-bit. Line 288:
This subtracts two uint64_t values and assigns to int32_t, truncating. As long as the actual delta fits in ~35 minutes (2³¹ µs) it works, but the intent is clearly "microseconds within one revolution," so use int64_t or compute the unsigned difference and check the bounds explicitly. Then on line 289 (delta &lt; period_us) mixes signed and unsigned — the compiler will promote delta to unsigned, so a negative delta (which the previous line delta &gt; -1 is trying to guard against) becomes a huge positive number and the guard fires backwards. The delta &gt; -1 test does almost nothing useful here.

5. ClearBuffer() from a BLE callback races with loop() and UpdatePhone(). It zeroes the buffer non-atomically while the data thread is +=-ing into it and the phone thread is reading it. Worst case is a transient garbage angle reading, which probably isn't catastrophic for a balancer, but worth knowing.

6. pCharRPM-&gt;setValue(rpm) passes uint32_t, but the comparison on the line above uses String(rpm). setValue(uint32_t) and String(rpm) produce different byte representations on the BLE side (raw 4 bytes vs. ASCII digits). The phone app sees binary; the comparison logic compares against ASCII. The "did this change" check will always think it changed, so notifications fire every 500 ms regardless. Same pattern for iMax and max. Either send strings everywhere (setValue(String(rpm).c_str())) or compare on the underlying numeric value.

7. Integer overflow in the angle-bin index. Line 290: (MAX_SAMPLES * delta) / period_us. MAX_SAMPLES is 360, delta can be ~23,000 at the slow end, product is ~8.3 million — fits in int32 easily. But if someone bumps MAX_SAMPLES or operates at lower RPM and the period is, say, 100,000 µs, 360 * 100000 = 36 million, still fine; at 1,000,000 µs (60 RPM) it's 360 million, still fine for int32 but getting close. Promote one operand to 64-bit to be safe: (int64_t)MAX_SAMPLES * delta / period_us.

8. uint TargetRPM — uint isn't standard C++. It works in the Arduino-ESP32 toolchain because &lt;sys/types.h&gt; is dragged in, but it's non-portable. Use uint32_t.

9. accel.selfTest() runs after startMeasurement() would normally be expected, but it's actually run before — and the result is ignored. It's just printed. If self-test fails, the sketch carries on regardless. Probably fine for hobby use; flag it and consider halting on failure.

10. while (true) {} on init failure starves the watchdog. On ESP32 the task watchdog will reset the chip. Use delay(1000) inside the loop or call esp_task_wdt_reset().

11. Sample struct has default initializers but is allocated as a global array — those initializers don't all reliably run for global aggregates in every toolchain version. Belt and suspenders: call ClearBuffer() once in setup().

12. The IPS conversion factor 3690 is a magic number. From the formula IPS = 3690 × g / RPM, this corresponds to g_to_ips = 386.4 × 60 / (2π) ≈ 3690, where 386.4 in/s² is 1g and the rest converts angular frequency. That's correct — but it assumes the measured g is peak vibration amplitude. After per-slot averaging, what's in the slot is roughly the once-per-rev component's value at that angle, which is the right quantity, so this is OK once the += bug is fixed.

Suggestions for improvement​

Beyond the bug fixes:

  • Use a proper accumulator. Right now you're storing a running sum of g and dividing by N at display time, which is fine but loses precision at large N. A two-pass running mean is more numerically stable, though for ≤4 kHz × a few seconds of data it doesn't matter.
  • Consider DC removal. Gravity contributes a constant ~1g on whichever axis is vertical; you don't want it in the imbalance estimate. The ADXL355 has a built-in HPF (the comment notes it's currently off). Turning it on (cutoff ~ODR/247) removes gravity and slow drift while leaving the once-per-rev signal intact. This is a real win for accuracy.
  • Reset the buffer when RPM leaves tolerance, not just on user command. Otherwise stale samples from a prior speed contaminate the average.
  • Phase reference matters. The current scheme labels slot 0 as "tach edge moment." That's only meaningful if the user knows the angular offset between the tach pickup and the prop's reference mark. Document this, or expose a calibration offset over BLE.
  • Send all six characteristics with consistent encoding. Either everything as float bytes (and document the layout) or everything as ASCII strings. Mixing them makes the phone app's life unnecessarily hard.
  • The String(...) != comparisons in UpdatePhone allocate on every iteration. Not a correctness issue, but ESP32 heap fragmentation from String is a classic. Cache the last sent values as numerics and compare those.
  • Consider a queue from the sample ISR/loop to the BLE task rather than scanning the whole 360-slot buffer every 500 ms; it's only ~5 KB so the scan is cheap, but a queue would let you decouple update rate from buffer size.
  • attachInterrupt(... RISING) on DRDY is correct for the ADXL355, but make sure the pin actually idles low. Some breakouts have DRDY active-low; check the datasheet for your board variant.
The single most important fix is =+ → += on lines 293–295. That alone is the difference between "the balancer works" and "the balancer reports the angle of whatever the last sample happened to be."
 
Truly amazing and some of it above my head. I wish you'd asked for my current code first.
Need to study up on volatile and how relates to race conditions.

I was just about to ask:
How do you guys get rid of the zero g error offsets? I'm already doing accel.setOffsetTrim(0,16000,0); to get rid of the gross Gravity component.
X and Y zero g offsets definitely vary with tail down and tail up (cruise attitude).
Also varies with temperature, according to datasheet.
I was going to just subtract measured average values but now Calude's suggestion above is to turn on the HPF. I didn't do that because I was not sure of the (group) delay it might introduce, which of course would shift the angle value, as well as introduce additional attenuation? I guess I need to look into how to turn that on and compensate for delay and attenuation.

But this is moving from quick and dirty to much more refined and robust.
 

Attachments

Truly amazing and some of it above my head. I wish you'd asked for my current code first.
Need to study up on volatile and how relates to race conditions.

I was just about to ask:
How do you guys get rid of the zero g error offsets? I'm already doing accel.setOffsetTrim(0,16000,0); to get rid of the gross Gravity component.
X and Y zero g offsets definitely vary with tail down and tail up (cruise attitude).
Also varies with temperature, according to datasheet.
I was going to just subtract measured average values but now Calude's suggestion above is to turn on the HPF. I didn't do that because I was not sure of the (group) delay it might introduce, which of course would shift the angle value, as well as introduce additional attenuation? I guess I need to look into how to turn that on and compensate for delay and attenuation.

But this is moving from quick and dirty to much more refined and robust.
I'll agree the HPF might not be the best solution. Claude is amazing, but it's best to always be skeptical of it's responses.

I'm using a couple of different techniques to handle the dc gravity offsets depending upon the type of analysis that's being performed. One is similar to method one listed below. With the other, DC gravity is implicitly rejected because I'm multiplying by sin(θ) and cos(θ) and summing over a complete revolution.

I fed your latest code into Claude and asked "Is there some way to naturally remove the offsets, instead of manually setting the bias x and bias y?".
Yes — several ways, ranging from "drop-in trick" to "elegant signal-processing approach." Here they are in order of how naturally they handle the problem:


1. Subtract the per-run mean from the binned data (cleanest fit for this code)​


The key insight: for a balanced rotor, the average of the once-per-rev signal over a full revolution is exactly zero. Any non-zero average across all 360° is the DC offset (gravity + sensor bias + slow drift), by definition.

So instead of subtracting a separately-measured bias, just compute it from the data you already have. In UpdatePhone(), before searching for the maximum:

C++:
// First pass: compute bin averages and the grand mean (= DC offset)
int64_t sum_x = 0, sum_y = 0;
int filled_bins = 0;
float bin_x[MAX_SAMPLES], bin_y[MAX_SAMPLES];

for (int i = 0; i < MAX_SAMPLES; i++) {
  uint32_t n = sample_buffer[i].NumberOfSamples;
  if (n > 0) {
    bin_x[i] = (float)sample_buffer[i].x / n;
    bin_y[i] = (float)sample_buffer[i].y / n;
    sum_x += bin_x[i];
    sum_y += bin_y[i];
    filled_bins++;
  }
}

if (filled_bins > 0) {
  float dc_x = sum_x / filled_bins;
  float dc_y = sum_y / filled_bins;
 
  // Second pass: subtract DC, find peak
  float max = 0;
  int iMax = -1;
  for (int i = 0; i < MAX_SAMPLES; i++) {
    if (sample_buffer[i].NumberOfSamples > 0) {
      float x = bin_x[i] - dc_x;
      float y = bin_y[i] - dc_y;
      float r = sqrt(x*x + y*y) / 256000.0;
      if (r > max) { max = r; iMax = i; }
    }
  }
  // ... rest of BLE notify code
}

Why this is great:
  • No calibration step, no user action, no stored constants.
  • Handles gravity in any attitude automatically — tail-up, tail-down, level, all the same.
  • Handles temperature drift automatically.
  • Handles sensor part-to-part variation automatically.
  • Zero phase shift.
  • Self-correcting: as the prop is rebalanced and the once-per-rev signal shrinks, the DC estimate gets more accurate (less signal contaminating the average).
The only requirement is that the bins be reasonably evenly populated around the circle. With 72 bins and a few seconds of data at 4 kHz ODR (~tens of thousands of samples spread across 72 bins), they will be.

You can throw away setOffsetTrim(), the hardcoded - 5028 / + 1587, and any calibration BLE characteristic. The code gets simpler, not more complex.

2. Subtract the mean of each axis from raw samples in​


Same idea, but maintain a running mean continuously and subtract it on the fly. A simple exponential moving average:

C++:
static float mean_x = 0, mean_y = 0;
const float alpha = 0.0001f;  // time constant ~ 1/(alpha*ODR) ≈ 2.5 sec at 4kHz

mean_x += alpha * (d.x - mean_x);
mean_y += alpha * (d.y - mean_y);

int32_t cx = d.x - (int32_t)mean_x;
int32_t cy = d.y - (int32_t)mean_y;

sample_buffer[index].x += cx;
sample_buffer[index].y += cy;

This is mathematically equivalent to a single-pole IIR high-pass filter implemented in software — i.e., it's basically Option B from my last message (the on-chip HPF), but in software where you control the corner exactly.

The trade-off vs. Option 1: this responds smoothly to attitude changes during a run, but it's a true filter, so it has the same phase-shift caveat as the on-chip HPF. With alpha = 0.0001 at 4 kHz ODR, the corner is roughly 0.06 Hz, so phase shift at 21.7 Hz (1300 RPM) is about 0.16°. Negligible for 5° bins.

The advantage over the on-chip HPF: you can tune alpha from code without touching registers, and it doesn't depend on ODR.

3. Use FFT-style harmonic extraction (most elegant, more code)​


This is what commercial balancers actually do. The insight: imbalance is by definition the first harmonic of rotation. Decompose the per-bin signal into its Fourier components and only keep the 1× component:

C++:
// For each axis, compute the 1-per-rev sine and cosine components
float ax_sin = 0, ax_cos = 0;
float ay_sin = 0, ay_cos = 0;

for (int i = 0; i < MAX_SAMPLES; i++) {
  uint32_t n = sample_buffer[i].NumberOfSamples;
  if (n > 0) {
    float theta = 2.0f * PI * i / MAX_SAMPLES;
    float x = (float)sample_buffer[i].x / n;
    float y = (float)sample_buffer[i].y / n;
    ax_sin += x * sin(theta);
    ax_cos += x * cos(theta);
    ay_sin += y * sin(theta);
    ay_cos += y * cos(theta);
  }
}

// Magnitude and phase of the 1× component on each axis
float ax_mag = sqrt(ax_sin*ax_sin + ax_cos*ax_cos) * 2.0f / MAX_SAMPLES;
float ax_phase = atan2(ax_sin, ax_cos);  // radians, 0 = phase reference at tach
// ... same for y

Why this is the gold-standard approach:

  • The DC term doesn't appear at all in the result. Gravity, sensor offset, temperature drift, you-name-it — all live at DC, and DC has zero contribution to the 1× component. You get rejection by infinity dB, not by some HPF corner.
  • It also rejects 2×, 3×, 4×, … harmonics, which is what you want. A bent shaft causes 2×; aerodynamic blade-passage causes N× (where N = blade count). Imbalance is only 1×. Separating them is exactly what a Fourier projection does.
  • The output is a single complex number per axis: magnitude tells you how much imbalance, phase tells you where on the rotation it points. This is more precise than "which bin had the max," because it uses all 72 bins to fit a sinusoid rather than picking the noisiest peak.
  • Works at any RPM, any attitude, any temperature, with no calibration.
  • Phase shift is exactly zero (it's a synchronous detector, not a filter).

The cost is a tiny bit more arithmetic. With 72 bins, that's 72 sin/cos evaluations per axis per update — easy to precompute into a lookup table since they're always the same angles. On an ESP32 it's negligible.


There's a minor subtlety: the angle of the peak vibration in your current code corresponds to the angle of the heavy spot plus 90° (because vibration peaks when the heavy spot is at the side, not the top). The FFT-style approach naturally gives you this in the phase term, and you can offset it once and be done. Same offset is implicit in your current code, you've probably already calibrated it out by trial.


My recommendation​


Start with #1. It's a four-line change to existing code, eliminates three sources of brittleness (offset trim, hardcoded constants, attitude-dependent recalibration), and you can test it tonight. If the residual looks clean, you're done.


If you later want to push for higher precision and reject mechanical/aerodynamic harmonics, graduate to #3. That's the principled long-term answer and it's not much more code — maybe 30 lines including a precomputed sin/cos table. The fact that it inherently rejects DC means you can throw away setOffsetTrim and never think about gravity vector or temperature drift again.


#2 is a reasonable middle option if you ever need to handle attitude changes during a run, but for ground-based prop balancing where you spool up, measure, shut down, adjust — #1 fits the workflow more naturally.


One thing all three share: they only work correctly if the rotor goes through full revolutions during the measurement window. Your current code already enforces this implicitly via the tach-period check, so you're good.
 
I'll agree the HPF might not be the best solution. Claude is amazing, but it's best to always be skeptical of it's responses.

I'm using a couple of different techniques to handle the dc gravity offsets depending upon the type of analysis that's being performed. One is similar to method one listed below. With the other, DC gravity is implicitly rejected because I'm multiplying by sin(θ) and cos(θ) and summing over a complete revolution.

I fed your latest code into Claude and asked "Is there some way to naturally remove the offsets, instead of manually setting the bias x and bias y?".
.
.
There's a minor subtlety: the angle of the peak vibration in your current code corresponds to the angle of the heavy spot plus 90° (because vibration peaks when the heavy spot is at the side, not the top). The FFT-style approach naturally gives you this in the phase term, and you can offset it once and be done. [emphasis added]
DIYers, don't miss this excerpt from Claude's response.[/spoiler]
 
I'll agree the HPF might not be the best solution. Claude is amazing, but it's best to always be skeptical of it's responses.

I'm using a couple of different techniques to handle the dc gravity offsets depending upon the type of analysis that's being performed. One is similar to method one listed below. With the other, DC gravity is implicitly rejected because I'm multiplying by sin(θ) and cos(θ) and summing over a complete revolution.

I fed your latest code into Claude and asked "Is there some way to naturally remove the offsets, instead of manually setting the bias x and bias y?".
Interesting how Claude keeps wanting to change into floats. I specifically changed my sample array to int64_t to accumulate raw data and keep precision (sensitivity?). We're getting 20 (19) significant bits from the ADXL355 and I don't think my Arduino IDE has that many significant bits in the float type.

Actually Solution 3 (FFT, Bin 1) is what I tried some years ago and could not get good phase (angle) results. Gave up and just got magnitude, four runs: unbalanced, test weight at 0, +120 and -120 degrees and put result into spreadsheet that calculated position and weight. Of course that was with the much, much slower Arduino Mega 2590 board.

Definitely worth a try with the 1000x more sensitive ADXL355 and much higher sample rate.

After extensive conversation with Claude I decided to add volatile and forego mutex.

...
Sorry. This is all making the code so complex. Rather have the occasional data race (quite rare given the procedure priorities and execution times).
5:16 PM




Claude responded: Completely reasonable.​

Completely reasonable. Given:

  • ISRs are highest priority
  • loop() at priority 2 runs every 250µs
  • UpdatePhone() at priority 1 only runs every 500ms and holds the buffer for microseconds
The actual probability of a collision is tiny. So just drop the mutex entirely and accept the occasional torn read in UpdatePhone(). The averaging over many samples makes one bad frame irrelevant anyway.
I'll look into solution 1 first, but want to test this code before that.
 

Attachments

Last edited:
I am about to balance a RV-12iS and using a borrowed ACES 1015 Sport. Reading the instructions, pretty cool. Tells you what to do. Where to put weight.
If you can not put weight exactly at clock or weight specified, you tell it what you can do. It will suggest an alternative second weight.

The kit I am using comes with Lyc and TCM brackets. Being RV-12iSt making my own brackets from heavy alum extrusion angles (scrap from my other RV-projects).

The side open treaded holes in gear box for accelerometer. On back of gear box two threaded through holes above the Pitot tube block, for the optical tach pickup. Manuals says needs to be 12" from target. I should be able to out the target on spinner backing plate.

I intend to leave upper cowl off. The spinner will be on. However once final weight is determined I will have to remove spinner to drill the spinner backing plate, install bolt, nut washers. I will for experiment re-run with final weights on spinner off. If good put spinner back on do final run.

The Temp Weights will be washers in the screws that hold spinner on the backing plate. When I get final Clock and Weight for balance mass (using wsahers), I will have to change to radius and increase weight by radius at on spinner backing plate where final weight is located, divided by radius of washers on spinner screw location. The ACES does not do this I think but it might, simple math no worries, math science physics.

RV-12iS has SB for radiator cracks. My friends RV-12iS is good, not cracks, no cowl interference, good shock absorbing play of mount. However prop balance is a must to prevent radiator cracking. The pay back in time and expense to Dynamic Prop Bal, well worth it, lower wear & tear, fatigue cracks, rubbing.
 
Last edited:
Well, it kept raining, so I added Claude's Solution 1 (modified to use int64_t until squaring for vector magnitude) to the attached code.
I mentioned upgrading to FFT to avoid RPM dependency and this is what Claude had to say:

FFT would be a natural upgrade. A few thoughts:


  • DC removal is trivial with FFT — bin 0 is the DC component, you just ignore it, no two-pass calculation needed.
  • RPM ranging becomes automatic since the fundamental frequency shows up as the peak bin, and bin index × (sample_rate / N) gives you frequency directly — no need for TargetRPM at all.
  • Arduino FFT libraries like ArduinoFFT work well on ESP32 and are straightforward to integrate.
  • The main tradeoff is that your current approach is synchronous with rotation (slot = angle), which gives you the heavy spot angle directly. With FFT you get frequency/amplitude but lose phase/angle unless you also track a once-per-revolution trigger, which you may already have for the RPM measurement.

So if angle output matters for balancing, you'd still need the tachometer reference to recover phase from the FFT — which adds complexity. Your current slot-based approach is actually quite elegant for that use case.

Of course if this works I'll upgrade to 180 or 360 slots for better angle accuracy although I suspect the 5 degree resolution works in practical use.

I am very, very impressed with Claude.ai catching things I would never have thought of as well as just plain errors. If nothing else it's a way to quickly learn programming. If it suggests something you don't know, just ask. Example: What is volatile? (in programing context)
 

Attachments

Last edited:
I don't get it. Why 90 degrees? Why isn't the angle always relative to the tach pickup location?
This is a classic and genuinely counterintuitive result from rotordynamics. The answer lies in the physics of a damped rotating system.

The Short Answer​

In a rotating system running above its natural frequency, the rotor doesn't respond in phase with the forcing function. The mass, spring (bearing stiffness), and damping forces interact such that at operating speed the peak displacement lags the imbalance force by 90°. This is the resonance condition of a second-order system.

The Physics​

An imbalanced rotor is a rotating force vector — it's essentially a sinusoidal forcing function applied to a spring-mass-damper system (the rotor on its bearings).
The equation of motion is:
mẍ + cẋ + kx = m·e·ω² sin(ωt)
Where:
  • m = rotor mass
  • c = damping coefficient
  • k = bearing/support stiffness
  • e = eccentricity (imbalance offset)
  • ω = rotational speed
The steady-state response of this system has a phase angle φ between the forcing input and the displacement output:
φ = arctan(2ζr / 1−r²)
Where:
  • ζ = damping ratio
  • r = ω/ωn (speed ratio — running speed divided by natural frequency)

What This Means at Different Speeds​

ConditionPhase LagBehavior
Well below critical speed (r << 1)~0°Rotor deflects toward heavy spot
At critical speed (r = 1)90°Peak vibration, heavy spot leads high spot by 90°
Well above critical speed (r >> 1)~180°Rotor deflects away from heavy spot (self-centering)
The 90° offset is specifically the resonance condition — it occurs at or near the critical speed (first natural frequency). Most industrial machines are balanced and operated above their first critical, which is why the 90° relationship is so commonly referenced in balancing practice.

The Intuitive Explanation​

Think of pushing a child on a swing:
  • If you push in phase with the motion (below resonance), you add energy efficiently
  • At resonance, maximum energy transfer happens when you push 90° out of phase with displacement — i.e., at the moment of maximum velocity, not maximum displacement
The rotor behaves the same way. At the critical speed, damping forces dominate the response, and damping force is proportional to velocity, which is always 90° ahead of displacement in sinusoidal motion. The system is essentially "responding to" velocity rather than position at that speed.

Why This Matters for Balancing​

This 90° relationship is the foundation of single-plane balancing:
  1. A phase reference (keyphasor or strobe) tells you where the high spot is
  2. You know the heavy spot is 90° ahead of the high spot (in the direction of rotation) at critical speed
  3. Correction weight is placed opposite the heavy spot
In practice, the exact offset varies with how close you are to the critical speed and the system's damping ratio, which is why influence coefficient balancing (trial weight runs) is used for precision work rather than assuming exactly 90°.

**At resonance (critical speed), the peak vibration amplitude lags the point of maximum imbalance (heavy spot) by 90 degrees.** This is a fundamental behavior in rotor dynamics for unbalanced rotating machines.

### Why This Happens: Simple Explanation via the Jeffcott Rotor Model
Consider a simple rotor (disk on a flexible shaft) with a small mass eccentricity *e* (the imbalance distance from the geometric center to the center of mass). The rotating imbalance produces a centrifugal force *m ω² e* (where *m* is mass, *ω* is rotational speed).

The rotor's response is like a forced, damped harmonic oscillator. The vibration displacement *x* relates to the driving force with a **phase lag φ** that depends on the speed ratio *ω / ω_n* (where *ω_n* is the natural frequency, i.e., critical speed).

- **Below resonance** (*ω << ω_n*): Phase lag ≈ 0°. The "high spot" (maximum outward deflection) aligns roughly with the heavy spot. The rotor whirls with the heavy spot outward.
- **At resonance** (*ω ≈ ω_n*): Phase lag = **90°**. Peak vibration (maximum amplitude) occurs 90° after the heavy spot passes a reference point. Damping controls the amplitude peak here, but the phase is exactly 90° for the idealized case (independent of damping amount, though real systems vary slightly).
- **Above resonance** (*ω >> ω_n*): Phase lag ≈ 180°. The rotor rotates about its center of mass (self-centering), so the heavy spot is inward, opposite the high spot.

This phase shift occurs because inertia, stiffness, and damping interact differently across speed ranges. At resonance, the system is inertia- and stiffness-dominated in a way that quadrature (90°) lag maximizes energy transfer and amplitude.

### Practical Implications in Machines
- **Vibration sensors** (e.g., proximity probes with a keyphasor/tachometer reference) show this lag. At critical speed, the heavy spot leads the maximum deflection by 90° (in the direction of rotation). To balance at or near resonance, place correction weight accordingly (often ~90° from the observed high spot, adjusted for direction).
- **Polar plots/Bode plots** from run-up or coast-down clearly show the amplitude peak and ~90° phase shift at critical speed.
- Damping influences amplitude (lower damping = higher peak) but the 90° phase at exact resonance holds in the model. Real rotors with asymmetric bearings or multiple modes may show slight deviations (e.g., 120° in some examples).

### Note on Horizontal vs. Vertical Phase
A separate but related observation: Pure imbalance often shows ~90° phase difference between horizontal and vertical vibration readings at the *same* bearing. This reflects the circular/elliptical whirling orbit, not the heavy spot offset.

This 90° lag at resonance is key for diagnostics, balancing procedures (especially near critical speeds), and avoiding excessive vibration in turbomachinery, motors, pumps, etc. For deeper math, the phase φ = arctan[(2ζ r) / (1 - r²)], where *r = ω/ω_n* and *ζ* is the damping ratio—exactly 90° when *r* = 1.

It’s a classic head-scratcher in vibration analysis, often referred to as the Phase Lag. To understand why this happens, we have to look at the relationship between the physical cause (the Heavy Spot) and the measured effect (the High Spot).
In a perfect world, if you pushed a mass, it would move instantly. But in a rotating system, the shaft and bearings act like a spring-mass-damper system, and physics likes to take its time.



1. The Heavy Spot vs. The High Spot

Before diving into the "why," let's define the two players:

  • The Heavy Spot: The actual point of physical mass imbalance (where the extra weight is).
  • The High Spot: The point of maximum radial displacement (where the vibration sensor "sees" the shaft coming closest to it).

2. The Role of Resonance

The 90-degree shift occurs specifically when the machine is running at its critical speed (resonance). The phase relationship changes based on how fast the machine is spinning relative to its natural frequency:

[TR]
[td]Speed Range[/td][td]Phase Lag[/td][td]Behavior[/td]
[/TR]
[TR]
[td]Below Resonance[/td][td]~0°[/td][td]The High Spot and Heavy Spot are essentially at the same location. The system is "stiffness-controlled."[/td]
[/TR]
[TR]
[td]At Resonance[/td][td]~90°[/td][td]The High Spot lags the Heavy Spot by 90°. The system is "damping-controlled."[/td]
[/TR]
[TR]
[td]Above Resonance[/td][td]~180°[/td][td]The High Spot is opposite the Heavy Spot. The system is "mass-controlled" (the rotor is spinning around its center of gravity).[/td]
[/TR]



3. Why 90 Degrees? (The Physics)

When a system is at resonance, the frequency of the centrifugal force (the imbalance) matches the natural frequency of the rotor.
In a forced harmonic oscillator, the displacement $x(t)$ is governed by the equation:
$$m\ddot{x} + c\dot{x} + kx = F_0 \cos(\omega t)$$
The phase lag $\phi$ between the force (Heavy Spot) and the displacement (High Spot) is calculated as:
$$\tan(\phi) = \frac{c\omega}{k - m\omega^2}$$
At resonance, the driving frequency $\omega$ is equal to the natural frequency $\sqrt{k/m}$. This causes the denominator $(k - m\omega^2)$ to become zero. When the denominator of a tangent function is zero, the angle $\phi$ is 90°.



4. The Intuitive Version

Think of a child on a swing.

  • If you push them very slowly (below resonance), they move exactly when you push.
  • If you push at the exact right timing to get them higher (at resonance), you are actually applying the most force when they are at the bottom of the arc (the midpoint), but their maximum height (displacement) happens a quarter-cycle later.
In a rotating machine, at resonance, the "springiness" of the shaft and the "inertia" of the mass cancel each other out. This leaves only the damping to resist the motion. Since damping force is proportional to velocity—and velocity leads displacement by 90 degrees—the maximum displacement is forced to wait until the velocity cycle catches up.

Applying this theory to an engine-propeller system is where "vibration analysis" becomes "propeller balancing." In aviation, understanding that 90-degree (or 180-degree) shift is the difference between a smooth flight and an engine that wants to shake itself off the mounts.

Here is how that physics applies specifically to your airplane:

1. The Operational Regime​

Most aircraft engines are mounted on rubber vibration isolators (Lord mounts). These mounts are designed so that the natural frequency of the engine-propeller system is very low—usually well below the engine's idle RPM.

Because the operating RPM (e.g., 2400 RPM) is much higher than the natural frequency of the mounts, the system is almost always operating "Above Resonance."

  • Theory: Above resonance, the phase lag shifts toward 180 degrees.
  • Reality: In this state, the rotor (propeller) is trying to rotate around its actual Center of Mass rather than its geometric center. The "High Spot" (vibration peak) will be roughly opposite the "Heavy Spot" (the imbalance).

2. Why you might still see 90 degrees​

If you are performing a dynamic balance and find a 90-degree offset, it usually indicates one of two things:

  1. Resonance Catching: You are measuring the vibration at an RPM that happens to excite a structural resonance in the airframe or the engine mount itself.
  2. System Damping: High levels of friction or damping in the engine mounts can "smear" the phase shift, keeping it closer to 90 degrees for a wider range of RPMs.

3. Practical Balancing (The "Clock" Method)​

When a mechanic uses a dynamic balancer (like a Chadwick-Helmuth or DynaVibe), the equipment uses a photocell to track a piece of reflective tape on a propeller blade. This establishes a "Top Dead Center" for the rotation.

  • The Sensor: An accelerometer on the engine case "feels" the High Spot.
  • The Calculation: The balancer measures the time delay between the reflective tape passing the sensor and the accelerometer feeling the "hit."
  • The Correction: If the computer knows the system is operating above resonance (180° lag), and it senses the vibration at the 12 o'clock position, it knows the Heavy Spot is actually at 6 o'clock. To fix it, it will tell the mechanic to add a weight at 12 o'clock.

4. The "Propeller Whip" Factor​

Unlike a rigid industrial rotor, an airplane propeller is a long, flexible beam. At high speeds, the blades can undergo gyroscopic precession and aerodynamic asymmetric loading (P-factor).

If the propeller is slightly out of track (one blade is physically forward of the other), it creates a "coupling imbalance." This can make the vibration phase act erratically, sometimes mimicking a 90-degree shift because the force isn't just radial (outward)—it’s also axial (fore and aft).

Since you're using a handheld balancer (like a DynaVibe, ACES, or MicroVib), you’re moving from theory into the "black box" of balancing algorithms. These devices do the vector math for you, but understanding that 90° to 180° shift is key to troubleshooting when the solution doesn't seem to work.

Here is the "ground truth" for using those balancers on an aircraft:


1. The Sensor Orientation "Cheat"​

The 90-degree offset you are thinking about is often "baked into" the balancer's logic, but it depends entirely on where you mounted your accelerometer.

  • If your accelerometer is mounted at 12 o'clock (on the crankcase spine) and your photocell is also at 12 o'clock, the balancer expects a certain phase lag.
  • If you move the accelerometer to 3 o'clock but leave the photocell at 12, you’ve just introduced a physical 90-degree shift that the computer doesn't know about unless you tell it.
Pro Tip: Always stick to the standard mounting positions suggested in the manual (usually as close to the front main bearing as possible) to ensure the internal "lag library" of the balancer matches reality.


2. The Trial Weight (The "Calibration" Run)​

Most handheld balancers use a Trial Weight method because they don't know the exact "stiffness" of your specific engine mounts.

  1. Run 1 (Baseline): It measures the vibration (e.g., 0.80 IPS at 120°).
  2. Run 2 (Trial Weight): You add a known weight (e.g., 10 grams) at a known hole.
  3. The Calculation: The balancer looks at how that 10g moved the vibration vector. It calculates the Influence Coefficient—essentially figuring out exactly how many degrees the "High Spot" lags the "Heavy Spot" on your specific airplane.

3. Why it might feel like "90 Degrees" in the Hangar​

If you are balancing at a low RPM (close to the natural frequency of the engine mounts), you are in that "Resonance Zone" where the lag is exactly 90 degrees.

As you increase RPM to a cruise setting, that lag shifts toward 180 degrees. This is why a prop balanced perfectly at 1800 RPM might still feel a bit "buzzier" at 2400 RPM.

The Rule of Thumb: Always perform your final balance runs at the RPM where the pilot complains the most (usually normal cruise).

4. Troubleshooting Weird Readings​

If the balancer is telling you to put weight in a spot, you do it, and the vibration gets worse or moves in an unexpected direction, check these three things:

  • The Tape Gap: Is your reflective tape clean? If the photocell misses a "hit," the phase angle will jump 180 degrees instantly.
  • The "Clock" Direction: Ensure you are counting holes/degrees in the direction of rotation. If the prop turns Clockwise (from the cockpit), "90 degrees leading" is different than if you're standing in front of the plane looking at it.
  • Mount Health: If your rubber engine mounts are shot (sagging or bottomed out), the "spring rate" of the system is gone. The physics shifts from "mass-controlled" to "stiffness-controlled," and the phase lag will be totally unpredictable.
 
Last edited:
Did a ground run this morning.
Algorithm won't work to this degree:
Even though I limit samples used to less than 0.25 Gs you still get this phenomena: a "high" G sample (wind gust, combustion event, prop interaction with ground or whatever) can cause a slot to have a max value compared to other slots (even if event might be added to other slots too) and it takes a while to average that down:
1778420364639.png
(Y: ips, X: angle degrees) @1,050 prop RPM

This is even with the "DC" (offset errors) component dynamically removed.

Note that I did clear the sample buffer a few times during the 20 min run. Need to improve the phone app to also log target RPM and buffer resets.

In this case it appeared that it settled down to 0.075 ips at 310 degrees. So I actually need to look for the angle of the lowest ips value it averages down to, it seems.

I guess I should try to add a test weight next.

Also been thinking of adding an ignition or injector interrupt input to verify my tach signal -- should be stable at 2.85 x optical tach pickup.

EDIT: Added 21g (126 inch-g) @204 degrees from trigger point. Got almost same result: 0.075 ips @340 degrees. Wonder what I'm actually measuring?
 

Attachments

Last edited:
Too much math for me.
So how do we know what the critical speed and natural frequency of our engine/prop/gearbox systems are?
 
Well - I have a solution. Not sure if it works - that will require another flight, but here is what I have.

Yesterday I got baseline data with no weights installed, today I added 23.4g at hole 4 and flew again. Theses are the resultant solutions:


1778526614463.png

1778527762185.png

1778526667394.png

I'll add these weights and see what I get. The ACES solution 2 years ago was 19.7 g at hole 6@2270 rpm. So I'm in the right universe.

I let Gemini do the math here - and cross-checked it with Claude -they argued a bit, but they finally agreed. This is a low speed- cruise balance. Later, I'll try one at my high speed 2270 rpm cruise as well.
 
Back
Top