Engee documentation
Notebook

Metaprogramming in model-oriented design

A simple example of metaprogramming in Julia, useful for modeling in Engee.

Introduction

Metaprogramming — this is the creation of programs that generate or modify other code during execution. It is needed to automate routine tasks, reduce the number of [template] tasks. кода](https://ru.wikipedia.org/wiki/Шаблонный_код ) and creating effective DSL (domain-specific languages).

Many languages support metaprogramming, such as Julia, Lisp, Ruby, Python, JavaScript, and Rust. Julia was originally designed with powerful macro-level metaprogramming and code generation support. It's relatively easy to master it in Julia due to homoiconicity (the code is represented by the data structures of the language itself) and a clear syntax, but effective use requires an understanding of the compilation stages.

In this example, we will look at how to use Julia metaprogramming in Engee in everyday and routine tasks of model-oriented design (MOS). A simple example of such a task is the design of model callbacks to determine the parameters of model blocks from the results of technical calculations in Engee scripts.

The following section is recommended for novice users, as well as users who are not familiar with the MOS.

Detailed task statement

Examples of model-based design (MOS), which are listed in the Engee Community, contain at least two files:

  • Engee script (*.ngscript) - contains a description of the example and, often, technical calculations that will later be used in modeling,

  • Engee model (*.engee) - based on the description and calculations from the script, performs dynamic modeling of the system under study.

The workflow of the MOS, during which these files are used, may look like the one shown in the figure below.:

alexevs - Frame 1.jpg

The data that we obtain as a result of technical calculations is used for dynamic modeling, and, in turn, the simulation results are used for further analysis.

However, during the development of the project, for example, when modifying the model, scaling it, and otherwise reusing it, the task arises to save and transfer the data for the model that was obtained during the initial technical calculations. These can be, for example, the values of the initial conditions of the simulation parameters, as in the example of [parameterization of an asynchronous motor] (https://engee.com/community/ru/catalogs/projects/model-asinkhronnogo-dvigatelia ). Here is the code that is needed for the model from this example to work.:

In [ ]:
Uₙ = 380;
f₁ = 50;
Pₙ = 7500.0;
Uₙ = 380;
p = 2;
R₁ = 0.6593049031972141;
X₁σ = 0.4861804567433673;
R₂ = 0.32521057126385705;
X₂σ = 0.4861804567433673;
Xₘ = 24.520695357232057;
J = 0.02;
Mₙ = 49.22317827584392;

And this is how it will continue to be used in the model (when configuring model blocks):

image.png

There are several ways to identify these variables before modeling.:

  • Each time before modeling, run a script to calculate and add variables to the workspace. In this case, an unmodified example script should be located along a known path in the file browser. If it doesn't exist, you need to pull it from the repository where it is stored or download it from your computer's memory. After that, you need to run it. At the same time, unnecessary intermediate calculations will be performed, Julia packages will be installed, modeling and analysis of the results will be performed. In other words, additional time and computing resources are spent by Engee to prepare for the simulation.
  • Each time before modeling, run a new special script for parameterization. The new script also needs to be stored in a fixed path and its version controlled, but it can only include the definition of model parameters, as in the code cell above. This approach is better than the previous one, but you still have to work with an additional entity, which is not always convenient and reliable.
  • The correct way is to use model callbacks, in which to determine the initial values of the parameters for modeling. It is enough to write the code into the callbacks of the original model once, and then, when it is reused, they will be automatically added to the workspace.

The only remaining routine, time-consuming, and not always convenient task is to transfer the model parameter definition code from the calculation script to the callbacks without errors.

At the same time, as noted above, the parameter names are given in the code, and their values are in the workspace or in the output code cell.

The code that we will look at below is aimed at making it easier to create code for defining model parameters. From the formulation of the problem, it is clear that this is a task for metaprogramming.

The beginning of the solution

What should the code being developed do?

++Our code gets ++: the name of the variable.

Our code returns: the code for defining a variable with a given name with the data contained in the workspace of the Engee variables.

The result of the solution will be a macro, because a macro is essentially a function that can work with code as with data.

Let's create the following macro:

In [ ]:
macro get_callbacks(var)
    :(println($(string(var)), " = ", $var, ";"))
end
Out[0]:
@get_callbacks (macro with 1 method)

Several features of the received code:

The macro get_callbacks accepts the argument var (which is an expression, not a value) and manipulates it by converting it to a string (string(var)) and substituting in the quote :().

The macro get_callbacks does not evaluate the expression passed to it :var, and converts it into a new code. During compilation @get_callbacks(x) will be replaced with the generated string println("var", " = ", var, ";")

Let's pass our macro the variable name to get the code for the model callbacks.:

In [ ]:
@get_callbacks(Uₙ)
Uₙ = 380;

As a result, we got the code to define the variable whose name we passed earlier.

For our macro, you can also set a static definition of the variable type, for example:

In [ ]:
macro get_typed_callbacks(var)
    :(println($(string(var)), "::$(typeof($var)) = ", $var, ";"))
end
Out[0]:
@get_typed_callbacks (macro with 1 method)
In [ ]:
number = 33.3
@get_typed_callbacks(number)

vector = [4, 8, 15, 16, 23, 42]
@get_typed_callbacks(vector)

name = "X Æ A-12"
@get_typed_callbacks(name)
number::Float64 = 33.3;
vector::Vector{Int64} = [4, 8, 15, 16, 23, 42];
name::String = X Æ A-12;

However, as we can see, such a macro does not print quotation marks for a string in the definition of a string variable. You can fix this as follows:

In [ ]:
macro get_typed_callbacks(var)
    quote
        value = $(esc(var))
        if typeof(value) == String
            println($(string(var)), "::$(typeof(value)) = \"", value, "\";")
        else
            println($(string(var)), "::$(typeof(value)) = ", value, ";")
        end
    end
end
Out[0]:
@get_typed_callbacks (macro with 1 method)
In [ ]:
number = 33.3
@get_typed_callbacks(number)

vector = [4, 8, 15, 16, 23, 42]
@get_typed_callbacks(vector)

name = "X Æ A-12"
@get_typed_callbacks(name)
number::Float64 = 33.3;
vector::Vector{Int64} = [4, 8, 15, 16, 23, 42];
name::String = "X Æ A-12";

Now our macro also takes into account the transfer of string variables. The functionality of our macro can be expanded further, but let's return to solving the original problem and refine the macro to a form that can be used in the routine work of model development.

Ready-made function

To generate code for model callbacks not for one parameter, but for several at once, you can create a vector inside the macro from the generated expressions, and then create a block of such expressions from the vector.:

In [ ]:
macro get_callbacks(vars...)
    exprs = []
    for var in vars
        push!(exprs, :(println($(string(var)), " = ", $var, ";")))
    end
    return Expr(:block, exprs...)
end
Out[0]:
@get_callbacks (macro with 2 methods)

Combining expressions into blocks will allow you to output code for parameterizing several parameters at once.

Use in modeling

Let's use the created macro, create a code to write it to the callbacks example model from the task description:

In [ ]:
@get_callbacks(Uₙ,f₁,Pₙ,Uₙ,p,R₁,X₁σ,R₂,X₂σ,Xₘ,J,Mₙ)
Uₙ = 380;
f₁ = 50;
Pₙ = 7500.0;
Uₙ = 380;
p = 2;
R₁ = 0.6593049031972141;
X₁σ = 0.4861804567433673;
R₂ = 0.32521057126385705;
X₂σ = 0.4861804567433673;
Xₘ = 24.520695357232057;
J = 0.02;
Mₙ = 49.22317827584392;

Now, when opening the model im_parametrization.engee These parameters of the model blocks will be determined automatically.

Let's save such a useful macro for the future in order to reuse this approach in other model development projects.

In [ ]:
cd(@__DIR__)
open("get_callbacks.jl", "w") do file
    write(file, """
macro get_callbacks(vars...)
    exprs = []
    for var in vars
        push!(exprs, :(println(\$(string(var)), " = ", \$var, ";")))
    end
    return Expr(:block, exprs...)
end
    """)
end;

Conclusion

In the example, we solved the problem of automating the writing of code for model callbacks using metaprogramming on Julia.