Using a Raspberry Pi to remotely measure cistern water level.
As part of my ongoing country living projects, I recently added a cistern storage tank as a reserve for my underground well. Unfortunately on the dry months this precaution wasn't enough, and occasionally I would have to truck in water to supplement the cistern. Not surprisingly, so did many other folks in our county, and I quickly discovered that I have to reserve quite ahead of time to get on the water delivery schedule.
The cistern is underground and the only reliable way to get a water level reading was to periodically drop a dipstick into the tank. After a few close calls it became quickly evident that I really needed to remotely read the level of the tank. By automating the tracking and water level and usage I could do a better job of estimating ahead of when I needed to get water delivered.
Coincidently I was working on a backup power system for my well pump and pressure tank. I really wanted to avoid the hassle of starting a generator at the pump house every time the power went out.
I chose to build my system using a Raspberry Pi to monitor the battery state of charge. This provided me an infrastructure I could leverage to track the water level too. The only difficult design issue was, how to reliably read the water level?
Measuring the Water Level
Although the tank already has a float valve to sense when the well pump should turn on and off. It wasn't really much use to me. The float only gives me a binary indication that triggers when the tank is below 9/10 of capacity and when it is full. This isn't helpful when the well runs dry and I running on the tank reserve. I need a analog level gauge that the Raspberry Pi can read.
AT first I considered using something like a HC-SR04 ultrasonic transducer to calculate the distance from the water to the top of the tank. The HC-SR04 is used often by Arduino robotics projects and can even be found in things like garage door sensors. And while they are fairly inexpensive, I found out rather quickly that distance data provided would need quite a bit of filtering.
Ultrasonic transducers are sensitive to temperature changes and require that you integrate a temperature sensor to calculate the change in the speed of sound.
I would also have to work around the disturbance of measurement that occurs when the well pump fills the cistern. Once solution is to place the sensor at the top of a vertical PVC pipe, this would dampen the oscillations of the splashes. But be prepared to periodically clean the unavoidable spider webs (we have lots of black widows).
I found a better alternative with the TL231 pressure sensor. Place the sensor at the bottom of the tank and as you vary the level of water in the tank, the water pressure will increase. It even happens in a predicable way regardless of the shape of the tank. Because the TL231 is a passive current loop device you can read these changes by observing the change in current draw.
Interfacing to the TL231
The cable going to the sensor has two wires, red and black and a thin tube used for reading ambient air pressure. At a diameter of about 2.8 cm the probe was thin enough to fit though one of the spare pipe fittings at the top of the cistern. Thus I didn't have to fool with drilling a new hole for the wire.
I simply lowered the TL231 to the floor of the tank and made up a water tight fitting to keeps bugs from crawling into the opening. I ran the other end of the cable to the Raspberry Pi enclosure.
Since the TL231 operating voltage of 12-36 volts and 4–20mA input signal makes it very compatible with many process control devices. All you have to do is to wire it in series with a power supply and you can measure the changes with an ammeter.
To test things out, I hooked up an ammeter and a battery and lowered the TL231 into the tank. In expectation of setting a high and low limit I took note of what readings I got above water and at the bottom. At ambient air pressure, at the top of the tank, I got a reading of about 4 mA. As I lowered it to the bottom of a full tank, the current increased. Theoretically this would continue to 20 mA at 5 meters.
Interfacing the TL231 to the Raspberry Pi
I spent some time thinking about the best way to hook up the TL231 to the Pi. One option is the PR33-7 which is a 4-20mC current loop receiver board from National Control Devices. This plugs into the Pi's I2C bus using the PR2-2 level shifter with minimal wiring and would be my first choice in most applications.
However in my case, I was already using the I2C-MCP3427 analog to digital converter and had a spare channel available. Or you could use something like the Sparkfun ADS1015.
But even with the A/D converter you still need a reliable way to convert the 20ma signal to a voltage. Rather than try and hack together a resistor bridge, I chose to use the SEN0262 Analog Current to Voltage Converter from the folks at DFRobot. It was built on top of the TP5551 zero-drift operational amplifier and converts 0-25mA current signals linearly into 0-3V voltage signals. Best of all the SEN0262 circuit didn't need calibration, and it's powered from the 3.3 volts available on the QWIIC-I2C bus.
Setting up the software
Both the I2C-MCP3427, Sparkfun ADS1015 and the PR33-7 are all I2C devices and interface in pretty much the same way. Regardless of programming language you chose, you might need to do a few things to configure the Pi's I2C drivers.
If you are using Raspbian, you can setup from the command line:
Run
sudo raspi-config
.Use the down arrow to select
5 Interfacing Options
Arrow down to
P5 I2C
.Select
yes
when it asks you to enable I2CAlso select
yes
if it asks about automatically loading the kernel module.Use the right arrow to select the
<Finish>
button.Select
yes
when it asks to reboot.
You might also want to install the i2c-tools utilities:
sudo apt-get update
sudo apt-get install -y python-smbus i2c-tools
This will allow you to use the i2cdetect utility to scan the I2C bus for devices.
sudo i2cdetect -y 1
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- 27 -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: -- -- -- -- -- -- -- -- 48 49 -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- 68 -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --
Basic I2C Coding
In my case I chose to write my code in C/C++. This is not really too difficult. You can use the standard C file I/O calls to communicate with the I2C bus.
#define I2C_SLAVE0x0703/* Use this slave address */
#define I2C_BUS_DEV_FILE_PATH "/dev/i2c-1"
// open a port to the i2c driver.
int fd = open( I2C_BUS_DEV_FILE_PATH, O_RDWR);
// check for error (fd < 1)
// open your Ic2 device as a slave
int devAddr = YOUR_IC2_ADDR
if (ioctl(fd, I2C_SLAVE, devAddr) < 0) {
// Failed to acquire bus access and/or talk to I2C slave
}
Once you have opened the device you can perform read and write calls.
Reading values from the MCP3427
The MCP3427 is wired up on I2C address range 0x68 - 0x6F. Looking at the data sheet for the MCP3427 we find that we can configure the device a number of ways using the 8-bit wide configuration register to select the input channel, conversion mode, conversion rate, and gain.
I would like to setup the device in 16 bit mode, a gain of 1x, Continuous Conversion and using channel 1. The mask my config register bits :
X | 0 | 0 | 1 | 0 | 0 | 0 | 0
or in hex 0x10
So the first thing we need to do is to write to our device register. Adding on to our previous setup we do the following:
// setup the config bits
uint8_t config_byte = 0x10;
if (ioctl(_fd, I2C_SLAVE, devAddr) < 0) {
printf("Failed to select I2C device(%02X): %s\n",
devAddr,strerror(errno));
return -1;
}
ssize_t count = write(_fd, &config_byte, 1);
if (count != 1) {
printf( "Failed to write config to device(%02x): %s\n",
devAddr, strerror(errno));
return(-1);
}
You can see how I encapsulated these calls into some C++ classes
In particular interest is the code I wrote to read the analog value from the converter.
bool MCP3427::analogRead(uint16_t &resultOut, uint8_t channel, ADCGain gain, ADCBitDepth bitDepth){
int16_t adcVal = 0;
bool conversionDone = false;
if(!isOpen())
return false;
uint8_t configByte = computeConfigByte(channel, true, bitDepth, gain);
if(_i2cPort.writeByte( configByte) == -1)
return false;
// Roughly wait the amount of time we need
delayByBitDepth(bitDepth);
uint8_t reattempts = 0;
while(!conversionDone && reattempts++ < 8)
{
uint8_t registerByte[3] = {0,0,0};
if(_i2cPort.readBytes(registerByte, 3) != 3)
{
// Read error, got less than the expected byte
continue;
}
adcVal = ((uint16_t)registerByte[0])<<8;
adcVal |= (uint16_t)((uint16_t)registerByte[1]);
uint8_t confByte = registerByte[2];
if (confByte & 0x80)
{
// /RDY is still high, conversion not done
usleep(200 * 1000);
continue;
}
else {
conversionDone = true;
resultOut = adcVal;
}
}
resultOut = adcVal;
return conversionDone;
}
Given the MCP3427 code above, reading the from the sensor is very easy.
MCP3427 _sensor;
uint8_t deviceAddress = 0x68
bool status = _sensor.begin(deviceAddress, error);
// check for fail
uint16_t rawData = 0;
auto gain = MCP3427::GAIN_1X;
auto adcBits = MCP3427::ADC_16_BITS;
if(_sensor.analogRead(rawData, 0, gain, adcBits)) {
// rawData contains the value from A/D
}
Getting to tank volume
Both Sparkfun ADS1015 and the PR33-7 are based on the ADS1015 and ADS1115 converters and work in a similar way to the MCP3427 and there are more than a few samples of code available on GitHub that talk to these devices.
In all the cases we need to somehow convert the raw analog value from the sensor to a percentage of tank volume available. This took a slight bit of data gathering on my part. I needed to figure out three constants.
tankEmpty = What does the converter read at tank empty
tankFull = What does the converter read at tank full
tankGals = How many gallons does the tank hold.
Then give the rV = raw data from converter we can solve for the volume of water left in the tank.
Percent = ((rV - tankEmpty) / (tankFull- tankEmpty)
Volume = Percent * tankGals
Or expressed as code:
uint16_t valFull = 15000;// Tank full
uint16_t valEmpty = 7000;// Tank empty
uint16_t tankGals = 2000;
// pin rawData within range
if(rawData < valEmpty) rawData = valEmpty;
else if (rawData > valFull) rawData = valFull;
float volume = (float((rawData - valEmpty))
/float(( valFull - valEmpty))) * float(tankGals);
In the end I was able to display this as a graphic on an iPhone
On On!
The tank level code is just part of a larger pumphouse framework posted on GitHub. There is a server written in C++ for Raspberry Pi as well as a client written in swift for IOS.
At some point I will move the tank code to a server separate from the inverter monitor. I have a few other water tanks that I need to monitor for garden and firefighting uses. The current pumphouse code also tracks the changes in tank level into a sqlite3 database.
I plan to use that data to look for trends and generate the usage alerts.