Using AnalogInput to Calculate a Tank Level

After many, many exchanges with users about using SensESP to send a tank level to Signal K, it’s obvious that it’s a complex topic. Hopefully, this page will make it easy to understand - or at least make it possible to work. There are four distinct examples - make sure that the description of the example fits your situation.

When you read an analog input pin directly, the raw value is an integer whose range depends on the microcontroller and how its ADC is configured - on an ESP32 it is typically 0 - 4095. SensESP’s AnalogInput Sensor hides that difference for you: it reads the pin as a calibrated voltage, normalizes that against the 3.3 V full-scale reference, and multiplies the result by a parameter called output_scale. The output is therefore always in the range 0 to output_scale, whichever ESP32 variant you are using. The default value for output_scale is 1024. Note that output_scale only chooses the units of the output - it does not affect the precision of the reading. Since 1024 is usually not a meaningful number to us humans - we like things expressed in percentages, or ratios, or sometimes flushes, as you’ll see below - the use of the AnalogInput Sensor (and probably the Linear Transform) will be explained in hopefully simple terms, to help you get an appropriate number out of an AnalogIn pin and into Signal K.

For all of these examples, we’ll use a tank level sensor that reads the level of liquid in a tank and sends the value to the ESP’s AnalogIn pin.

EXAMPLE 1: a tank level sensor that uses the pin’s full range

This is the sensor that drives the pin all the way from 0 V when the tank is empty to the full-scale reference voltage when it’s full - so with the default output_scale of 1024, AnalogInput reports 0 for empty and 1024 for full. (All the values quoted in these examples are values reported by AnalogInput, not raw ADC counts.)

You, being human, would rather see empty and full expressed as a percentage from 0 to 100. When you instantiate your AnalogInput Sensor, do it with an output_scale of 100, like this:

// assume `pin`, read_delay`, and `config_path` are already defined in main.cpp
auto* tank_level = new AnalogInput(pin, read_delay, config_path, 100);

AnalogInput will convert any reading from empty to full into the proper percentage from empty (0) to full (100). Ta da!

However… tank levels are supposed to be sent to Signal K as a “ratio” - a number between 0 and 1, where 0 is empty and 1 is full. So, instead of using 100 for the output_scale, use 1 to express the value as a ratio.

EXAMPLE 2: a sensor that outputs 0 when the tank is empty, but less than full scale when the tank is full

It will be very unusual for your sensor to reach full scale (1024 in our first example) when the tank is full. Far more likely is that it will stop short of it - let’s say AnalogInput, with its default output_scale of 1024, reports 826 when the tank is full. To keep this relatively simple, we’ll still assume the output is 0 when the tank is empty.

  • empty_value = 0
  • full_value = 826
  • range = full_value minus empty_value = 826 - 0 = 826

We still want to express our final output as a percentage of full, so now, a value of 826 should be output as 100 (percent). A little algebra:

  • 1024 / 826 = X / 100
  • 1.239709443 = X / 100
  • 1.239709443 x 100 = X / 100 x 100
  • 123.9709443 = X (round to 124)

So if you set output_scale to 124, a reading of 826 will be 100 on that scale. The simple formula for you to use to calculate your output_scale is:

  • output_scale = 1024 / range x 100
  • or even simpler:
  • output_scale = 102400 / range (102400 / 826 = 123.9709443, which you can round to 124)
    auto* tank_level = new AnalogInput(pin, read_delay, config_path, 124);
    

    will convert any value from 0 - 826 into the appropriate percentage, 0 - 100.

However… tank levels are supposed to be sent to Signal K as a “ratio” - a number between 0 and 1, where 0 is empty and 1 is full. So, instead of using 124 for the output_scale, you need to divide it by 100: 124 / 100 = 1.24.

EXAMPLE 3: a sensor that outputs something other than 0 when the tank is empty

Not only is it unlikely that your sensor will reach full scale when the tank is full, it’s also unlikely to output 0 when the tank is empty. It’s far more likely to output some small-ish value, like 43. When that happens, we’re going to have to do two things to get our 0 - 100% scaled output: we have to get the proper scale, and we also have to have an offset, to compensate for the fact that empty = 43.

There are a couple ways to do this, but your humble author believes the simplest way is to let AnalogInput report whatever value it’s going to report with its default output_scale of 1024, and then run that value through a Linear Transform, using the transform’s multiplier and offset parameters. Here’s how it works (to avoid confusion with previous examples, note that the example numbers are totally different):

In your main.cpp start by using only AnalogInput connected to SKOutputFloat:

// notice that `output_scale` is not provided, so the default of 1024 is used
auto* tank_level = new AnalogInput(pin, read_delay, analog_in_config_path);
tank_level->connect_to(new SKOutputFloat(sk_path)); // assumes sk_path has already been defined

