A story about programming without thinking first
- This problem looks simple.
- Oops, this gains complexity fast!
- Everything collapses beautifully into a generic solution.
Bonus points if the generic solution is obvious to you from the beginning!
Introduction#
I’ve got this nice remote control to control some machinery.
It features a potentiometer dial with a center position at the top
(where the output should be 0
)
which can be turned either to the left (output should be -255
)
or to the right (255
).
In my case, it’s important to read 0
even if the dial is slightly off-center. So we need
some kind of tolerance here. Also, I found that the potentiometer does not use the full
ADC reading range.
Please forgive me for these bad drawings. They don’t add much value, but I liked drawing them.
What you want:
- a linear range of output values from -100 % to +100 %
- a clearly defined center position at 0 % with some tolerance
What you have:
- not the full reading range (e.g.
12...986
instead of0...1023
on a 10 bit ADC) - a center value that may flicker and change with temperature.
Naive solution#
One tip you find [1] quite [2] often [3] is to define a dead zone in which you set your values to zero.
Let’s assume the dial covers an ADC reading range of 12..986
we have:
def _map(x, in_min, in_max, out_min, out_max):
# we don't want to override the std `map` function
return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
def centered_dial_reading_v1(val, range_start, range_end, tolerance = 10):
center = (range_end - range_start) / 2
half_tol = tolerance / 2
if center - half_tol <= val <= center + half_tol:
return 0
return _map(val, range_start, range_end, -255, 255)
Arduino version
int centered_dial_reading_v1(int val, int range_start, int range_end, int tolerance = 10) {
int center = (range_end - range_start) / 2;
int half_tol = tolerance / 2;
if (center - half_tol <= val && val <= center + half_tol) {
return 0;
}
return map(val, range_start, range_end, -255, 255); // Arduino map function
}
int raw = analogRead(12); // whatever pin your dial is connected to
int result = centered_dial_reading_v1(raw, 12, 986);
This approach works, but has problems. Increasing the tolerance and plotting the values for the theoretical ADC range looks like this:
As you can see, we hit a hard bump as soon as we move the dial outside the center position. This is only usable for very tight tolerance values. Also note that the bump on the left is bigger than the one on the right! This is a result of the pot’s range.
But there’s another bug hidden in here.
Your potentiometer range might change due to temperature or aging.
In this case, we might read values > 255
- which shouldn’t be possible - so we need
to constrain the output value here.
Simple solution#
So we take the tolerance offset into account, constrain the output and get something like this:
def constrain(x, lower, upper):
if x < lower:
return lower
if x > upper:
return upper
return x
def centered_dial_reading_v2(val, range_start, range_end, tolerance = 100):
center = (range_end - range_start) / 2
half_tol = tolerance / 2
if val <= center - half_tol:
result = _map(val, center - half_tol, range_start, 0, -255)
elif val >= center + half_tol:
result = _map(val, center + half_tol, range_end, 0, 255)
else:
return 0
return constrain(result, -255, 255)
This looks better! And we can see that we got rid of the bug, as the function correctly caps the output values at the start and at the end.
Movable zone positions#
But wait. What if we want to move the center position? Why not make this a function parameter?
The center position now needs another name (because it’s not the center anymore, d’oh!), so we just call it “zone” now.
Let’s add a zone_pos
parameter and try it out (zone_pos=350
):
def zoned_dial_reading(val, range_start, range_end, zone_pos, tolerance = 100):
half_tol = tolerance / 2
if val <= zone_pos - half_tol:
result = _map(val, zone_pos - half_tol, range_start, 0, -255)
elif val >= zone_pos + half_tol:
result = _map(val, zone_pos + half_tol, range_end, 0, 255)
else:
return 0
return constrain(result, -255, 255)
Hey, this is fun! Maybe this looks a bit strange because the different gradients…
It becomes complex#
But what if this dial is used for something like a speed setting, where it only goes
from -100
in reverse to +255
forward? This would help evening out the gradients in
such cases.
Also, it would be nice if we could invert this and go from 500
down to -500
.
And dead zones at the start or end of the range would also be nice. So we need to be able to parameterize the zone value.
def dial_read_complex(x, range_start, range_end, out_start, out_end, zone_pos, zone_val, tolerance = 10):
def mapval(v):
return _map(v, range_start, range_end, out_start, out_end)
i_lower, i_upper = min(range_start, range_end), max(range_start, range_end)
o_lower, o_upper = mapval(i_lower), mapval(i_upper)
if x < i_lower:
return o_lower
elif x > i_upper:
return o_upper
elif x < zone_pos - tolerance:
return _map(x, i_lower, zone_pos - tolerance, o_lower, zone_val)
elif x > zone_pos + tolerance:
return _map(x, zone_pos + tolerance, i_upper, zone_val, o_upper)
else:
return zone_val
dial_read_complex(v, range_start=12, range_end=968, out_start=-255, out_end=255, zone_pos=400, zone_val=0, tolerance=100)
dial_read_complex(v, range_start=12, range_end=968, out_start=255, out_end=-255, zone_pos=100, zone_val=150, tolerance=20)
Yes! party.gif
(Please don’t ask how long I debugged this.)
The breaking point#
So wouldn’t this be a nice little Arduino library?
I guess some people would like to add multiple zones, each with different tolerances and output values. I know I’d love this!
Can’t be so difficult, can it? Just need to pass in something like a list of zones? Given they are sorted correctly, we then interpolate between them.
It would look like this:
But wait. Isn’t this just…
The solution#
An interpolation table. Yep, it is.
Let’s delete everything and start again.
# the table must be sorted by X coordinate!
TABLE = [
(12, -255), # constrain the end
(450, 0), # zone from
(550, 0), # zone to
(968, 255), # constrain the end
]
def interpolate(x, table):
first_x, first_y = table[0]
last_x, last_y = table[-1]
if x <= first_x:
return first_y
elif x >= last_x:
return last_y
for i in range(0, len(table) - 1):
tx, ty = table[i]
nx, ny = table[i + 1]
if tx <= x < nx:
return _map(x, tx, nx, ty, ny)
return 0 # should never happen
The simplifies everything.
Multiple zones and different end values - now all the stuff we dreamed up becomes a simple table definition:
TABLE = [
(12, 0), # constrain the start
(300, 100), # zone 1
(400, 100), # zone 1
(500, 200), # zone 2
(550, 200), # zone 2
(800, 100), # zone 3
(900, 100), # zone 3
(968, 50), # constrain the end
]
I guess 20 hours of programming really can save an hour of thinking.
Thanks for reading!