Engee documentation
Notebook

Fixed point data types

In this example, let's look at the possibilities of interacting with fixed-point numbers in both scripts and models.

Fixed-point numbers are numbers whose binary representation is limited by the size of their integer and fractional parts. These numbers have no possibility to change the position of the point in the bit representation of the number.

To interact with such numbers, Julia uses the FixedPointNumbers library, and Engee has its own built-in method to implement fixed point, so our demonstration will be aimed at comparing these two options for specifying such numbers.

FixedPointNumbers

The FixedPointNumbers library has two main commands Nifj and Qifj for specifying fixed point numbers, where:

  1. i and j are the number of bits allocated to the integer and fractional part;

  2. N - indicates that the number is unsigned;

  3. Q - indicates that one bit of the number is allocated to the sign.

*Note: the total of all bits allocated to a number must be equal to 8, 16, 32 or 64 bits.

In [ ]:
Pkg.add(["FixedPointNumbers", "CSV"])
   Resolving package versions...
  No Changes to `~/.project/Project.toml`
  No Changes to `~/.project/Manifest.toml`
In [ ]:
using FixedPointNumbers # Подключение библиотеки

Next, let us consider several examples of defining and applying such numbers. The first variant is the definition of an unsigned fractional number, the integer part of which is allocated 0 bits.

In [ ]:
x = N0f8(0.8)
Out[0]:
0.8N0f8

Then we add to this number an integer number with 2 bits allocated for the integer part. As we can see, the resulting data type has 8 bits for integer and fractional parts. Thus we can observe automatic increase of digit capacity of the number, which is implemented to prevent overflows.

In [ ]:
x + N2f6(2.8)
Out[0]:
3.592N8f8

Next, consider a number with a sign, which has 2 bits for the integer part, 5 bits for the fractional part and one bit for the sign.

In [ ]:
x = Q2f5(-1.8)
Out[0]:
-1.81Q2f5

Increasing this number three times, we will get an overflow on the integer part of the number, thereby losing the sign of the number.

In [ ]:
3x
Out[0]:
2.56Q2f5

To avoid overflow, we can force the type. In this case, we will perform addition with a number with a different point location and a different number dimension. When adding two numbers, the resulting data type will be assigned based on the number with the larger digit.

In [ ]:
Q4f11(2.8)-x
Out[0]:
4.6123Q4f11

If we want to perform operations on a number without a sign and a number with a sign, we need to convert the unsigned number to a number with a sign bit allocated. The float command is used for this purpose.

In [ ]:
x=Q2f5(float(N2f6(2.8)))
Out[0]:
2.78Q2f5
In [ ]:
Q4f11(2.8)+x
Out[0]:
5.5811Q4f11

Fixed point Engee

Now let's look at the implementations provided in Engee. The function ```fi(Value, Sign, Total_bits, Fractional_bits)` `` is used to define a fixed point number. It is more versatile than the Julia library and allows you to define any number within a single instruction, and it is not bound to a fixed number of bits per word, which, therefore, allows you to get an algorithm with optimal memory costs.

Now let us consider what the parameters of this function mean.

  1. Value - the value of the original number.
  2. Sign - sign (1-sign, 0-unsigned types).
  3. Total_bits - the total digit capacity of the word.
  4. Fractional_bits - size of the fractional part.
In [ ]:
Value = 128.9
Sign = 1;
Total_bits = 16;
Fractional_bits = 7;

println("fi: $(fi(Value, Sign, Total_bits, Fractional_bits))")
fi: 128.8984375

Let's repeat the tests previously described for the Julia library and see that ```fi()` `` is a more versatile command.

