CarbOnBal

Summary

This document exists to document changes, observations, thoughts, and some experiments related to the excellent CarbOnBal (ref: https://hackaday.io/project/27569-carbonbal) Do-It-Yourself electronic carb balancer (synchronizer).

I hope you find it interesting.

NOTE: Due to the facts that:

  1. The original source code is licensed under the GNU GPL v3
  2. I'm a big fan of Open Source
  3. I reproduce very small, selected portions of the source code

This work is also licensed under the GNU GPL v3. I - Jon Nelson - jnelson@randomcharacters.net - maintain full copyright on this work.

Background

I am a long-time motorcycle enthusiast and one of the regular maintenance items for motorcycles is a so-called "carb sync". The problem stems from the fact that most motorcycles are multi-cylinder and most multi-cylinder motorcycles utilize one carbuerator (or fuel injector/ throttle body) per cylinder. An internal combustion engine is basically a big air pump; with each rotation of the engine air is moved in and out of the combustion chamber(s), and the role that these devices play is to very precisely mix fuel in a specific ratio with the air that is flowing through them.

These devices are imperfect, however, and - all other things being equal - cannot be guaranteed to flow exactly the same. These differences can impact drivability, performance, and efficiency in a negative way. While computerized fuel injection can attempt to compensate for this to some extent, ultimately an adjustment process is often still worthwhile.

This adjustment process is referred to variously as "synchronizing", "balancing", and other names. Roughly what happens is that minute adjustments are made such that each device associated with each cylinder pulls the same vacuum. The theory of operation is that if all of the devices are pulling the same vacuum then they are all restricting the flow of air exactly the same and therefore (all other things being in a similiar state of tune) will be producing the same air/fuel mixture.

Typically, one of these device is non-adustable and all others are adjusted relative to the reference device. The way this is usually done is by measuring the vacuum (negative pressure) at each device while the bike is running and making appropriate adjustments to ensure that they all end up reading the same.

In the long ago times, this was done with Mercury Sticks. Yes, Mercury. Nobody does that any more because mercury is scary and toxic. Over the years various fluids and meters and other things have been developed, such as the excellent Morgan Carbtune. In fact, I own a Morgan Carbtune and it is an excellent tool.

However, I wanted to explore using electronics and - especially as a huge fan of Open Source software - I wanted to build it myself. After much research, I ran across CarbOnBal. This is the first Arduino project that I have ever attempted.

Overview

I ended up making many changes to the source code. Some were aesthetic, while others added (experimental) support for other Arduino devices (such as the Arduino Nano Every). If you are interested in the good stuff, I recommend skipping this next section; it's pretty boring.

Change Summary

Boring changes

I made a bunch of boring changes like reformatting the source code and fixing spelling errors, changing types (I strongly prefer sized types such as uint16_t over unsigned int), liberal use of enumerations, modern binary syntax (0b1011 vs B1011), and so on.

Interesting changes

When I first started the project, I accidentally fried the Arduino Nano I had. I replaced it with the mostly-compatible Arduino Nano Every because I didn't have a spare Nano and I had a spare Nano Every. The big differences to pay attention to are:

  1. The Nano Every doesn't do PWM on as many pins (this matters for the LCD contrast and brightness)
  2. The Nano Every has 256B of EEPROM vs. 1024B on the Nano (this is a really big deal for calibration)

To overcome the first issue, I refactored the code to support a a mostly-complete software-based PWM and in the process completely altered how interrupts were used (also making the code a bit more modern and easier to port to other platforms). I also took the opportunity to start abstracting away the storage process.

I did end up using a potentiometer to partly control brightness while contrast remains controlled via software. This works most of the time but the port effort is incomplete.

I still have not overcome the second issue (although I have some ideas). I have since replaced the Arduino Nano Every with an Arduino Nano, which avoids these issues entirely.

The single largest challenge I encountered was a bit of an 'aha' moment for me in learning about embedded microcontrollers. I hadn't thought about the difference in EEPROM sizes when (temporarily) substituting my Arduino Nano Every (256B EEPROM) for the specified Arduino Nano (1024B EEPROM). The code "worked" but calibrations were all weird. Maybe I've been programming in C++, Java, Rust, Python, etc. for too long but I was expecting some sort of error when writing or reading to areas in the EEPROM that were "past" the storage area, but that's just not so.

The code assumed a specific EEPROM size and hard-coded it.

Before we dive into what it's doing with EEPROM and why this is complex, let's talk about calibration and why it's useful.

Calibration

What is calibration? Fundamentally, calibration works like this:

  1. Assume (4) pressure sensors. Per the project, using General Motors pressure sensors works great.
    NOTE: DO NOT use cheap clone sensors. I tried this to save some money and they were VERY BAD.
  2. One sensor is the reference sensor. The code assumes this is no. 4, but all of my bikes want it to be cylinder no. 1.
  3. One or more readings are taken from the reference sensor followed by reading(s) from the sensor we're calibrating. Those readings are compared.

    For example, sensor 1 might return a value of 350 (out of 1024) and sensor 2 might return 349. What we care about is storing a value of 1 that is associated with sensor 2 at position 349.

  4. Store/manage the calibrations.
  5. Go-To step 3 and repeat until the user says not to.
  6. Persist all calibrations.

During the process, the expectation is you (the human) will apply a slow, consistent negative pressure (vacuum) to the reference and calibration sensors. I use a small, hand-held "brake bleeder and vacuum" with a plastic "tee" and two silicone vacuum lines. One silicone line is always attached to cyl. number 1 and the other is moved from cylinder to cylinder.

I build vacuum very slowly and never "release" the vacuum until the calibration is complete. This prevents wild swings in the values which can cause noise in the calibrations.

What you will see below is some saved data that I extracted from a serial console and turned into a CSV file for ease of use.

In [1]:
import pandas
%matplotlib inline
import matplotlib.pyplot as plt

plt.style.use('fivethirtyeight') # bmh is also nice; https://vegibit.com/matplotlib-in-jupyter-notebook/

# read the data
df = pandas.read_csv("results-raw.csv")
df.head()
Out[1]:
idx sensor1 sensor2 sensor3
0 4 1.00 1.000000 NaN
1 5 1.00 0.000000 0.792683
2 6 NaN 0.928571 0.718447
3 7 NaN 0.000000 0.000000
4 8 0.95 NaN 0.750000
In [2]:
# what's the min and max?
# FIXME: have to exclude NaN
min(df.sensor1), min(df.sensor2), min(df.sensor3)
Out[2]:
(-1.0, -9.0, nan)
In [3]:
max(df.sensor1), max(df.sensor2), max(df.sensor3)
Out[3]:
(2.0, 1.0, nan)

Let's break down the data. Each row contains 4 columns. The columns are "idx" (the sensor value returned by a given sensor) and three columns each representing the data we have for the "offsets" at that index to arrive at the value we see for the reference sensor.

For example, if we see:

300,3,0,2

What that means is that for each sensor (1,2,3) that saw a value of 300, then sensor zero saw a simultaneous value of 303, 300, and 302.

This way, later, when we read values from sensors, we simply take that value, look it up in the table, and add the value that we see there before comparing it to sensor 0.

The values represent values as returned by the Arduino's ADC and could theoretically range from 0 to 1023. Smaller values mean less pressure. In Death Valley, USA the values would be higher than at Pike's Peak in Colorado. Here at about 1,000 feet above sea level, I saw values no larger than about 350. Note that these values are "raw" values from the sensor and must be converted into units. The code already supports several units but we won't care about those here.

I collected the data by printing to the serial console each collection. Technically, the reference sensor is the average of a before reading and an after reading, with the other sensor read in the middle, but otherwise these values pretty raw.

To convert to millibar, we multiply by millibarFactor and add P0VSENSOR (a sensor's minimum pressure). millibarFactor is $(`P5VSENSOR` - `P0VSENSOR`) / 1024$ P5VSENSOR is 1020 (noted as the maximum pressure reported by the sensor at 5V in millibar) P0VSENSOR is 150 (noted as the minimum pressure reported by the sensor at 0V in millibar) Therefore: millibarFactor is $(1020 - 150) / 1024 ~= 0.85$

Each value in the range is therefore equal to approximately 0.85 millibar, a very small amount.

If you calibrate the device at one altitude and use it at another, very different altitudeyou might need to recalibrate.

Next up is how the code currently works. It's clever!

Clever use of shift + exponential moving average to smooth "very small" values

The code is quite clever. The values stored in the EEPROM are of type int8_t, giving a maximum adjustment of +/- 127. However, when calibrating, these are loaded into memory and shifted left 5 bits. This is to buy ourselves 5 extra bits of "precision". The comments in the code are reproduced below (note that 'EMA' refers to 'exponential moving average'; more on that soon):

//read existing values from EEPROM and pre-shift them
        //shifting an int left by n bits simply gives us n bits of 'virtual' decimal places
        // this is needed for accuracy because EMA calculation works by adding or subtracting relatively small values
        // which would otherwise all be truncated to '0'
        for (int i = 0; i < numberOfCalibrationValues; i++) {
                values[i] = (int8_t) EEPROM.read(
                                getCalibrationTableOffsetByPosition(sensor, i));
                values[i] <<= shift;
        }

So we read the adjustments in. The value 1 becomes 32, 2 becomes 64, etc... The data type for values is int (and I later changed this to int16_t to be explicit). A value of 127 turns into 508. Signs are retained.

The next part of the code reads from the reference and selected sensors and produces a differential value, and also maintains some min/max/sanity check stats. The next part is interesting to me:

values[(readingSensor >> 2)] = intExponentialMovingAverage(shift,
                                factor, values[(readingSensor >> 2)], calibrationValue);

readingSensor is the NON reference sensor. shift is 5. factor is 4.

Shifting the values we read to the right 2 is the same as dividing by 4 (but might be faster). So we have effectively cut our range from 0-1023 in 4 which means we now only have to store $3*256B = 768B$ of data.

Then we take the current value, shift (5), factor (4), and the new adjustment (calibrationValue) and pass this into intExponentialMovingAverage.

That code looks like this:

// calculate Extremely Fast Integer Exponentially weighted moving average for smoothing.
// factor is how much weight is given to new values vs the stored average as a power of 2.
//    ie: 0 = 1:1 and 4 = 1/16th
// shift is used to get n bits of accuracy 'below zero' as it were 0 means no smoothing, more is exponentially (1/2^n) more smoothing
// average is a value in which to store the moving average; 
//    NOTE that this value is stored shifted 'shift' bits to the left and must be unshifted before use
//    NOTE2 the shift WILL truncate if you overdo it, best used on 8-bit Bytes etc.
int intExponentialMovingAverage(int shift, int factor, int average, int input) {
        average += ((input << shift) - average) >> factor;
        return (average);
}

I theorize that the reason this is used is because when calibrating we're seeing rapid-fire, very small adjustments for the same index values. In other words, we'll likely see a large number of adjustments (10's or hundreds) around the same areas. If sensor0 is reading 350 and sensor1 is reading 351 we'll see many, many adjustments around ($351 >> 2 = 87$). We'll take the difference ($350-351 = -1$), shift it left by 5 ($-1 << 5 = -32$), subtract the current average (let's start with 0), and then shift right by factor (4): $(-32 - 0) >> 4 = -2$. This process places more "weight" on recent data, so over time repeated measurements "converge" towards a more accurate approximation. This is in contrast to a simple arithmatic rolling average which treats old and new values equally.

When we're all done we shift all of the values back to the right by shift (5) which basically loses their accuracy. In essence, what's happening here is the calibration values are turned into a very fast approximation of floating points and then back into integers.

#

Why not floating point?

Why not just use floating points then? Floating points have their own issues, mostly speed. However, I will reach out to the author for some feedback on this.

Let's look at some scatter plots:

In [4]:
# sensor 1 vs sensor 0 difference scatter plot
plt.scatter(df.idx, df.sensor1, label='Sensor 1')
Out[4]:
<matplotlib.collections.PathCollection at 0x7fc4529a7208>

Kinda noisy, but not awful.

In [5]:
# sensor 2 vs sensor 0 difference scatter plot
plt.scatter(df.idx, df.sensor2, label='Sensor 2')
Out[5]:
<matplotlib.collections.PathCollection at 0x7fc4529405f8>

Other than one or two few outliers (which are likely to be false readings), not too bad.

In [6]:
# sensor 3 vs sensor 0 difference scatter plot
plt.scatter(df.idx, df.sensor3, label='Sensor 3')
Out[6]:
<matplotlib.collections.PathCollection at 0x7fc4528688d0>

Pretty similar to sensor 1, but overall very good. Now remember, these are ADC readings and are on a scale of 0 through 1023, so a variation of 1 is 0.1%.

Processed

Now let's take a look at the "processed" data. I (attempted) to replicate (in Python) what the existing code does, which roughly boils down to:

  1. For any given sensor value, we shift right by 2 (value >> 2). This effectively cuts the total range (0-1023) down to 0-255 (enough for a uint8_t). In other words, 4 neighboring values are sort of smooshed together, reducing the precision of the readings. In practice we'll see a range of under 100.
  2. I implemented a functionally identical exponential moving average in Python:
def my_exponential_moving_avg(values):
    avg = 0
    for v in values:
        avg += ((v << 5) - avg) >> 4
    return avg >> 5
In [7]:
# read the data
df = pandas.read_csv("results-processed.csv")
df.head()
Out[7]:
idx sensor1 sensor2 sensor3
0 1 0.0 0 0.0
1 2 0.0 -1 0.0
2 3 0.0 0 0.0
3 4 0.0 0 0.0
4 5 0.0 0 0.0
In [8]:
# what's the min and max?
min(df.sensor1), min(df.sensor2), min(df.sensor3)
Out[8]:
(-1.0, -2, -1.0)
In [9]:
max(df.sensor1), max(df.sensor2), max(df.sensor3)
Out[9]:
(1.0, 0, 0.0)

That's a pretty big difference from the unprocessed data.

In [10]:
# sensor 1 vs sensor 0 difference scatter plot
plt.scatter(df.idx, df.sensor1, label='Sensor 1')
Out[10]:
<matplotlib.collections.PathCollection at 0x7fc452560d30>
In [11]:
# sensor 2 vs sensor 0 difference scatter plot
plt.scatter(df.idx, df.sensor2, label='Sensor 2')
Out[11]:
<matplotlib.collections.PathCollection at 0x7fc44a168ef0>
In [12]:
# sensor 3 vs sensor 0 difference scatter plot
plt.scatter(df.idx, df.sensor3, label='Sensor 3')
Out[12]:
<matplotlib.collections.PathCollection at 0x7fc44a1c1e48>

Interpretation

For sensor 1, there are only 3 adjustments, both of magnitude 1. One adjustment is at the extreme far end of things and unlikely to be of practical value.

For sensor 2, most of the adjustments are -1.

For sensor 3, except for one outlier, all of the adjustments have an offset of zero (0).

My interpretation is that the the process of shifting the sensor readings to the right (dividing by 4) is reducing the utility of those adjustments. Combined with the exponential moving average, there seems to be overall very little value in these calibrations for these sensors. Especially since a difference of 1 works out to under a millibar (0.8something millibar).

It's probably worth experimenting with different approaches by generating different datasets with varying degrees of jitter (simulating better or worse sensors).

  1. TODO: experiment with intRunningAverage, longExponentialMovingAverage, floatExponentialMovingAverage..