Advanced settings of the Engee Function unit¶
The Engee Function block allows the user to embed their algorithm into the Engee model without redrawing it on primitives. The user has full control over the definition of the block's behaviour.
The following are examples of how this control can be exercised.
Overloading output parameters¶
Example: Conversion to a vector¶
You need to implement a block that converts its input to a vector.
No operations on the input are required. The only thing required is to convert the input into a vector. To do this, we need to calculate the length of the vector and get the type of its elements.
This task is implemented in the vectorize_it model by the VectorizeIT block.
Let's consider the code of this Engee Function. We are interested in the block's functor and the method of overriding dimensions and output type.
Let's start with the definition of the functor:
function (c::Block)(t::Real, cache, in1)
if in1 isa AbstractVector
cache .= in1
elseif in1 isa AbstractArray
cache .= vec(in1)
elseif in1 isa Real
cache[1] = in1
end
end
Here we should pay attention to the cache variable. This variable is declared for the Engee Function block if the setting Use external cache for non-scalar output
is enabled :
This setting is only available if the block has only one output.
Next, let's look at defining the dimension and output type. You can define them separately by selecting the Override type inheritance method and Override dimensions inheritance method options. However, you can define them in a single function. To do this, select Use common method for types and dimensions inheritance (the selection will become available after both methods are enabled):
For this case, the latter option is most appropriate. Let's consider the code of this method:
function propagate_types_and_dimensions(inputs_types::Vector{DataType}, inputs_dimensions::Vector{<:Dimensions})::Tuple{Vector{DataType}, Vector{<:Dimensions}}
in1t = inputs_types[1]
in1d = inputs_dimensions[1]
outd = (prod(in1d), )
return ([eltype(in1t)], [outd]);
end
The input is two vectors: the types of the inputs and their dimensions. At the output, a tuple consisting of two vectors must be formed:
- a vector of output signal data types
- vector of output signal dimensions
It should be noted separately that the dimension is passed as a tuple (i.e. as the result of the function size()
), and the data type as an element type. In our case it will not be Vector{eltype(in1t)}
, but simply eltype(in1t)
.
Strict block typing and caching of results¶
Example: Calculating a system of equations¶
The Julia language allows you to dynamically allocate memory, but this results in slower code. Therefore, the Engee Function block can have a negative impact on the speed of the simulation. This example demonstrates how to maximise the performance of the Engee Function block.
Using a separate variable to store the result of the block's work (cache) allows you to get rid of dynamic memory allocation and significantly speeds up the block's work. Let's consider a simple example:
a = [[i, 2i, 3i] for i in 1:1_000_000]
b = [[4i, 5i, 6i] for i in 1:1_000_000]
cache = [0, 0, 0]
function sum_without_cache(a, b)
@time for i in eachindex(a)
a[i] .+ b[i]
end
end
function sum_with_cache(cache, a, b)
@time for i in eachindex(a)
cache .= a[i] .+ b[i]
end
end
println("Without cache:")
sum_without_cache(a, b)
println("With cache:")
sum_with_cache(cache, a, b);
As you can see from the example, applying a cache is a highly desirable practice. For Engee Function, we can define the cache in two ways:
- By specifying the Use external cache for non-scalar output checkbox (for a single output)
- Manually creating caches in the block definition
To maximise performance, all fields in the block structure should have a specific type and a fixed size.
As an example, consider the cached_calcs model and the ComputingTypesRuntime block.
This block implements the following system of equations: $$ \begin{cases} y_1 = sin(u_1) + cos(u_2)\\ y_2 = u_1 + u_2 \end{cases} $$
The implementation of this system involves the use of caches since the inputs may be non-scalar.
To create caches we need to define a typed structure. The usual Define component structure method does not allow us to do this, but we can enable the Use common code setting and create a structure for a block in "common" code. The code for defining the block structure would be as follows:
struct Block{CacheType1,CacheType2} <: AbstractCausalComponent
c1::CacheType1;
c2::CacheType2;
function Block()
sz1 = OUTPUT_SIGNAL_ATTRIBUTES[1].dimensions;
sz2 = OUTPUT_SIGNAL_ATTRIBUTES[2].dimensions;
type1 = OUTPUT_SIGNAL_ATTRIBUTES[1].type;
type2 = OUTPUT_SIGNAL_ATTRIBUTES[2].type;
c1 = isempty(d1) ? zero(t1) : zeros(t1, d1)
c2 = isempty(d2) ? zero(t2) : zeros(t2, d2)
new{typeof(c1), typeof(c2)}(c1,c2);
end
end
It should be noted that the cache type and dimensions are derived from the outputs. And the attributes of the outputs are derived in the Use common method for types and dimensions inheritance using a single calculation of the system of equations:
function propagate_types_and_dimensions(inputs_types::Vector{DataType},
inputs_dimensions::Vector{<:Dimensions})
::Tuple{Vector{DataType}, Vector{<:Dimensions}}
in1t = inputs_types[1]
in2t = inputs_types[2]
in1d = inputs_dimensions[1]
in2d = inputs_dimensions[2]
mockin1 = zeros(in1t, in1d)
mockin2 = zeros(in2t, in2d)
mockout1 = sin.(mockin1) .+ cos.(mockin2)
mockout2 = mockin1 .+ mockin2
return ([eltype(mockout1), eltype(mockout1)], [size(mockout1), size(mockout1)])
end
This technique will only result in a single memory allocation.
Direct feedthrough¶
Example: Breaking an algebraic loop¶
As an example, let's consider the practical task of breaking an algebraic loop. It is recommended to break an algebraic loop using a delay block. But this technique leads to distorted results. And the IC block does not break the loop.
Therefore, let's create such an Engee Function block that would implement the following system of equations and break the algebraic loop: $$ y(t) = \begin{cases} IC, t = 0\\ u, t > 0 \end{cases} $$ To break the algebraic loop, we need to remove the direct feedthrough attribute. This can be done either from the settings of the Engee Function block or programmatically by setting the Override direct feedthrough setting method option.
The example implementation is available in the loopbreaker model in the LoopBreaker block.
The principle of operation of the block is as follows: the parameter IC
, which will be output at the moment of initialisation of the model, and at the initial moment of simulation time, is set. At the next steps of the simulation, the block input will be immediately passed to the output. Let's look at the implementation of this algorithm:
A block is defined as:
struct Block{T} <: AbstractCausalComponent
InCn::T
function Block()
InCn_t = INPUT_SIGNAL_ATTRIBUTES[1].type;
new{InCn_t}(IC)
end
end
And its functor as:
function (c::Block)(t::Real, in1)
if t<=0.0
return c.InCn
else
return in1
end
end
Additionally, the Override direct feedthrough setting method is defined. No logic or calculations are required here, so we just open the loop:
function direct_feedthroughs()::Vector{Bool}
return [false]
end
Let's make sure that the simulation works correctly:
demoroot = @__DIR__
mdl = engee.load(joinpath((demoroot),"loopbreaker.engee");force=true);
simres = engee.run(mdl)
st = collect(simres["Ref.1"])
res = collect(simres["LoopBreaker.1"])
using Plots
p = Plots.plot(st.time, st.value, label="Step")
Plots.plot!(res.time, res.value, label="LoopBreaker")
Plots.plot!(legend=:topleft)
engee.close(mdl;force=true)
display(p)
Setting Sample Time¶
Example: "Slowing down" the output¶
There may be models and algorithms that require that the outputs of blocks be refreshed for a certain period of time. In the case of the Engee Function block, the user has two methods of implementing such a requirement:
- explicitly specifying the sampling period through the block parameters
- Writing their own algorithm to determine the required period
This example considers the second option.
Consider the sample_time_override model:
Functionally, the Engee Function blocks ST_Orig and ST_Override correspond to the algorithm from the Cache and strict typing section. However, there is now a requirement to produce the result with the lowest period. Let's see how the built-in sampling period inheritance mechanism works:
We can see that the period D1 is inherited, i.e. the highest period. But the task is to inherit D2. To do this, in the Engee Function code, let's enable the Override sample time inheritance method section, and choose the slowest period:
function propagate_sample_times(inputs_sample_times::Vector{SampleTime},
fixed_solver::Bool)::SampleTime
max_period = argmax(t -> t.period, inputs_sample_times)
return max_period
end
After implementing this method the following result is obtained:
It can be seen that the desired period is now inherited, D2
IMPORTANT!¶
The first argument is a vector of named tuples of type SampleTime, given as:
SampleTime = NamedTuple{(:period, :offset, :mode), Tuple{Rational{Int64}, Rational{Int64}, Symbol}}
The mode field of this tuple takes the following values: :Discrete, :Continuous, :Constant, :FiM.
The second argument is a flag that indicates the class of the solver. If the flag is true, a constant step solver is selected.
Conclusions¶
Methods for advanced programming of the Engee Function block have been discussed. These methods allow you to define in detail the required block behaviour and achieve maximum performance. This material does not cover typical errors when writing block code and techniques of writing safe code.