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.
Here is a rough plan.
Here is an inventory of what I used:
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):
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.
Support multiple ADC inputs
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!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):
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.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.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);
}
(CAUTION: my first ever schematic. Also, I don't really know what I'm doing.)
More or less immediately the results are much better.
The following results are with gain set to TWO_THIRDS and without the voltage divider.
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)
# 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)
# Also take a quick look at the data
df.tail()
Overall, it looks pretty good, but there is some weirdness.
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?
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.
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:
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!
Let's compare "raw" vs "through the voltage divider" results.
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$):
# 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.
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...
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.