Get these two values for your tank: the value when the tank is empty, and the value when the tank is full (with examples):

  • empty_value (43)
  • full_value (991)
  • range = full_value minus empty_value (991 - 43 = 948)

We want to spread 948 equally across the percentage range of 0 - 100, so:

  • range divided by 100 = “output units per percent” (948 / 100 = 9.48)

In other words, each percentage point represents 9.48 “output units” from the AnalogIn pin. So if we take whatever the output is from AnalogIn and divide it by 9.48, we’ll get the appropriate percentage:

  • empty tank: 43 / 9.48 = 4.536
  • half full tank: (43 + 474) (which is 1/2 the range) / 9.48 = 54.536
  • full tank: 991 / 9.48 = 104.536

Obviously, every value is 4.536 bigger than it should be, so we know that our offset parameter needs to be -4.536.

Above, we’re dividing our reported value by 9.48, but multiplier is a multiplier, not a divisor. That is, the math is value X multiplier, not value / multiplier. We can convert a divisor into a multiplier simply:

multiplier = 1 / divisor (1 / 9.48 = 0.1055), and prove it with the same three sample numbers above:

  • 43 x 0.1055 = 4.536: add the offset (-4.536), and you get 0%.
  • (43 + 474) x 0.1055 = 54.546: add the offset (-4.536), and you get 50%.
  • 991 x 0.1055 = 104.551: add the offset (-4.536), and you get 100%.

So, by making Linear’s multiplier = 0.1055 and offset = -4.536, it will convert any value from AnalogInput into a percentage from 0 - 100.

Here are your important numbers and formulas for this example (with example numbers):

  • empty_value (43)
  • full_value (991)
  • range = full_value - empty_value (991 - 43 = 948)
  • divisor = range / 100 (948 / 100 = 9.48)
  • multiplier = 1 / divisor (1 / 9.48 = 0.1055)
  • offset = 100 - full_value x multiplier (100 - (991 x 0.1055) = -4.536)

Now, you add Linear to your main.cpp with your calculated multiplier and offset values:

auto* tank_level = new AnalogInput(pin, read_delay, analog_in_config_path);
tank_level->connect_to(new Linear(0.1055, -4.536))
          ->connect_to(new SKOutputFloat(sk_path));

However… tank levels are supposed to be sent to Signal K as a “ratio” - a number between 0 and 1, where 0 is empty and 1 is full. So, instead of using 0.1055 for the multiplier, you need to divide it by 100: 0.1055 / 100 = 0.001055. And the offset needs to be divided by 100, too: -4.536 / 100 = -0.04536.

ADVANCED EXAMPLE: Outputting something other than a percentage or a ratio (number of “flushes” for a blackwater tank)

You may not always want to see your output as a percentage. In the case of a blackwater tank, it might be more useful to express the level in terms of “flushes”, so that you’ll know how many more times you can flush the head before you have to pump out. (This is how the author does it, and trust the author - it works really well!) The only difference in this example is that instead of using 100 (percent) in the formulas, you use the number of flushes that represents a full tank. This will be only an estimate, as each flush isn’t exactly the same volume as every other flush, but it’s close enough. Here’s how you do it:

In your main.cpp start by using only AnalogInput connected to SKOutputFloat:

// notice that `output_scale` is not provided, so the default of 1024 is used
auto* tank_level = new AnalogInput(pin, read_delay, analog_in_config_path);
tank_level->connect_to(new SKOutputFloat(sk_path)); // assumes sk_path has already been defined

Get these two values for your tank: the value when the tank is empty, and the value when the tank is full (with example numers):

  • empty_value (43)
  • full_value (991)
  • range = full_value minus empty_value (991 - 43 = 948)

In addition, you need to know the value of an average flush:

  • with the tank not close to being full, record the value: (354)
  • flush the head 3 times, and record the value: (417)
  • calculate the average per flush: ((417 - 354) / 3 = 21)

Now you know that your range is 948 and an average flush is 21, so you have about 45 flushes in a full tank. (948 / 21 = 45)

Here are the important values and formulas, with our example numbers:

  • empty_value (43)
  • full_value (991)
  • range (991 - 43 = 948)
  • avg_flush (21)
  • total_flushes (45)
  • multiplier = 1 / avg_flush (1 / 21 = 0.0476)
  • offset = total_flushes - (full_value x multiplier) (45 - (991 x 0.0476) = 45 - 47.1716 = -2.1716)

Now, you add Linear to your main.cpp with your calculated multiplier and offset values:

auto* tank_level = new AnalogInput(pin, read_delay, analog_in_config_path);
tank_level->connect_to(new Linear(0.0476, -2.1716))
          ->connect_to(new SKOutputFloat(sk_path));

Note that this approach doesn’t actually “count” flushes; instead, it estimates the number of remaining flushes until full. As noted above, it works great! And in this case, we do NOT want to send the value to Signal K as a ratio, as we did in the other examples, so these calculated values for multiplier and offset are the ones to use.