Water Tank Monitor Using Hall Effect Switches


#1

I had a need to monitor the depth of water in my 2600 gallon water tank that supplies my home and outside irrigation needs. I initially tried the “easy” solution, using an HC-SR04 ultrasonic sensor. This worked well for a day or so before condensation on the sensor caused it to report bad values. @bko suggested heating the sensor above the due point (see discussion of water tank monitoring here), but for several reasons, I decided not to go that route. For one, I thought it wold take a lot of testing under different temperature conditions (summer and winter) to determine if the solution was robust. Another more serious concern was that my tank has a rather vigorous ozone bubbler in it which causes a lot of aerosol formation. Given the high mineral content of my well water, I thought there would be a significant chance that the sensor would become coated with mineral deposits, rendering it useless.

So, I decided to use multiple hall effect switches that would be activated by a magnet on a float. All the electronic components would be inside a PVC pipe, with a donut shaped float riding up and down on this pipe; none of the sensors would be exposed to the internal atmosphere of the tank this way. I think this will be a more reliable way to measure the water level in my particular case, with the trade-off being discrete data points, rather than continuous ones, and a considerably more complicated hardware setup.

I used 48 hall effect switches (Allegro MicroSystems A3212) that were attached to a flat(ish) PVC garage door weather seal. After cutting the seal to about 1-1/2 inches wide, it was flat on one side, and had two grooves on the other that I could use to confine bare copper wires used for the ground and power connections to the switches.


Front and back of the PVC strip holding the Hall Effect switches. The first (highest in the tank) 25 sensors are 3/4” apart, the next 15 are 1” apart, and the remaining 8 are 2” apart. The sensors were secured to the PVC with a drop of cyanoacrylate glue.

After soldering all the sensors to the ground and power buses, and all the data pins to thermostat wire (8 conductors per cable), the strip was put into a 2” PVC pipe with styrofoam pieces on the wired face to keep the sensor side pressed against the inside wall of the pipe. I ripped a 1/2” piece of PVC pipe, taking off about a quarter of it to create a “U” shaped piece that I glued to the 2” pipe on the side opposite of the sensors. This would act as a key to keep the float from rotating as it moved up and down on the pipe.

The electronic part of the project was simpler than the mechanical part. The circuit board only contains a Photon and 3 CD4067 16 channel CMOS multiplexers. The outputs of the multiplexers were tied together and tied to one I/O port on the Photon. The code cycles through the chips (through their inhibit lines) and the addresses to read each sensor in turn. The code keeps track of which sensors are currently activated (it can be one or two), and averages the values if two are activated concurrently. Those values are sent to Ubidots for storage and graphing. This allows me to monitor the amount of water I use each day, and alerts me (via an SMS) if the tank fails to fill overnight; my well pump has a bad habit of losing its prime as soon as I go on vacation. The project has been running for over a month now with no problems.

Here is the Photon’s code,

// This #include statement was automatically added by the Particle IDE.
#include "HttpClient/HttpClient.h"

int dataPin = D2; // data pin to read the A3212 Hall Effect Switches (connected to the common in/out pin on the CD4067)

int chipInh0 = A2; // chip inhibit pins for the 3 CD4067 1x16 multiplexers. The chip is selected when the chip inhibit pin is LOW (inhibited when HIGH)
int chipInh1 = A3;
int chipInh2 = A4;

int onesBit = D1; // address pin A on a CD4067
int twosBit = D0; // address pin B
int foursBit = A1; // address pin C
int eightsBit = A0; // address pin D

unsigned long startTime;
float lastLevel = 100; // will be LOW for the selected sensor in the presense of a magnet
int counter;

float level; 
bool shouldSend6AMData = true;

