Моделирование режимов работы аккумулятора
Моделирование режимов работы аккумулятора на основе конечных автоматов
Библиотека "Конечные автоматы" - это инструмент, предназначенный для удобного графического представления состояний системы и описания логики переходов между ними при выполнении определённых условий. Подход на основе конечных автоматов повышает наглядность, надёжность и масштабируемость сложных алгоритмов управляющей логики.
В данном примере мы шаг за шагом рассмотрим основные концепции библиотеки "Конечные автоматы" и смоделируем режимы работы простейшего аккумулятора.
Шаг 1. Машины состояний
Давайте добавим на холст блок Chart, перейдём в него и создадим два состояния:
- Аккумулятор может быть подключен к внешнему источнику питания и заряжаться (состояние
Charge). - В противном случае он разряжается (состояние
Discharge).

Мы с вами сейчас начали разрабатывать простейшую машину состояний. Машины состояний предназначены для моделирования систем, которые могут находиться в одном из заранее определённых состояний или режимов работы.
Добавим между состояниями переходы, а также обратим внимание на то, что к Charge подключен переход по умолчанию. Он обозначается точкой на конце и указывает на состояние, которое окажется активным на первом шаге симуляции.

Два других перехода пока что являются безусловными. Это значит, что если мы запустим симуляцию, то на каждом её шаге будет выполняться переход из одного состояния в другое. Т. е. они будут становиться активными поочерёдно.
Давайте запишем условия перехода - логические выражения, при истинности которых переходы будут считаться валидными. Их нужно указывать в квадратных скобках:
-
Если аккумулятор заряжается и переменная
isChargingпринимает значениеfalse, выполняется переход в состояниеDischarge. -
И наоборот, если аккумулятор разряжается и переменная
isChargingпринимает значениеtrue, выполняется переход в состояниеCharge.
.png)
Кстати, помимо условия мы могли бы задать действие, которое должно выполняться при осуществлении перехода. Оно записывается в фигурных скобках и чаще всего используется для присваивания переменных.
Рассмотрим алгоритм выполнения машины состояний более подробно:
- На первом шаге симуляции, машины состояний начинают выполняться с перехода по умолчанию.
- Последующие вызовы блока Chart приводят к выполнению с последнего активного состояния.
- После чего начинают проверяться условия переходов, исходящих из этого состояния. Если условие является валидным (то есть принимает значение "Истина"), то выполняются действия и переход осуществляется.
- В противном случае, тестируются другие переходы. Если они отсутствуют, или ни один переход не является валидным, текущее состояние остаётся активным.
Давайте теперь напишем код внутри состояний:
- Пусть аккумулятор не выдаёт мощность во время зарядки, а при разрядке выдаёт нагрузку в 3.5 Вт. Будем присваивать эти значения только при входе в состояние, поэтому напишем перед ними ключевое слово
entry. - Кроме того нам нужно изменять заряд аккумулятора в зависимости от активного состояния. Пусть скорость зарядки составляет 4% за шаг симуляции, а скорость разрядки - 3%.
- Заряд должен изменяться при условии, что состояние остаётся активным на текущем шаге симуляции, поэтому напишем перед выражением ключевое слово
during.
.png)
Есть ещё ключевое слово exit. Код после него выполняется при выходе из состояния.
Мы обратились в коде уже к трём переменным, давайте объявим их в настройках блока Chart. Здесь вы можете добавлять входные и выходные сигналы, локальные переменные и события, а также параметры. Кроме того можно указать их размерность, комплексность и тип данных.
Переменные, используемые в блоке Chart часто называют символами:
-
Входы - данные, поступающие из сигналов среды моделирования.
-
Выходы - данные, передающиеся в сигналы среды моделирования.
-
Локальные переменные - данные, используемые внутри блока для промежуточных вычислений.
-
Параметры - данные, определённые в рабочей области Engee.
Создадим вход isCharging и два выхода power и charge. Зададим начальное значение заряда равным 50%.
.png)
Выйдем из блока и обнаружим, что у него появились порты. Подадим на его вход ступеньку с временем воздействия равным 5. Я сразу преобразую этот сигнал к логическому значению.
Перед запуском симуляции запишем сигналы, чтобы затем их визуализировать.
.png)
Сохраним путь до примера в переменную dir:
dir = @__DIR__;
Запустим симуляцию:
model = engee.load(joinpath(dir, "step_1.engee"))
result = engee.run(model)
engee.close(model; force = true)
И построим графики:
plot(result["power"].time, result["power"].value, label = "Выходная мощность")
title!("Выходная мощность аккумулятора")
xlabel!("Время, [с]")
plot(result["charge"].time, result["charge"].value, label = "Заряд")
title!("Заряд аккумулятора")
xlabel!("Время, [с]")
Сигнал power удовлетворяет нашим требованиям - при разрядке выходная мощность составляет 3.5 Вт, а во время зарядки равна 0.
А вот с уровнем заряда явно что-то не так. Сначала он стал отрицательным, а затем превысил 100%.
Давайте это исправим.
Шаг 2. Иерархия состояний
Для начала создадим внутри Charge дочернее состояние (назовём его FastCharge) и перенесём в него код, определяющий изменение заряда. Это состояние должно активироваться при активации родительского, поэтому я подключил к нему переход по умолчанию:
.png)
Пусть при достижении уровня заряда в 80% активируется медленный режим, а при 100% зарядка прекращается. Для этого добавим ещё два дочерних состояния (назовём их SlowCharge и Full) и соединим переходами.
Внутри состояния SlowCharge запишем выражение изменения заряда, а Full оставим пустым, так как в нём ничего не должно происходить.
.png)
По аналогии добавим дочерние состояния в режим разрядки:
-
Выражение уменьшения заряда перенесём в состояние
Powered. -
А если заряд меньше или равен 3%, должно активироваться состояние
Empty, в котором выходная мощность аккумулятора равна 0.
.png)
Мы с вами только что добавили в машину состояний иерархичность. Она позволяет упростить моделирование систем, обладающих естественной иерархией, объединить повторяющиеся условия и действия, и облегчить масштабирование и повторное использование моделей.
При активации родительского состояния, выполнение должно перейти к одному из его дочерних состояний.
Дочернее состояние может быть активировано посредством:
-
Перехода по умолчанию.
-
Перехода, пересекающего границу родительского состояния (его обычно называют суперпереходом).
То есть переходы могут осуществляться как внутри одного уровня иерархии состояний, так и между различными уровнями.
Кстати, иерархия состояний может быть неограниченной вложенности.
Давайте заменим входной сигнал на прямоугольный импульс с периодом 5 секунд и шириной равной 50%:
.png)
Запустим симуляцию:
model = engee.load(joinpath(dir, "step_2.engee"))
result_2 = engee.run(model)
engee.close(model; force = true)
И убедимся, что теперь уровень заряда принимает допустимые значения
plot(result_2["charge"].time, result_2["charge"].value, label = "Заряд")
title!("Заряд аккумулятора")
xlabel!("Время, [с]")
Кроме того, выходная мощность аккумулятора становится нулевой, при условии, что он разряжен:
plot(result_2["power"].time, result_2["power"].value, label = "Выходная мощность")
title!("Выходная мощность аккумулятора")
xlabel!("Время, [с]")
В моменты времени, когда аккумулятор разряжается, его выходная мощность принимает фиксированное значение (3.5 Вт). Давайте сделаем так, чтобы она не превышала это значение, но при этом зависела от реального потребления подключенного устройства. А заодно разберём ещё один способ моделирования внутри блока Chart.
Шаг 3. Графы переходов
Как я говорил ранее, машины состояний это наглядный и удобный способ представления систем с различными режимами работы. Например, телевизора, автоматической коробки передач или аккумулятора.
Алгоритмы и процессы обычно моделируют на основе графов переходов. Добавим на холст пять узлов и соединим их.
.png)
Из начального узла в конечный можно попасть двумя способами:
-
Либо пойти по длинному пути с четырьмя переходами.
-
Либо по короткому с двумя.
Если из одного узла (как из начального) выходит несколько переходов, в начале стрелочек отображаются цифры. Это приоритеты, указывающие порядок, в котором переходы будут проверяться на валидность.
Так как мы пока что не задали никаких условий, всегда будет выполняться первый переход. Это значит, что мы всегда будем идти по более длинному пути, а переход с цифрой 2 никогда не станет активным.
Запишем условие в квадратных скобках, а действия в фигурных:
- Если требуемая мощность больше максимальной, выходная мощность равна максимальной.
- В противном случае выходная мощность равна требуемой.
- И пусть вне зависимости от выбранного пути, заряд уменьшается на значение выходной мощности.
.png)
Данный граф переходов представляет собой конструкцию if-else. На основе узлов можно собирать и более сложные управляющие конструкции - например, switch-case и циклы с пре- и пост- условиями или счётчиками.
В отличие от машин состояний, граф переходов выполняется от перехода по умолчанию до конечного узла при каждом вызове блока Chart.
То есть, графы переходов используются для проектирования логики принятия решений без учёта состояний. На каждом шаге симуляции в зависимости от заданных условий выбирается маршрут из начального узла в конечный.
Графы переходов и машины состояний можно не только использовать независимо, но и комбинировать. Давайте удалим код из состояний Discharge и Powered и встроим граф переходов в машину состояний.
.png)
Наверное, вы заметили, что я соединил границу состояния с начальным узлом. Это так называемый внутренний переход, который является графическим эквивалентом ключевого слова during.
Если бы я подключил к начальному узлу переход по умолчанию, это соответстовало бы ключевому слову entry.
Добавим в настройках блока локальную переменную maxPower с начальным значением 3.5 Вт и входной сигнал requiredPower. Начальное значение заряда charge изменим с 50 до 100%.
.png)
Требуемую мощность зададим синусоидой с амплитудой и смещением в 2.5 и фазой равной -pi/2.
Так как нас интересует разрядка аккумулятора, заменим прямоугольный импульс на константу со значением false:
.png)
Если мы сейчас запустим симуляцию, блок Chart будет вызываться на каждом её шаге и аккумулятор очень быстро разрядится. Давайте изменим его период дискретизации, например, увеличим его до двух десятых секунды.
.png)
Запишем требуемую мощность и после запуска симуляции убедимся, что выходная мощность теперь не превышает 3.5 Вт и зависит от реального потребления подключенного устройства.
model = engee.load(joinpath(dir, "step_3.engee"))
result_3 = engee.run(model)
engee.close(model; force = true)
plot(result_3["power"].time, result_3["power"].value, label = "Выходная мощность")
plot!(result_3["requiredPower"].time, result_3["requiredPower"].value, label = "Требуемая мощность")
title!("Мощность аккумулятора")
xlabel!("Время, [с]")
plot(result_3["charge"].time, result_3["charge"].value)
title!("Заряд аккумулятора")
xlabel!("Время, [с]")
Давайте теперь добавим в нашу модель ещё одну одноразовую резервную батарею, которая будет работать в случае разрядки аккумулятора.
Шаг 4. Параллельные состояния
Для этого я создам ещё одно состояние, назову его Main и помещу в него основные функции аккумулятора.
.png)
В ещё одном состоянии с именем Backup добавим режимы работы резервной батареи:
- Если аккумулятор заряжен, резервная батарея должна находится в режиме ожидания.
- В противном случае она будет разряжаться.
.png)
Состояние Discharge будет очень похоже на то, что мы делали раньше. Добавим в него два дочерних состояния Powered и Empty (по аналогии с основным аккумулятором).
Запишем условие изменения заряда резервной батареи и условие перехода в состояние Empty:
.png)
Раньше, когда мы запускали симуляцию, на каждом её шаге определялось только одно активное состояние. Для корректной работы нашей модели необходимо чтобы режим работы резервной батареи зависел от текущего состояния аккумулятора. То есть на каждом шаге симуляции должно быть активно не одно состояние, а два. Для реализации подобной логики в Engee можно включить параллельную декомпозицию состояний или параллельные состояния, как их обычно принято называть.
Кликнем правой кнопкой мыши по холсту и изменим тип декомпозиции:
.png)
Наверное вы заметили, что граница состояний стала пунктирной, а их в верхнем правом углу появилась цифра, обозначающая порядок исполнения:
.png)
Теперь на каждом шаге симуляции сначала будет определяться активное состояние аккумулятора, а затем активное состояние резервной батареи.
Обратите внимание, что несмотря на название, параллельные состояния не выполняются одновременно. Термин «Параллельный» обозначает, что несколько состояний на одном уровне иерархии будут активны в один и тот же момент времени симуляции.
К параллельным состояниям не могут быть подключены переходы. Все параллельные состояния активируются при активации родительских состояний в соответствии с порядком исполнения.
Соединим переходами состояния Wait и Discharge и запишем их условия.
Для координации параллельных состояний существует специальная функция in. Она возвращает значение true, если переданное в неё состояние активно.
То есть мы можем написать:
-
"Если аккумулятор разряжен, резервная батарея переходит в состояние
Discharge"; -
"Если аккумулятор заряжается, резервная батарея переходит в режим ожидания".
.png)
И давайте добавим в состояния Wait и Discharge вспомогательную переменную, по которой сможем понять, в каком режиме находится резервная батарея:
.png)
Добавим в настройках блока выходные сигналы bCharge с начальным значением 100% и isMain, а также запишем их:
.png)
Заменим конcтанту на генератор прямоугольных импульсов и вернём наследование шага решателя блоком Chart:
.png)
Запустим симуляцию и убедимся, что резервная батарея начинает работать, при условии, что аккумулятор разряжен.
model = engee.load(joinpath(dir, "step_4.engee"))
result_4 = engee.run(model)
engee.close(model; force = true)
plot(result_4["charge"].time, result_4["charge"].value, label = "Основной аккумулятор")
title!("Заряд аккумулятора")
xlabel!("Время, [с]")
plot(result_4["bCharge"].time, result_4["bCharge"].value, label = "Резервный аккумулятор")
title!("Заряд аккумулятора")
xlabel!("Время, [с]")
plot(result_4["isMain"].time, result_4["isMain"].value, label = "Признак работы")
title!("Признак работы основного аккумулятора")
xlabel!("Время, [с]")
Параллельная декомпозия состояний позволяет:
-
Объединить взаимозависимые машины состояний внутри одного блока Chart и реализовать чёткое разделение логических компонентов.
-
Обеспечить интуитивно понятные зависимости состояний на основе порядка исполнения.
Вывод
В данном примере мы рассмотрели основные концепции библиотеки "Конечные автоматы" - научились моделировать алгоритмы и процессы в виде графов переходов, а также разрабатывать машины состояний - системы, поведение которых зависит от текущего активного состояния. Кроме того мы изучили иерархические состояния и параллельную декомпозицию.
Данная библиотека расширяет классическую математическую концепцию конечных автоматов и позволяет проектировать по настоящему сложную управляющую логику в интуитивно понятной форме.