Метапрограммирование
Метапрограммирование в модельно-ориентированном проектировании
Простой пример метапрограммирования на языке Julia, полезный для моделирования в Engee.
Введение
Метапрограммирование — это создание программ, которые генерируют или модифицируют другой код во время выполнения. Оно нужно для автоматизации рутинных задач, уменьшения количества шаблонного кода и создания эффективных DSL (предметно-ориентированных языков).
Метапрограммирование поддерживают многие языки, например - Julia, Lisp, Ruby, Python, JavaScript и Rust. Julia изначально спроектирована с мощной поддержкой метапрограммирования на уровне макросов и генерации кода. Освоить его в Julia относительно просто благодаря гомоиконичности (код представляется структурами данных самого языка) и понятному синтаксису, но для эффективного использования требуется понимание этапов компиляции.
В этом примере мы рассмотрим, как использовать метапрограммирование на Julia в Engee в повседневных и рутинных задачах модельно-ориентированного проектирования (МОП). Простой пример такой задачи - оформление обратных вызовов модели для определения параметров блоков модели из результатов технических расчётов в скриптах Engee.
Следующий раздел рекомендуется к ознакомлению начинающим пользователям, а также пользователям не знакомым с МОП.
Подробная формулировка задачи
Примеры модельно-ориентированного проектирования (МОП), которые приведены в Сообществе Engee, содержат, по-минимуму, два файла:
-
скрипт Engee (*.ngscript) - содержит описание примера и, зачастую, технические расчёты, которые далее будут использованы в моделировании,
-
модель Engee (*.engee) - основанная на описании и расчётах из скрипта, осуществляет динамическое моделирование исследуемой системы.
Рабочий процесс МОП, в ходе которого эти файлы используются, может выглядеть так, как показано на рисунке ниже:
Данные, которые мы получаем в результате технических вычислений, используются для динамического моделирования, а, в свою очередь, результаты моделирования используются для дальнейшего их анализа.
Однако при развитии проекта - например, при модификации модели, её масштабировании и ином реиспользовании возникает задача сохранять и переносить данные для модели, которые были получены при первоначальных технических расчётах. Это могут быть, например, значения начальных условий параметров моделирования, как в примере параметрирования асинхронного двигателя. Вот код, который необходим для работы модели из этого примера:
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;
И вот, как он далее будет использоваться в модели (при конфигурировании блоков модели):
Чтобы определить эти переменные перед моделированием, существует несколько путей:
- Каждый раз перед моделированием запускать скрипт для расчёта и добавления их в рабочую область переменных. В этом случае нужно, чтобы по известному пути в файловом браузере располагался немодифицированный скрипт примера. Если его нет - нужно стянуть его из репозитория, где он хранится или загрузить из памяти компьютера. После этого требуется его запустить - при этом будут выполняться лишние промежуточные расчёты, будут устанавливаться пакеты Julia, выполняться моделирование и анализ результатов. То есть для подготовки к моделированию дополнительно затрачивается время и вычислительные ресурсы Engee.
- Каждый раз перед моделированием запускать новый специальный скрипт для параметрирования. Новый скрипт тоже нужно хранить по фиксированному пути, контролировать его версию, однако в него можно включить только определение параметров модели, как в кодовой ячейке выше. Этот подход лучше предыдущего, но всё равно приходится оперировать с дополнительной сущностью, что не всегда удобно и надёжно.
- Правильный путь - использовать обратные вызовы модели, в которых и определить исходные значения параметров для моделирования. Достаточно один раз записать код в обратные вызовы исходной модели, а далее, при её реиспользовании они будут автоматически добавлять в рабочую область.
Остаётся единственная рутинная, трудозатратная и не всегда удобная задача - без ошибок перенести в обратные вызовы код определения параметров модели из скрипта с расчётами.
При этом, как отмечалось выше - имена параметров приведены в коде, а их значения - в рабочей области или в выводе кодовой ячейке.
Код, который мы рассмотрим ниже, направлен на то, чтобы облегчить создание кода для определения параметров модели. Из формулировки задачи ясно, что это задача для метапрограммирования.
Начало решения
Что должен делать разрабатываемый код?
Наш код получает: имя переменной.
Наш код возвращает: код для определения переменной с заданным именем данными, содержащимися в рабочей области переменных Engee.
Результатом решения будет макрос - ведь именно макрос по-сути, является функцией, которая может работать с кодом, как с данными.
Создадим следующий макрос:
macro get_callbacks(var)
:(println($(string(var)), " = ", $var, ";"))
end
Несколько особенностей полученного кода:
Макрос get_callbacks
принимает аргумент var
(который является выражением, а не значением) и манипулирует им, преобразуя в строку (string(var))
и подставляя в quote :()
.
Макрос get_callbacks
не вычисляет переданное ему выражение :var
, а преобразует его в новый код. Во время компиляции @get_callbacks(x)
будет заменён на сгенерированную строку println("var", " = ", var, ";")
Передадим нашему макросу имя переменной для получения кода для обратных вызовов модели:
@get_callbacks(Uₙ)
В итоге мы получили код для определения переменной, имя которой мы передавали ранее.
Для нашего макроса можно также задать и статическое определение типа переменной, например:
macro get_typed_callbacks(var)
:(println($(string(var)), "::$(typeof($var)) = ", $var, ";"))
end
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)
Но, как мы видим, такой макрос не выводит кавычки для строки в определении строковой переменной. Исправить это можно следующим образом:
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
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)
Теперь наш макрос учитывает и передачу строковых переменных. Функциональности нашего макроса можно расширять и дальше, но вернёмся к решению исходной задачи и доработаем макрос до вида, который можно будет использовать в рутинной работе модельной разработки.
Готовая функция
Чтобы генерировать код для обратных вызовов модели не по одному параметру, а сразу для нескольких, можно создать внутри макроса вектор из генерируемых выражений, а далее создать блок таких выражений из вектора:
macro get_callbacks(vars...)
exprs = []
for var in vars
push!(exprs, :(println($(string(var)), " = ", $var, ";")))
end
return Expr(:block, exprs...)
end
Объединение выражений в блоки позволит выводить код для параметрирования сразу нескольких параметров
Использование в моделировании
Воспользуемся созданным макросом, создадим код для записи его в обратные вызовы модели примера из описания задачи:
@get_callbacks(Uₙ,f₁,Pₙ,Uₙ,p,R₁,X₁σ,R₂,X₂σ,Xₘ,J,Mₙ)
Теперь, при открытии модели im_parametrization.engee
определение этих параметров блоков модели произойдёт автоматически.
Сохраним такой полезный макрос на будущее, чтобы реиспользовать такой подход в других проектах модельной разработки.
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;
Заключение
В примере мы решили задачу автоматизации написания кода для обратных вызовов модели с использованием метапрограммирования на Julia.