Starting Over

Summary

Previously, I discussed how I had wanted to explore voltage dividers (aka potential dividers) and some of the challenges and solutions I encountered.

Philosophically, I'm a fan of the "explore, and cover as much ground as possible" followed by the "and now go more slowly and deliberately" approach. I've already covered the "go fast" part, now it's time to "start over" a bit.

Plan

Here is a rough plan.

  1. I feel as though I can improve the calibation of my multimeter, so I did so using an LM4040 voltage reference.
  2. Clean up the wiring and leverage the fact that the ADC supports multiple inputs.
  3. Clean up the code!
  4. Make a schematic!

Inventory

Here is an inventory of what I used:

Circuit Changes

As part of "starting over", I cleaned up a bunch of things. The circuit is cleaner. The code is much cleaner. Roughly, an overview of those changes include (in no particular order):

Circuit changes

  1. I used multiple ADC inputs so that I could simultaneously (and without moving wires) measure the voltage:
    1. before the voltage divider ('raw')
    2. after the voltage divider
    3. rail voltage (to understand what the DAC sees for voltage) NOTE: This turned out to be unnecessary after I thought about it, althought the schematic still shows it.
  2. I removed the capacitor from the original circuit

Code changes

  1. I implemented an autoGain function which tries to determine the best gain from a sample voltage. It's primitive, but it works.

    Addendum: the implementation appears to cause some instability in the results; see more on that in a bit.

  2. Support multiple ADC inputs

  3. Altered the code to understand that ADC values may be positive or negative, and to store and use separately the ADC "ranges" (0-1023 for the 10-bit ADC, -2048 to +2048 for the 12-bit ADC).
  4. Used even powers-of-two as divisors (except for the DAC).
  5. Speaking of the DAC, as part of this refactoring I started out with a method calibrateDACvRef which leveraged the third ADC input to VSS so that the code knows what the maximum Voltage the DAC could put out, primarily because it would change as VSS fluctuated. While the ADC has it's own voltage reference, it's useful to produce "expected" values, but at least now I don't have to fight as much with voltage differences or fluctuations!
  6. Not only were there bugs in my implementation of RMS, but I wasn't thinking through the problem correctly which could not have been helping things. I no longer use RMS. More on that in 'Type Conversion Issues'.
  7. Lots of code abstraction and general cleanup.

Type Conversion Issues

One of the problems that I am having has to do with the fact that it's been some years since I spent much time with some of the gnarlier parts of C/C++. Specifically, I had forgotten about some of the rules for integer and float type conversions, and I especially forgot that uint_fast16_t might be 32 bits (or even 64!), which it is on this Wemos D1 Mini.

The trouble started when I added a simple arithmatic mean function:

int_fast16_t readADCWithAverage(uint8_t channel, uint_fast16_t sample_count) {
    int_fast16_t sum = 0;
    for(uint16_t i = 0; i < sample_count; ++i) {
        sum += adc1015->readADC_SingleEnded(channel);
    }
    return sum / sample_count;
}

Do you see the problem(s)?