// The first 25 Hall effect switches are 3/4" apart. The next 15 are 1" apart. The last 8 (lowest down in the tank) are 2" apart
// There are two places in the array below where the values are out of order (11&12 and 27&28) -- this is intentional to correct for wiring mistakes
float inchesDown[48] = {0.032, 0.75, 1.5, 2.25, 3, 3.75, 4.5, 5.25, 6, 6.75, 7.5, 9, 8.25, 9.75, 10.5, 11.25, 12, 12.75, 13.5, 14.25, 15, 15.75, 16.5, 17.25, 18, 19, 20, 22, 21, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 35, 37, 39, 41, 43, 45, 47, 49};

http_request_t request;
http_response_t response;
HttpClient http;
http_header_t headers[] = {
        { "Content-Type", "application/json" },
        { NULL, NULL } 
    };

STARTUP(WiFi.selectAntenna(ANT_EXTERNAL));
SYSTEM_THREAD(ENABLED);


void setup() {
    Time.zone(-7);
    request.hostname = "things.ubidots.com";
    request.port = 80;
    
    pinMode(dataPin, INPUT_PULLUP);
    
    pinMode(onesBit, OUTPUT); 
    pinMode(twosBit, OUTPUT); 
    pinMode(foursBit, OUTPUT); 
    pinMode(eightsBit, OUTPUT); 
    
    pinMode(chipInh0,OUTPUT);
    pinMode(chipInh1,OUTPUT);
    pinMode(chipInh2,OUTPUT);
    
    startTime = millis();
    Particle.function("getRSSI", getRSSI);
}


void loop() {
    
    if (Time.hour() == 6 && Time.minute() == 0 && shouldSend6AMData == true) {
        shouldSend6AMData = false;
        request.path = "/api/v1.6/variables/<variable ID here>/values?token=<token here>";
        request.body = "{\"value\":" + String(level * -31.33) + "}"; // this value is sent to Ubidots variable "levelAt6AM" once a day at 6 AM, and is tied to a Ubidots event that sends an SMS if the value is < -2 (i.e. the tank did not fill overnight)
        http.post(request, response, headers);
    }
    
    if (Time.hour() == 6 && Time.minute() == 1 && shouldSend6AMData == false) {
        shouldSend6AMData = true;
    }
    
    unsigned long now = millis();
    if (now - startTime > 5000) {
        cycleAddressPins();
        startTime = now;
    }
}



void cycleAddressPins() {
    float total = 0;
    counter = 0;
    for (int i=0; i<47; i++) { // 47 instead of 48 because the last sensor (lowest down in the tank) is non-functional and always reads LOW (as if a magnet was next to it)
        
        digitalWrite(chipInh0, (i < 16)? LOW:HIGH);
        digitalWrite(chipInh1, (i > 15 && i < 32)? LOW:HIGH);
        digitalWrite(chipInh2, (i > 31)? LOW:HIGH);
        delay(50);
        digitalWrite(onesBit, (i & 1));
        digitalWrite(twosBit, (i & 2) >> 1);
        digitalWrite(foursBit, (i & 4) >> 2);
        digitalWrite(eightsBit, (i & 8) >> 3);
        delay(50);
        int reading = digitalRead(dataPin);
        
        // interpolate between sensors if 2 are activated at the same time
        if (reading == 0) {
            total += inchesDown[i];
            counter ++;
        }
    }
    
    level = (counter >0)? total/counter : lastLevel;
    if (level != lastLevel) {
        lastLevel = level;
        request.path = "/api/v1.6/variables/<variable ID here>/values?token=<token here>";
        request.body = "{\"value\":" + String(level * -31.33) + "}"; // there are 31.33 gallons of water per inch of height in my 8' diameter tank
        http.post(request, response, headers);
    }
}



int getRSSI(String cmd) {
        return WiFi.RSSI();
}

#2

Very Cool!

@aguspg From Ubidots would love to see this I’m sure :smiley:


#3

Very neat solution to the difficult problem of home-made fluid level sensing!


#4