In [ ]:
# x = N0f8(0.8)
x = fi(0.8,0,8,8)
Out[0]:
fi(0.80078125, 0, 8, 8)
In [ ]:
# x + N2f6(2.8)
x + fi(2.8,0,8,6)
Out[0]:
fi(3.59765625, 0, 11, 8)
In [ ]:
# x = Q2f5(-1.8)
x = fi(-1.8,1,8,5)
Out[0]:
fi(-1.8125, 1, 8, 5)
In [ ]:
3x
Out[0]:
fi(-5.4375, 1, 72, 5)
In [ ]:
# Q4f11(2.8)-x
fi(2.8,1,16,11)-x
Out[0]:
fi(4.6123046875, 1, 17, 11)
In [ ]:
# x = Q2f5(float(N2f6(2.8)))
x = fi(fi(2.8,0,8,6),1,8,5)
Out[0]:
fi(2.8125, 1, 8, 5)
In [ ]:
# Q4f11(2.8) + x
fi(2.8,1,16,11) + x
Out[0]:
fi(5.6123046875, 1, 17, 11)

As we can see, the representation implemented in Engee is indeed more universal, and also more resistant to overflows, since it automatically adds bits to a word depending on the data types used for a particular number.

Using fixed-point calculations in models

Now let's move on to using fixed point numbers in models. For this purpose in Engee in the block settings there is a possibility to select the output data type. The figure below shows the interface of one of such blocks.

image_4.png

This example implements a PID controller model using fixed point logic. A proportional-integral-differential controller is a device in a feedback control loop. It is used in automatic control systems to form a control signal in order to obtain the required accuracy and quality of the transient process.

The figure below shows the model we have implemented.

image_3.png

Let's move on to the launch of this model.

In [ ]:
function run_model( name_model, path_to_folder )
    
    Path = path_to_folder * "/" * name_model * ".engee"
    
    if name_model in [m.name for m in engee.get_all_models()] # Проверка условия загрузки модели в ядро
        model = engee.open( name_model ) # Открыть модель
        model_output = engee.run( model, verbose=true ); # Запустить модель
    else
        model = engee.load( Path, force=true ) # Загрузить модель
        model_output = engee.run( model, verbose=true ); # Запустить модель
        engee.close( name_model, force=true ); # Закрыть модель
    end

    return model_output
end
Out[0]:
run_model (generic function with 1 method)
In [ ]:
# Запуск модели
run_model( "simple_model_pid_fixed", @__DIR__ )
Building...
Progress 0%
Progress 78%
Progress 100%
Out[0]:
SimulationResult(
    "SubSystem.command" => WorkspaceArray{Fixed{1, 16, 13, Int16}}("simple_model_pid_fixed/SubSystem.command")

)

Next, to process the modelling data recorded in CSV, we need to connect two libraries - DataFrames and CSV. To display this data, we will use the Plots library.

In [ ]:
# Подключение библиотек
using CSV
using DataFrames
using Plots

Now let's read the data from CSV, build the resulting graph and analyse this data.

In [ ]:
command_fixed = Matrix(CSV.read("$(@__DIR__)/command_fixed.csv", DataFrame)); #загрузка данных
command_fixed = (command_fixed[:,2]);

Let's look at the structure of the recorded data. As we can see below, the recorded data are presented in a standard format and do not require additional processing.

In [ ]:
dump(command_fixed[101])
Float64 3.0198974609375

Let's plot the recorded data, as well as perform an analysis based on the floating point data recorded during the simulation and compare their accuracy.

In [ ]:
command = Matrix(CSV.read("$(@__DIR__)/command.csv", DataFrame)); #загрузка данных
# Построение графиков 
plot(command[:,2]) 
plot!(command_fixed)
Out[0]:

As we can see, the graphs are approximately the same. In order to accurately determine the error, let's find their difference.

In [ ]:
sum(command[:,2]-command_fixed)
Out[0]:
0.0

It turns out that the difference is zero. It can also be seen that as a result, the fixed point number logic we used did not affect the principle of operation of the PID controller. Despite the loss of accuracy, the system tends to equilibrium.

Conclusion

In this example, we have broken down the possibilities of using fixed-point logic in scripts and in models. We also found out how they can be saved from the model and analysed or further processed in scripts.

Blocks used in example