Let's break this down (all sizes are relevant only for this platform):

  1. Each of the 'fast' variants is 32 bits (verified). What that means in practice is that those items will have a greater (Integer Conversion Rank)[https://en.cppreference.com/w/cpp/language/usual_arithmetic_conversions#Integer_conversion_rank], which effectively promotes all statements using them to use 32 bit integers. That's not so bad.
  2. The type of the sum variable and return type of readADC_SingleEnded are different: sum will be a signed, 32-bit integer (due to the 'fast' variant) and readADC_SingleEnded returns an int16_t (which is guaranteed to be 16 bits). Since they have the same sign, the return value is promoted. We are OK so far.
  3. Eventually, we get to the division. This is where things go wrong. Arithmatic with mixed-sign integers (even of the same size) have rules, and the rules basically say that since the "conversion rank" is the same that the signed value (they have the same width) it is converted to an unsigned value (by adding UINT32_MAX + 1 and then subtracting the absolute value of the unsigned value). Let's say that sum is -99 (which might happen if we took 3 samples each returning a value of -33). $2^32 + 1 - |99| = 4294967198$. Then divide by 3. The result is not -33 but 1431655732.
  4. We then take this result and attempt to convert it back into a signed value.

    Fun fact: The C/C++ standards supply required behaviors for conversion of signed to unsigned but not for unsigned to signed.

    In practice, if the signed variable "can" represent the value without loss, that is what happens. Can a int_fast16_t safely hold 1431655732?

    The width of the int_fast16_t is 31 bits + 1 for sign, so the question is: is $1431655732$ greater than $2^31$ ? No, it is not. Therefore, the value held by the int_fast16_t will have the value 1431655732. However, the value remains positive. The sign is lost. Booo!

So what we can we do about it?

Let's try static_cast<int_fast16_t>(the unsigned value) first so that the arithmatic has two signed, identical-width values to work with. Applying the same rule for "will the signed value safely hold the unsigned value" (yes), we then get two signed values of the same width (and therefore the same rank) and the division that takes place is $-99 / 3 = -33$ which is what we'd expect. Yay!

The finished version:

int_fast16_t readADCWithAverage(uint8_t channel, uint_fast16_t sample_count) {
    int_fast16_t sum = 0;
    for(uint16_t i = 0; i < sample_count; ++i) {
        sum += adc1015->readADC_SingleEnded(channel);
    }
    return sum / static_cast<int_fast16_t>(sample_count);
}

Schematic!

(CAUTION: my first ever schematic. Also, I don't really know what I'm doing.)

schematic.png

The Results

More or less immediately the results are much better. The following results are with gain set to TWO_THIRDS and without the voltage divider.

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

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


class RollingAvg:
    def __init__(self, size):
        self.avg = 0
        self.size = size
        self.values = collections.deque()
    
    def addValue(self, v):
        self.values.append(v)
        if len(self.values) > self.size:
            self.values.popleft()
        self.avg = sum(self.values) / len(self.values)
        return self.avg

class ExponentialMovingAvg:
    def __init__(self, alpha):
        self.avg = None
        self.alpha = float(alpha)
    
    def addValue(self, v):
        if self.avg is None:
            self.avg = v
        else:
            self.avg += (v - self.avg) / self.alpha

        return self.avg
    
def addSmoothing(df, sourceColumn, destColumn, func):
    df[destColumn] = df[sourceColumn].apply(func)
In [ ]:
# read the data
df = pandas.read_csv("results-noauto-29-Jan-2024.csv")
addSmoothing(df, "measuredVoltageDifference", "smoothedMeasuredVoltageDifference", ExponentialMovingAvg(16).addValue)
plt.plot(df.DACposition, df.measuredVoltageDifference, linewidth=1.0)
plt.plot(df.DACposition, df.smoothedMeasuredVoltageDifference, linewidth=1.0)
In [ ]:
# Also take a quick look at the data
df.tail()

Overall, it looks pretty good, but there is some weirdness.

  1. There appears to be a very slight positive slope.
  2. What's that drop-off at the end?

There is still some icky code smell, primarily in how we calculate an expected ADC value. What do the results look like with gain set to TWO_THIRDS for the whole run?

In [ ]:
df = pandas.read_csv("results-auto-29-Jan-2024.csv")
addSmoothing(df, "measuredVoltageDifference", "smoothedMeasuredVoltageDifference", ExponentialMovingAvg(16).addValue)
plt.plot(df.DACposition, df.measuredVoltageDifference, linewidth=1.0)
plt.plot(df.DACposition, df.smoothedMeasuredVoltageDifference, linewidth=1.0)

The overall "shape" is roughly the same, but the auto-gain has clearly less variation.

Voltage Divider

Let's see what things look like using the voltage divider. At some point, it might be interesting to modify the client and server to ask the code to take two measurements - one from the 'raw' ADC and the other from the voltage divider side - and give us the results.

That might be interesting! But first, let's look at what we get:

In [ ]:
df = pandas.read_csv("results-auto-with-vd-29-Jan-2024.csv")
addSmoothing(df, "measuredVoltageDifference", "smoothedMeasuredVoltageDifference", ExponentialMovingAvg(16).addValue)
plt.plot(df.DACposition, df.measuredVoltageDifference, linewidth=1.0)
plt.plot(df.DACposition, df.smoothedMeasuredVoltageDifference, linewidth=1.0)

That's really not that bad. It more or less looks like it has the same shape as well. Very interesting!

Comparing Raw vs With Voltage Divider

Let's compare "raw" vs "through the voltage divider" results.

In [ ]:
df = pandas.read_csv("results-auto-compare-29-Jan-2023.csv")
plt.plot(df.DACPosition, df.Raw, df.DACPosition, df.WithDivider, linewidth=1.0)

That looks pretty fantastic, but due to the scale, any weirdness is lost. Let's zoom in on just differences ($withDivider - raw$):

In [ ]:
# It's easier to see if I plot the difference
addSmoothing(df, "Difference", "smoothedDifference", ExponentialMovingAvg(16).addValue)
plt.plot(df.DACPosition, df.Difference, linewidth=1.0)
plt.plot(df.DACPosition, df.smoothedDifference, linewidth=1.0)

With the voltage divider, I see an offset (roughly -13mV) and a negative slope, but that's hardly awful. The offset isn't far off of what the 'raw' results are, either, so if we discount that I think I can live with a 10mV "slope" over 4096 steps.

The Impact of Sampling Speed

I've been using RATE_ADS1015_128SPS to control how fast (or slow) the ADS1015 is. What happens if I speed things up? Switching to RATE_ADS1015_3300SPS...

In [ ]:
df = pandas.read_csv("results-auto-compare-faster-29-Jan-2023.csv")
addSmoothing(df, "Difference", "smoothedDifference", ExponentialMovingAvg(16).addValue)
plt.plot(df.DACPosition, df.Difference, linewidth=1.0)
plt.plot(df.DACPosition, df.smoothedDifference, linewidth=1.0)

While there is a little bit of additional noise, that's interesting.