
Привет, Хабр!
При разработке технических систем часто приходится описывать управляющую логику, зависящую от множества факторов: времени, событий, текущего состояния устройства и действий пользователя. Например, кофемашина может переключаться между режимами ожидания, приготовления напитка и очистки, а квадрокоптер – переходить в режим посадки при низком уровне заряда или в аварийный при неисправности.
Со временем логика системы начинает разрастаться: появляются дополнительные режимы работы, усложняются условия переходов между ними, возникает необходимость корректно реагировать на ошибки. В какой-то момент код превращается в клубок из вложенных if-else
, флагов и переменных, описывающих состояние системы, что не только затрудняет её поддержку и расширение, но и снижает надёжность.
Одним из решений этой проблемы может стать подход на основе конечных автоматов. В этой статье я покажу, как можно разработать управляющую логику фитнес-браслета с использованием удобного инструмента визуального проектирования и при этом не дать ей выйти из-под контроля.
Состояния и переходы
Но для начала расскажу немного о конечных автоматах. Это математическая абстракция – модель, описывающая систему, которая в каждый момент времени может находиться в одном из заранее определённых состояний.
В качестве примера простейшего конечного автомата можно привести телевизор. Он может быть в двух состояниях: «включён» и «выключен». Нажатие кнопки на пульте переводит телевизор из одного состояния в другое – выполняется переход.
Конечные автоматы удобно представлять в виде диаграммы состояний – ориентированного графа, вершины которого обозначают состояния, а дуги показывают переходы между ними:

В такой модели важно не только событие, вызывающее переход, но и состояние, в котором находилась система в момент его возникновения.
Интересно то, что конечными автоматами можно описать множество объектов и процессов реального мира – технические системы, сетевые протоколы, бизнес-процессы и даже пользовательские интерфейсы.
Для разработки на основе конечных автоматов существует специальное направление, называемое автоматным программированием. Оно призвано улучшить читаемость кода, описывающего сложную логику, что упрощает его масштабирование и позволяет минимизировать ошибки. Также стоит отметить, что для программирования промышленных логических контроллеров широко используется графический язык последовательных функциональных схем SFC (Sequential Function Chart), предлагающий описывать процессы в форме, близкой к диаграммам состояний.
Ещё более удобный способ разработки на основе конечных автоматов – визуальное проектирование. Некоторые среды математического моделирования предоставляют для этого специальные инструменты. Если вы работали в Simulink, то наверняка что-то слышали и о StateFlow. Я же в рамках этой статьи буду использовать российскую платформу математических вычислений и динамического моделирования Engee, для которой разработана библиотека «Конечные автоматы».
По сравнению с другими отечественными средами математического моделирования, это не только функциональный, но и интуитивно понятный инструмент, поэтому предлагаю сразу начать осваивать его на практике.
Если после прочтения статьи вам захочется посмотреть на другие интересные примеры использования библиотеки «Конечные автоматы» а также узнать больше о возможностях отладки и генерации C кода из диаграмм состояний, приглашаю на вебинар «Разработка управляющей логики с использованием конечных автоматов в Engee».
Библиотека «Конечные автоматы»
Для начала создадим новую модель и добавим на холст блок Chart
:

Внутри этого блока могут быть использованы следующие элементы:

На основе состояний можно разрабатывать машины состояний – те самые системы с различными режимами, о которых я говорил ранее. Узлы позволяют строить графы переходов, наглядно представляющие алгоритмы. Машины состояний и графы переходов можно комбинировать.
Соберём простую машину состояний, принимающую входной сигнал power
и возвращающую определённое значение выходного сигнала data
в зависимости от активного состояния:

Входы и выходы блока необходимо задать в его окне настроек:

Давайте разберёмся, как это работает:
На первом шаге симуляции модели осуществляется переход по умолчанию и активируется состояние
Off
.Внутри состояния задаются инструкции, которые будут выполняться при условии, что оно активно. В нашем примере благодаря ключевому слову
entry
значение переменнойdata
будет присваиваться только при входе в состояние.В квадратных скобках над стрелками указываются условия переходов.
На каждом следующем шаге симуляции анализируются все переходы, исходящие из активного состояния. Если какое-либо условие перехода истинно или переход является безусловным, он выполняется.
Одно из главных достоинств визуального подхода к проектированию конечных автоматов – его наглядность. Для того, чтобы понять, в каких состояниях может находиться система, достаточно просто посмотреть на схему.
Для сравнения – если бы мы попытались реализовать этот простой конечный автомат в виде функции на языке C, скорее всего у нас получилось бы нечто подобное:
#include <stdint.h>
#include <stdbool.h>
double FSM(bool power) {
static bool initialized = false;
static int64_t state = 1;
double data;
if (initialized) {
if (state == 1) {
if (power) {
state = 2;
}
}
else {
if (!power) {
state = 1;
}
}
}
else {
initialized = true;
}
if (state == 1) {
data = 0;
}
else {
data = 1;
}
return data;
}
Для реальных технических систем, которые могут содержать десятки состояний и сотни переходов между ними, сложность написания и поддержки кода по сравнению с визуальным проектированием возросла бы ещё более стремительно.
Библиотека «Конечные автоматы» в Engee расширяет классическую математическую концепцию. Например, графы переходов могут размещаться внутри состояний, а сами состояния – представлять собой иерархию неограниченной вложенности и активироваться параллельно. Для управления переходами можно использовать специальные темпоральные операторы и индикаторы изменений. Всё это позволяет разрабатывать сложную логику, оставаясь при этом в рамках наглядно структурированной модели. Если вы знакомы с UML State Machine Diagrams, то наверняка подметите некоторые сходства.
Теперь у нас достаточно знаний об инструменте, а, значит, можно переходить к моделированию.
Моделируем управляющую логику
Создадим новую модель, назовём её fitness_tracker.engee
и добавим в неё два блока Chart
.
Человек
Первый блок будет имитировать характеристики человека – частоту сердечных сокращений (количество ударов в минуту) и каденс (количество шагов в секунду) в зависимости от выбранной деятельности:
сон;
покой;
ходьба;
бег.
Зададим целевые значения характеристик:
ID состояния | Состояние | Частота сердечных сокращений | Каденс |
---|---|---|---|
0 | Сон | 65 | 0 |
1 | Покой | 75 | 0 |
2 | Ходьба | 85 | 1 |
3 | Бег | 85 + 20 * | 1 + 0.5 * |
Целевые значения для бега будут зависеть от его интенсивности, которая может принимать значения от 1 до 5.
При переходах между состояниями характеристики не должны изменяться мгновенно, поэтому при входе в состояние будем запоминать их текущие значения, а также время, прошедшее с начала симуляции. Это потребуется нам для дальнейшего вычисления промежуточных значений. Для упрощения модели примем, что частота сердечных сокращений будет приходить к целевой за 20 секунд, а каденс за 10 вне зависимости от состояния.
Назовём этот блок human_simulator
и разместим в нём состояние Activity_Simulator
.
При входе в это состояние (на первом шаге симуляции) необходимо задать начальные значения частоты сердечных сокращений и каденса в зависимости от деятельности человека. Сделаем это на основе простого графа переходов:

Условия переходов проверяются в соответствии с приоритетами, указанными в начале стрелок. Если ни одно из трёх условий не выполняется, будем считать, что человек находится в состоянии «Бег», и инициализируем характеристики значениями, соответствующими его средней интенсивности.
Также добавим в Activity_Simulator
четыре состояния – Sim_Sleep
, Sim_Rest
, Sim_Walk
и Sim_Run
. Их содержимое будет отличаться лишь целевыми значениями характеристик.
Например, состояние Sim_Run
будет выглядеть так:

Здесь кроме entry
, о котором я рассказывал ранее, также используется ключевое слово during
, формирующее группу операторов, которые должны выполняться, только если состояние остаётся активным на текущем шаге симуляции. Сразу скажу, что есть ещё ключевое слово exit
, смысл которого понятен из названия.
Переменная t
внутри блока Chart
возвращает текущее время симуляции.
Вычисление промежуточных значений характеристик внутри группы during
производим по формуле линейной интерполяции:
Выход из активного состояния будет осуществляться при изменении интенсивности бега или смене деятельности человека. Узнать об этом можно, вызвав индикатор изменений hasChanged()
внутри условия перехода:

В результате состояние Activity_Simulator
будет выглядеть так:

Перед тем, как перейти к фитнес-браслету, добавим рядом с Activity_Simulator
ещё одно состояние и назовём его Results_Processing
:

В нём мы будем прибавлять к частоте сердечных сокращений некоторое малое случайное значение и округлять полученный результат до ближайшего целого. Кроме того, каждую секунду симуляции будем увеличивать количество шагов пользователя. Для этого в фигурных скобках на переходе запишем выражение, которое должно выполняться, если темпоральный оператор after(1, sec)
возвращает true
.
Изменим тип декомпозиции блока на параллельный для того, чтобы состояния Activity_Simulator
и Results_Processing
активировались на каждом шаге симуляции независимо друг от друга в порядке нумерации. Он появится в правом верхнем углу состояний, а их граница станет пунктирной.
Фитнес-браслет
Блок, представляющий модель фитнес-браслета, будет принимать на вход частоту сердечных сокращений и каденс, и на основе их анализа определять текущий вид деятельности пользователя.
Кроме того, устройство должно:
Имитировать подсчёт шагов, при условии, что человек идёт или бежит;
Присылать уведомление с предложением отдохнуть, если время физической активности превышает заданное;
Предлагать тренировку, если отдых затянулся;
Предупреждать об опасной частоте сердечных сокращений.
Для того, чтобы не реагировать на внезапные всплески характеристик (например частота сердечных сокращений может подскакивать во сне при психоэмоциональной нагрузке), переходы между состояниями будут осуществляться, только если их условия выполняются в течение 5 секунд. Можно использовать для этого темпоральный оператор duration()
.
Назовём блок fitness_tracker
и добавим в него первое состояние:

При входе в Check_Heart_Rate
активным становится его дочернее состояние Quiet
, в котором ничего не происходит. Но если частота сердечных сокращений пользователя превышает заданную в течение 10 секунд, выполняется переход в состояние Notify
, после чего переменная notification_dangerous_heart_rate
принимает значение true
.
Выход из состояния Notify
осуществляется сразу после того, как пульс вернётся в норму, notification_dangerous_heart_rate
при этом принимает значение false
.
Наверное, вы уже заметили, что я сразу же изменил декомпозицию блока на параллельную, так как планирую добавить ещё одно состояние, которое должно выполняться на каждом шаге симуляции. Назовём его Activity_Tracker
. Это состояние окажется чуть более сложным, чем всё, что мы делали ранее, поэтому предлагаю разобраться с ним по частям.
Для начала научим фитнес-браслет определять, чем пользователь занимается:

Для этого разделим все возможные состояния на две группы – «Бездействие» (сон и покой) и «Действие» (ходьба и бег).
На первом шаге симуляции выбираем активное состояние следующим образом:
Если человек движется (каденс больше 0), значит он или бежит (каденс больше 1) или идёт;
Иначе пользователь спит (частота сердечных сокращений меньше 70) или находится в состоянии покоя.
Переходы между состояниями можно описать так:
№ перехода | Переход из состояния | В состояние | При условии, выполняющемся больше 5 секунд |
---|---|---|---|
1 | Действие | Бездействие | Каденс равен 0 |
2 | Бездействие | Действие | Каденс больше 0 |
3 | Покой | Сон | Частота сердечных сокращений меньше 70 |
4 | Сон | Покой | Частота сердечных сокращений больше или равна 70 |
5 | Ходьба | Бег | Каденс больше 1 |
6 | Бег | Ходьба | Каденс меньше или равен 1 |
Стоит отметить, что переходы из дочерних состояний (3-6) проверяются только в случае, если их родительское состояние остаётся активным (т. е. переходы 1 и 2 не выполняются).
Рассмотрим каждое состояние по отдельности.

При входе в Sleep
мы изменяем переменную activity
, указывающую текущее состояние. При каждой активации состояния обновляем счётчик времени, в течение которого оно активно – для этого вызываем ещё один темпоральный оператор elapsed()
. При выходе из состояния запоминаем его длительность, на случай если захочется посмотреть, как долго продолжался последний сон или тренировка.


Состояния Walk
и Run
отличаются от Sleep
лишь идентификатором, записываемым в переменную activity
.

В Rest
добавляем обработку уведомлений:
При входе в состояние переменная
notification_need_rest
принимает значениеfalse
;Если пользователь отдыхает больше разрешённого времени, переменная
notification_need_action
принимает значениеtrue
.
Подсчёт шагов будем вести в родительских состояниях.
При входе в Inaction
обнулим счётчик шагов:

А при активации состояния Action
будем обновлять счётчик вместе с общим количеством шагов за всё время симуляции:

В Action
также проводится ещё одна обработка уведомлений:
При входе в состояние переменная
notification_need_action
принимает значениеfalse
;Если пользователь идёт или бежит слишком долго, переменная
notification_need_rest
принимает значениеtrue
.
Вот что у нас получилось в итоге:

Несмотря на достаточно большое количество вложенных состояний и условий переходов между ними, алгоритм чётко структурирован. Если со временем функциональность фитнес-браслета захочется расширить (например, добавить подсчёт калорий), это можно будет сделать гораздо проще, чем при написании кода. Кроме того, для организации логики, привязанной ко времени, нам не пришлось писать свои таймеры.
Прежде чем запустить симуляцию
Соединим блоки human_simulator
и fitness_tracker
и подадим на них значения, определяющие деятельность пользователя, интенсивность его бега, пороговую частоту сердечных сокращений и временные задержки:

Мы уже сейчас могли бы запустить симуляцию и проанализировать результаты, но мне хочется добавить в модель немного интерактивности.
Так как мы планируем изменять поведение пользователя и настройки фитнес-браслета на лету, замедлим симуляцию и зададим её время окончания равным бесконечности:

Создадим в папке проекта интерактивный скрипт и назовём его fitness_tracker.ngscript
.
В интерактивные скрипты можно добавлять текстовые и кодовые ячейки. При желании кодовую ячейку можно маскировать – скрыть код, отобразив вместо него удобный графический интерфейс.
На эту тему можно написать отдельную статью, поэтому не буду на ней надолго останавливаться и покажу лишь небольшой пример:

При помощи специальных комментариев @markdown
и @param
можно описывать разметку на языке Markdown
и делать переменные интерактивными.
В результате у меня получился вот такой красивый интерфейс:

При изменении значения интерактивной переменной кодовая ячейка выполняется.
Значения настроек конечных автоматов будут изменяться при помощи программного управления – специальных функций, позволяющих взаимодействовать с моделью не из графического интерфейса, а через командную строку или запуск кодовой ячейки.
Например, для того, чтобы обновить в модели пороговое значение частоты сердечных сокращений, достаточно вызвать команду:
engee.set_param!("fitness_tracker/parameter_threshold_heart_rate", "Value" => threshold_heart_rate);
Также научим фитнес-браслет отправлять уведомления в командную строку Engee по UDP.
Добавим блок Engee Function
, позволяющий интегрировать в модель код на языке Julia, и подадим в него текущее состояние устройства и статусы уведомлений:
Разберёмся с его исходным кодом:
В ячейке Common code
подключим библиотеку Sockets
и создадим сокет.
В методе Step
напишем код, который будет выполняться на каждом шаге симуляции:
На основе входных сигналов и битовых операций формируем 8-битное сообщение;
Отправляем его на порт
50000
.
В методе Terminate
отправим по UDP признак завершения симуляции и закроем сокет.
Осталось написать скрипт, который будет принимать пакеты и отображать их в командной строке, назовём его run_interactive_simulation.jl
:
В нём мы асинхронно запускаем симуляцию, создаём сокет и привязываем его к порту 50000
, на который блок UDP_send
отправляет пакеты. В бесконечном цикле осуществляем приём, извлечение данных и их вывод в командное окно. Если принят признак завершения симуляции, выполнение скрипта завершается.
Анализируем результаты
Запустим скрипт, после чего начнём изменять текущий вид деятельности и интенсивность бега человека.
Это будет приводить к изменению его показателей активности:
Что, в свою очередь, повлияет на статус устройства:
Общее количество шагов, подсчитанное фитнес-браслетом, незначительно отличается от реального за счёт задержки в определении состояния пользователя:
Для того чтобы сделать демонстрацию более наглядной, я записал небольшое видео:
Модель фитнес-браслета корректно реагирует на изменение активности человека, а также имитирует подсчёт шагов и отправку уведомлений.
Заключение
Итак, мы ознакомились с визуальным подходом к проектированию управляющей логики на основе конечных автоматов и смоделировали работу фитнес-браслета. Также мы увидели, что использование современных и удобных инструментов не только влияет на эффективность разработки технических систем, но и повышает их наглядность, надёжность и масштабируемость.
Если у вас остались какие-то вопросы, буду рад ответить на них в комментариях.
До новых встреч!