Congrats that you finally found a working solution.
I’m a bit late to the show but would still like to throw in an (possibly ridiculous) idea, just to get some oppinions:
Did you ever consider a pressure/tension/weight sensor to messure the buoyancy?


#5

Yeah, I did consider this. Physically, it would be simpler; just hang a metal rod in the water from a load cell, and measure the weight. The increase in weight would be proportional to the to how much of the rod was out of the water as the level went down. This would require careful calibration, maybe taking into account the temperature of the water? Also, In the picture of my float in the tank, you can already see the light brown color of the iron oxide on the part of the float (which is made from redwood) that’s under water; my concern would be how much that iron coating would affect the weight of the rod (maybe a teflon coated rod?). Another consideration would be how accurate (and therefore how expensive) of a load cell would you need. Most of the “action” happens in the top 12 to 20 inches of the tank, so you would be looking at pretty small changes in the weight.


#6

That is quite cool. Though it’s a little short for your application, I’ll mention this option that requires much, much less work in case someone else finds this in search and it fits their needs. I use one of these [magneto-resistive level sensors] (http://fuelminder.biz/FM%202%20sender/fm%202_purchasing_details.html), which is the same idea: a float with a magnet on a 42” long stainless steel rod. I got the 0-5V output version, which I fed into a voltage divider and into a Photon analog input. I use it to measure the depth of the oil in my fuel oil tanks.


#7

That would have been one version of that idea where you sink a heavy object and measure the weight loss, the invers version would have been a light weight object like a sealed PVC pipe which actively pushes up harder the higher the water stands.


#8

Hello Ric - Great project. I assume it is still running? How often do you take readings? You mentioned sometimes two sensors are activated at the same time, how is this? Is the magnet large enough to cover both or is it the flux from the magnet flowing out a little? Where did you get your float? I want to do this in a tote of acid so I need one that can handle that. Does your program retain previous positions?

Thank you,

Anthony


#9

Yes, the project is still running. I don’t take time based readings, the values are concatenated to a string every time the level changes, and I read that from an iOS app; I have to initiate that reading, and now that it’s been working so well for a long time, I usually don’t read it more than once a day. The string is reset to an empty string every day at 7 AM. At the top of the pipe, the sensors are only 3/4" apart, so the magnet is large enough to activate 2 at a time if it’s right in the middle between them. The float I’m using, I built out of a piece of 2"x12" redwood. Originally, that’s all it was, but it got so waterlogged that it didn’t float so well any more, so now, I have a piece of foam attached to the bottom of it, so it floats higher. I think you would probably want to use a plastic float instead, if you’re measuring an acid. To answer your last question, yes, it keeps a running list of the water levels until it’s reset at 7 AM.

The code has changed somewhat since I posted this. This is the code I’m running now.

#include <HttpClient.h>
 #include "Extras.h"

 typedef struct {
	 float reading;
	 int timeStamp;
 } data;

typedef union{
    uint8_t addr;
    struct {
        uint8_t bit1 : 1; // lsb
        uint8_t bit2 : 1;
        uint8_t bit4 : 1;
        uint8_t bit8 : 1;
        uint8_t highNibble : 4;
    };
} AddressUnion;

AddressUnion a;
data d;
data allData[100];
int indx;

OneShot timer6AM(6,0); // timer that fires once per day at 6:00
OneShot timer7AM(7,0); // timer that fires once per day at 7:00
OneShot timer7Thirty(7,30); // timer that fires once at 7:30 AM
OneShot timer11_30PM(23,30);
Wait fiveSecs(5, SECONDS); // a hidden millis timer. Can be MILLIS, SECONDS, or MINUTES
Timer fillRateTimer(300000, fillRateTimerHandler);
double fillStartValue;

const int dataPin = D2; // data pin to read the A3212 Hall Effect Switches (connected to the common in/out pin on the CD4067)

const int chipInh0 = A2; // chip inhibit pins for the 3 CD4067 1x16 multiplexers. The chip is selected when the chip inhibit pin is LOW (inhibited when HIGH)
const int chipInh1 = A3;
const int chipInh2 = A4;

const int onesBit = D1; // address pin A on a CD4067
const int twosBit = D0; // address pin B
const int foursBit = A1; // address pin C
const int eightsBit = A0; // address pin D

int counter;
int timeFilled;
double gallonsDown;
double twentyThreeThirtyGallonsDown;
char valuesString[620];
bool shouldReset;
bool shouldMonitorFillRate;

// The first 25 Hall effect switches are 3/4" apart. The next 15 are 1" apart. The last 8 (lowest down in the tank) are 2" apart
// There are two places in the array below where the values are out of order (11&12 and 27&28) -- this is intentional to correct for wiring mistakes
float inchesDown[48] = {0.032, 0.75, 1.5, 2.25, 3, 3.75, 4.5, 5.25, 6, 6.75, 7.5, 9, 8.25, 9.75, 10.5, 11.25, 12, 12.75, 13.5, 14.25, 15, 15.75, 16.5, 17.25, 18, 19, 20, 22, 21, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 35, 37, 39, 41, 43, 45, 47, 49};

http_request_t request;
http_response_t response;
HttpClient http;
http_header_t headers[] = {
        { "Content-Type", "application/json" },
        { NULL, NULL }
    };

STARTUP(WiFi.selectAntenna(ANT_EXTERNAL));
SYSTEM_THREAD(ENABLED);


void setup() {
	waitFor(Particle.connected, 600000); // wait up to 10 minutes for Wi-Fi router to come back online after power failure
	Particle.function("getRSSI", getRSSI);
	Particle.function("reset", resetDevice);
	Particle.function("fillMonitor", fillMonitor);
    Particle.variable("gallonsDown", gallonsDown);
    Particle.variable("values", valuesString);
	Particle.variable("timeFilled", timeFilled);
	pinMode(D7, OUTPUT);
	digitalWrite(D7, HIGH);
	waitFor(Time.isValid, 10000);
	digitalWrite(D7, LOW);
	setZone(); // sets time zone to Pacific Daylight Time or Pacific Standard Time depending on the date
    request.hostname = "things.ubidots.com";
    request.port = 80;

    pinMode(dataPin, INPUT_PULLUP);

    pinMode(onesBit, OUTPUT);
    pinMode(twosBit, OUTPUT);
    pinMode(foursBit, OUTPUT);
    pinMode(eightsBit, OUTPUT);

    pinMode(chipInh0,OUTPUT);
    pinMode(chipInh1,OUTPUT);
    pinMode(chipInh2,OUTPUT);

	fiveSecs.begin(); // nothing here other than start = millis() for a millis-like timer object
	//Particle.publish("WellDry", "Well is running dry");
}


void loop() {

	if (timer11_30PM.fired()) twentyThreeThirtyGallonsDown = gallonsDown;

	if (shouldReset == true) {
		  shouldReset = false;
		  System.reset();
	}

	// Sync time with the cloud once a day at 7:00 AM
  if (timer7AM.fired()) {
      Particle.syncTime();
		  waitFor(Time.isValid, 30000);
		  setZone();
		  for (int i=0; i<100; i++) {
			     allData[i] = {0, -1};
		   }
		   indx = 0;
       valuesString[0] = 0; // reset valueString to empty string once a day
	  }


    if (timer6AM.fired()) {
        if (allData[indx].reading > 0.04) {
            char sixAMValue[30];
            sprintf(sixAMValue,"Tank down by %.1f gallons", allData[indx].reading * 31.33 );
            Particle.publish("WaterTankFailure", sixAMValue, 60, PRIVATE); // triggers a webhook that sends a notification to my phone through Pushover
        }
    }

	if (timer7Thirty.fired()) {
		timeFilled = 0;
	}

    if (fiveSecs.isUp()) cycleAddressPins();
}


void cycleAddressPins() {
    float total = 0;
    counter = 0;
    for (a.addr=0; a.addr<47; a.addr++) { // 47 instead of 48 because the last sensor (lowest down in the tank) is non-functional and always reads LOW (as if a magnet was next to it)

		a.highNibble == 0 ? pinResetFast(chipInh0) : pinSetFast(chipInh0);
        a.highNibble == 1 ? pinResetFast(chipInh1) : pinSetFast(chipInh1);
        a.highNibble == 2 ? pinResetFast(chipInh2) : pinSetFast(chipInh2);
        delay(50);
        digitalWriteFast(onesBit, a.bit1);
        digitalWriteFast(twosBit, a.bit2);
        digitalWriteFast(foursBit, a.bit4);
        digitalWriteFast(eightsBit, a.bit8);
        delay(50);
        int magnetReading = digitalRead(dataPin);

        // interpolate between sensors if 2 are activated at the same time
        if (magnetReading == 0) {
            total += inchesDown[a.addr];
            counter ++;
        }
    }

	float lastValue = (indx == 0)? allData[0].reading : allData[indx - 1].reading;
    float level = (counter >0)? total/counter : lastValue;
    gallonsDown = level * -31.33; // there are 31.33 gallons of water per inch of height in my 8' diameter tank

	if ((Time.hour() < 7  || (Time.hour() > 22 && Time.minute() > 30))  &&  gallonsDown - twentyThreeThirtyGallonsDown > 48  &&  Particle.connected()) {
		Particle.publish("TankAlert", PRIVATE);
	}

    if (level != lastValue && indx < 99) {
		allData[indx].reading = level;
		allData[indx].timeStamp = Time.hour() * 60 + Time.minute();

		if (shouldMonitorFillRate == true) {
			shouldMonitorFillRate = false;
			fillStartValue = gallonsDown;
			fillRateTimer.start();
		}

		// get rid of repeat data if the last pair of values is the same as the pair before it
		if (indx > 2) {
			if (allData[indx].reading == allData[indx - 2].reading && allData[indx - 1].reading == allData[indx - 3].reading) {
				allData[indx] = {0, -1};
				allData[indx - 1] = {0, -1};
				indx -= 2;
			}
		}

		indx++;

		if ((Time.hour() > 21 || Time.hour() < 6) && level < 0.1 && timeFilled == 0) timeFilled = Time.hour() * 60 + Time.minute();

		valuesString[0] = 0;
		for (int i=0; i<indx; i++) {
			char s[15];
			sprintf(s, "%d-%.2f, ", allData[i].timeStamp, allData[i].reading);
			strcat(valuesString, s);
		}

        request.path = "/api/v1.6/variables/576f55be7625422b94879009/values?token=dqLW9lJbK9ilKhuhGAMHQxuCeqchrp";
        request.body = "{\"value\":" + String(gallonsDown) + "}";
        http.post(request, response, headers);
    }
}


int fillMonitor(String cmd) {
	if (cmd == "start") {
		shouldMonitorFillRate = true;
		return 1;
	}else {
		fillRateTimer.stop();
		return -1;
	}
}


void fillRateTimerHandler() {
	if (gallonsDown == fillStartValue && abs(gallonsDown + 23.5) > 1 ) { // value stayed the same and the tank is not full
		Particle.publish("WellDry", PRIVATE);
	}else if (abs(gallonsDown + 23.5) < 1) { // tank is full
		fillRateTimer.stop();
		Particle.publish("TankFilled", PRIVATE);
	}else if (gallonsDown < fillStartValue) { // tank level changed in under 5 minutes
		fillStartValue = gallonsDown;
		Particle.publish("FillingFast", PRIVATE);
	}
}


int resetDevice(String cmd) {
	shouldReset = true;
	return 1;
}



int getRSSI(String cmd) {
    return WiFi.RSSI();
}

#10

Ric - Thank you for the quick update and the new code. What type of magnet are you using? Is it a disc or a bar mounted on the float (hence the keying)? What would be the magnets rough dimensions? I am surprised at the bottom with 2" gaps between sensors that you don’t have areas where no sensor is triggered. What hall sensor are you using?

Anthony


#11

Ric - I missed the hall sensor you posted above. Got it now. Any info on the magnet would be appreciated.

Thank you,

Anthony


#12

I don’t remember where I bought the magnets. They’re small Neodymium discs made by Applied Magnets, 1/2" in diameter, and 1/8" thick. I think I have 5 or 6 of them stuck together, and held down on top of the float with a piece of plastic. I think any of the real strong Neodymium types should work. I do have places where none of the sensors are triggered, but that doesn’t matter for my application. I know the last one that was triggered, and that’s good enough.

What specifically is your use case? How deep will the liquid be, and what type of acid are you dealing with?


#13

Thank you Ric - My application is a 275 gal tote about 48 inches deep. I will have multiple totes out in the field. The acids will be sulfuric or hydrochloric at different concentrations. Sulfuric is tougher on things than hydrochloric. I was planning on an arduino, a gsm module, rtc and a sensor. I plan on only reading once per day so I will have the rtc alarm once every 24 hours, the alarm will trigger a mosfet circuit that will kick in the arduino. The readings will be taken, a text sent, IFTTT will grab the text and send it to a google sheets file. It will be running on battery and I think with this method I can get a long time out of the battery. Since usage could be substantial between readings, if there is a dead space, I cannot assume the last reading will be close to the liquid level. I really need an array like this to alway trigger at least one hall sensor. The nice thing about this is it does not take calibration, temperature is not a big issue, density of fluid doesn’t matter and PVC will hold up to the tough environment.

Anthony


#14

How close do you need to be? If the sensors are 1" apart, you would have an uncertainty of about 5 gallons.


#15

I don’t need to be real close. Just be able to decide if we fill it this week or next. I think 1" would be plenty accurate enough. If 1" prevents dead spaces that would work great and prevent the chance of gapping a large amount. This would be close enough for us to trend usage also.

Anthony


#16

Hello Ric - I have some hall sensors coming and some multiplexers coming to try this out. I am curious on your opinion of using normally closed reed switches instead of hall sensors and have them in parallel 1" apart with a resistor in between each switch. The resistors would be in parallel with each other so it would look like a ladder with the resistors being the steps and the wire and switches being the side rails. The controller would then read the resistance of the circuit and as the fluid goes down or up, the number of play would change. This would eliminate the multiplexers and there would be only two wires coming to the controller instead of a bundle. I don’t know if the controller would have a problem distinguishing between 48 points or so but if it did it could be broken into 2 or 3 banks of switches and still have a lot less wires. Anthony

Note: this was edited because I had the resistors and switches backwards


#17

Anthony,

Another possible solution would be to use a 0-5 PSI pressure transducer as shown, on an open ended PVC pipe. This is used on large tanks and should work on smaller applications but I’m not sure on the accuracy of volume height of 48 inches.
Of course make sure the transducer is rated for the liquid you will be monitoring. For about every 24 inches of volume height you would see about 1 PSI increase, best way would be to read sensor at empty and at full and do some easy math.

fluid%20level

/r,
Kolbi


#18

@Ric, I worked at a company that make tank monitoring systems. Your approach is similar to what they did.
They used reed switches, and had 3 magnets in the float. Years later they went to radar.


#19

I’m not really sure what your circuit is, so it’s hard to comment. Personally, I’m not a fan of reed switches because they’re noisy and require debouncing. I like the hall effect switches because their hysteresis helps give you a nice clean signal. I haven’t worked much with reed switches so I don’t know how close the magnet needs to be to get a reliable response.


#20

If you don’t need extreme precision, then 4 or 5 industrial float switches can be suspended from the top of the tank at the various heights and will change state as the level rises and drops - low tech and cheap and pretty easy to replace.