The Sunday blog: Boost your HMI with advanced mathematics!
Part 1: Introduction
Developers of Nextion HMI solutions often wish to be able to perform display only calculations directly in Nextion code. Be it to relieve the external microcontroller or in a stand-alone application – there is always a reason! A motive could also be that the integrated processor (32bit ARM Cortex M0 or better) in the Nextion HMI is significantly more powerful than many of the 8bit AVR or PiC processors used in the hobby and control sector.
Before we learn what hidden horsepower our Nextion HMI has under the hood and how to use it optimally, let us first recall some basics. Because only if we understand in principle how a microprocessor calculates can we make the best use of its capabilities.
In the beginning there were the integer and the addition
This could be done even before microprocessors existed. So-called adders already existed as TTL logic devices. Every subtraction can be done by a simple change of sign and an addition: a – b = a + (-b). Therefore, we will only deal with addition in the following, the rest is trivial.
Two integers are added by starting at the least significant digit, adding the corresponding digit values, adding any carry to the next higher digit, and so on to the most significant digit.
So it says in the mathematics book and is the formal description of what we know as written addition. We write the two numbers one below the other and add them from right to left, place by place, and note any carry-overs. The advantage is that this algorithm can be easily applied to any number system, be it binary, octal, decimal or even hexadecimal. That is why pocket calculators work this way, and microprocessors too.
This already shows the first advantage of 32bit processors over 8bit processors. While an 8bit register can only hold values from -128 to +127, which for larger numbers requires a procedure of several additions with carry and intermediate storage of the result, a 32bit register can process values from -2 147 483 648 to +2 147 483 647 in a single step. This means that as long as we can ensure that our result does not exceed this range of values, we do not have to worry about carry or overflow.
Many additions are a multiplication
Already in the early days of mathematics it was understood that many similar additions can be represented more easily by a multiplication, for example: a + a + a + a = 4 * a. This means that if you want to calculate 253 * 75, you simply add up the 253 total 75 times, which is however tedious and cumbersome. It can be easier:
Two integers are multiplied by multiplying each digit of the first number by each digit of the second number and adding the partial results taking into account the place value. The significance of a partial result that results from the multiplication of the digits n and m is m+n.
This academic description of what we know as written multiplication is also automatically applied, for example, by the Arduino C compiler when the numbers to be multiplied are larger than the value a register can hold, which makes the code large and slow, especially on 8bit processors, because many partial multiplications and additions have to be performed. On a 32bit system like our Nextion the work is mostly done in a single CPU cycle.
Sometimes you do need more accuracy. Let’s take the example of a conversion from 98°F to °C: First you have to subtract 32 and then divide by 1.8. The subtraction is still easy to do: 98 – 32 = 66, but how do I divide 66 by 1.8? This can only be done with integers if I first multiply both the dividend and the divisor by 10. Then you can calculate 660/18, which gives 36. But this is still relatively far away from the precise result (36.666666666…), especially if you wanted to make further calculations based on this result, which risks increasing the error.
How accurate is accurate?
The exact answer is it depends namely on how exactly I need the result. If I want it to be accurate to one decimal place, I have to calculate two decimal places and round them up or down to minimize the error. But how do I get two decimal places from an integer division? In our case, by “borrowing” two decimal places from the number to be divided by multiplying it by 100. Then I calculate 66000/18 and get 3666, and to do the rounding I add 5, which causes the penultimate digit to change if the last digit is 5 or more, or not to change if the last digit is 4 or less. Now I can divide the 3671 by an integer division of 10 (which simply cuts off the last digit) and get 367. At the same time I have reduced the number of “borrowed” digits from 2 to 1. So I know that I have one decimal place. The Xfloat element in the Nextion HMI now allows me to set a decimal place, so when I set its .val attribute to the 367 I got, it actually shows me 36.7.
It is true! If you have to be careful all the time where errors can occur, where you have to borrow places, where it makes sense to round up or down, and where places have to be cut again, then the microprocessor is not much help to us. In C or C++, the compiler does much of this work, but this can lead to bloated and slow code. Or, assuming the appropriate hardware, it delegates this to the mathematical coprocessor. But our Nextion hardware does not have that much luxury, which is one of the reasons why the Nextion programming language does not offer support for floating point numbers.
In two steps to the compromise
However, two techniques allow us to improve the precision of internal calculations of our Nextion without much effort: The first technique is called fixed-point format, which means that we commit ourselves to a certain number of decimal places from the beginning, which saves us a lot of moving the decimal place:
For example, we could decide to display two decimal places. To avoid rounding errors, we would therefore have to calculate three decimal places:
Example: 5.453 + 4.771 would be represented and calculated internally without much effort by 5453 + 4771. So we get 10224 at first, and after rounding (see above) 1022, which in an Xfloat with the previously known 2 decimal places is immediately displayed as 10.22.
We have to pay a little more attention to the multiplication, because the result of multiplying two numbers with 3 decimal places each already has 6 decimal places. So for rounding, where 5 is added to the third last digit, 5000 must be used as a summand, before the result is scaled to two decimal places again by integer division by 10000:
5453 * 4771 = 26 016 263. add 5000 and divide by 10000: 2602, the result is then displayed as 26.02!
In the division, however, the decimal places cancel each other out. Therefore, we have to upscale the dividend by three additional decimal places in advance: 5453000 / 4771 = 1142. Rounded and truncated as with addition and subtraction: 114, displayed 1.14.
The second technique is to move decimal places and round up or down not in the decimal system, but in binary. Instead of multiplying and dividing by powers of ten, we will do this with powers of two, because this can be done with simple bit-shift operations, which only need one CPU cycle, whereas a division on the ARM Cortex M0 needs at least 11 cycles.
How this is done in practice, and how much easier it is, we will see next week!