🚀 Введение
Я работаю системным аналитиком в компании «Цифра» (Zyfra). Наш продукт «Диспетчер» — автоматизированная информационная система уровня MDC (Machine Data Collection): изначально она мониторила станки с ЧПУ, а сейчас мы развиваем мониторинг производственных линий. «Диспетчер» собирает данные более чем по 50 протоколам — от УЧПУ и ПЛК до датчиков и измерительных приборов, считает показатели и рисует мнемосхемы и графики для производственного персонала. Интерфейс подключения линии может быть любым; у линии, которую мы эмулируем, это Modbus TCP. Мы начали развивать мониторинг линий розлива, и почти сразу упёрлись в простую вещь: для проверки формул, сценариев и интерфейсов нужна живая линия, а её в тест не привезёшь.
Так родился barselona — эмулятор линии розлива на Go. Он разговаривает с «Диспетчером» по Modbus TCP как настоящий ПЛК и позволяет гонять любые сценарии: производство на разной скорости, простои, брак по типам, систематический брак, ошибки СВА, контроль весов. Всё детерминированно и воспроизводимо.
Ключевое решение — эмулировать ПЛК, а не продукт. Эмулятор отдаёт «сырые» теги: счётчик оптического датчика, события СВА, состояния линии, веса ёмкостей. А все производные показатели — годная продукция, брак по типам, систематический брак, производительность, простой — считает сам «Диспетчер» по своим формулам. Так мы тестируем настоящую логику продукта, а не её копию в эмуляторе.
🏗️ Архитектура
Проект — три пакета Go, каждый отвечает за своё:
flowchart LR
SIM["internal/sim\nдвижок линии"]
TUI["internal/tui\nдашборд (Bubble Tea)"]
PLC["internal/plc\nModbus TCP :502"]
DISP["Сервер «Диспетчер»\n(MDC)"]
SIM -->|"Snapshot()\nкаждый тик"| PLC
SIM -->|"тик 1 с"| TUI
PLC <-->|"FC01/02/03/05/0F"| DISP
DISP -->|"уставки, команды"| PLC
internal/sim— движок: конечный автомат линии, счётчик розлива, генератор событий СВА, весы. Тикает раз в секунду (sim.tick), симулирует физику и ведёт журнал.internal/plc— Modbus TCP-сервер наsimonvetter/modbus: отдаёт снимок состояния как регистры и принимает команды и уставки от «Диспетчера».internal/tui— дашборд на Bubble Tea: мнемосхема, счётчики, весы, журнал событий. Управление линией с клавиатуры.
Связка простая: движок каждый тик собирает Snapshot() под мьютексом, а Modbus-хендлеры и TUI читают копию. Никакой общей таблицы регистров нет — coils хранятся как флаги, а остальное вычисляется на лету из снимка на каждый запрос.
func (e *Engine) Tick() {
e.mu.Lock(); defer e.mu.Unlock()
e.tickNum++
grew := e.fill.Tick(e.line.FillAllowed())
e.counters.Rozliv = e.fill.Counter()
e.line.Tick(grew > 0, e.cfg.Line.IdleTimeout)
if e.sorterScalesTicks > 0 { e.sorterScalesTicks-- }
if e.sorterCvaTicks > 0 { e.sorterCvaTicks-- }
for i := 0; i < grew; i++ { e.produceBottle() }
e.updateCvaError()
e.pushFillHist()
}
Бонус детерминизма: все три генератора случайных чисел засеяны фиксированными сидами (0xcafe, 0x5eed, 0xbeef). Один и тот же сценарий даёт одну и ту же последовательность событий — критично, когда ищешь баг по логам «Диспетчера».
🔌 Modbus как интерфейс
Modbus — лишь один из 50+ протоколов, которые понимает «Диспетчер», и в линиях интерфейсы бывают разные. Но ПЛК нашей линии общается по Modbus TCP, значит и эмулятор должен. Используем github.com/simonvetter/modbus и реализуем все нужные функции кода:
| Функция | Код | Что |
|---|---|---|
| FC01/05/0F | Coils | команды «Диспетчера»: CmdStop, CmdDefect, CmdReset |
| FC02 | Discrete inputs | состояния: линия включена, конвейер, производство, простой, остановки, СВА, изолятор |
| FC04 | Input registers | «сырые» данные (зеркалируются в holding) |
| FC03/06/10 | Holding registers | уставки сервера + «сырые» данные |
Пара слов про совместимость
«Диспетчер» опрашивает только holding registers (FC03) — входные регистры (FC04) он не читает. Поэтому «сырые» данные дублируются: адреса 7–16 в holding регистрах — зеркала input-регистров. Читаются они через ту же функцию:
case HR_LineState, HR_ScalesStatus, HR_LastWeight, HR_ProductCodeFact,
HR_CvaProd, HR_CvaDefect, HR_CvaKod, HR_CvaDate, HR_Rozliv, HR_Rozliv + 1:
res[i] = s.ir(req.Addr+uint16(i)-HR_LineState+IR_LineState, snap)
u32 (счётчик «Розлив», код номенклатуры) раскладывается на два регистра в big-endian — старший регистр первым. Unit id приходит в каждом запросе, эмулятор отвечает на любой — ПЛК с slave id на линии всё равно один.
Ещё одна деталь совместимости: пока линия выключена, событийные и измерительные теги читаются как 0 (чтобы «Диспетчер» не считал мусор), а LineState и счётчик «Розлив» сохраняют значения.
⚙️ Конечный автомат линии
Состояние линии — LineState (0–7). Переходы описывает простой конечный автомат:
| 0 | выкл |
|---|---|
| 1 | вкл |
| 2 | запущена |
| 3 | производство |
| 4 | простой |
| 5 | стоп-брак |
| 6 | стоп-код |
| 7 | стоп-дата |
Запуск — лесенкой: вкл → конвейер → первая выработанная ёмкость переводит в «производство». Стоп-состояния (5–7) приходят из пары «команда + причина».
Стоп — это флаг и причина
Останов линии — не один регистр, а пара: катушка CmdStop (флаг: хранит true, пока не сбросят) + StopReason в holding регистре. Причины: 1 брак, 2 код, 3 дата, 4 ручная. Readout катушки — это фактическое состояние останова:
res[CO_CmdStop] = s.coils[CO_CmdStop] || inStop(snap.State)
Выход из стопа — только фронтом CmdReset (или Enter в TUI): линия сбрасывает флаг и возвращается в StateOn, при этом конвейер сохраняется — линия сама возобновляется.
case StateStopDefect, StateStopCode, StateStopDate:
if !l.lineOn { l.conveyor = false; l.state = StateOff }
else if reset { l.cmdStop = false; l.stopReason = StopNone; l.state = StateOn }
Простой — два пути: сняли конвейер (контроль весов) или нет роста счётчика idleTimeout тиков (по умолчанию 180 = 3 минуты).
🧴 Розлив
Счётчик «Розлив» — это сырой счётчик оптического датчика: растёт целыми, а дробные бутылки копятся в аккумуляторе. Скорость задаётся в бутылках/час, за тик 1 с получаем speed / 3600 бутылки:
func (f *Fill) Tick(growthAllowed bool) int {
if !growthAllowed || f.speedBph <= 0 { return 0 }
acc := f.accumulator + float64(f.speedBph)/3600.0
bottles := int(acc)
if bottles > 0 { acc -= float64(bottles); f.counter += uint32(bottles) }
f.accumulator = acc
return bottles
}
При 3600 бут/ч — ровно одна бутылка за тик; при 1800 — бутылка каждый второй тик; при 7200 — две за тик. Рост разрешён только в состояниях «запущена» и «производство» — на стопе счётчик заморожен. Скорость меняется +/- на 10% от текущей.
📷 События СВА
Система видеоаналитики (СВА) на реальной линии фиксирует дефекты. Эмулятор генерирует событие на каждую бутылку — с вероятностями из конфига:
| Параметр | Значение | Что |
|---|---|---|
probNoCap | 0.005 | крышка отсутствует / недокручена |
probCrookedLabel | 0.004 | этикетка криво |
probNoLabel | 0.002 | нет этикетки |
probKodFail | 0.01 | штрихкод не расшифрован |
probDateNo | 0.005 | дата не расшифрована |
probDateWrong | 0.003 | дата неверная |
Событие — это набор полей: prod (1 годная / 2 брак), defect (тип дефекта), kod (расшифрован/нет), date (верная/нет). По ТЗ любой дефект или сбой кода/даты ⇒ prod=2:
ev.Defect = DefectNone
switch {
case c.roll(pCap): ev.Defect = DefectCap
case c.roll(pCrok): ev.Defect = DefectCrok
case c.roll(pNoLabel): ev.Defect = DefectNoLabel
}
if c.roll(pKod) { ev.Kod = 0; ev.ProductCode = 0 } else { ev.Kod = 1; ev.ProductCode = uint16(product.Code) }
switch {
case c.roll(pDateNo): ev.Date = 0
case c.roll(pDateWrong): ev.Date = 2
default: ev.Date = 1
}
if ev.Defect != DefectNone || ev.Kod == 0 || ev.Date != 1 { ev.Prod = 2 } else { ev.Prod = 1 }
roll(p) = rng.Float64() < p. Дефектная бутылка включает изолятор брака (весы или СВА) на sorterHoldTicks (2 такта) — в «Диспетчере» это видно как активный изолятор на мнемосхеме. Если производство идёт, но события не приходят cvaFailTimeout тиков (10 с) — поднимается CvaError: так проверяется сценарий «СВА отвалилась».
⚖️ Весы
Взвешивание — нормальное распределение вокруг номинала: weight = nominal + rng.NormFloat64()*sigma, σ = 2 г по умолчанию. Если вес вышел за допуск (|w − nominal| > tolerance) — ёмкость отклоняется, счётчик брака по весам растёт, изолятор весов включается.
Есть отдельный сценарий контрольного взвешивания — технологи периодически проверяют весы эталоном. Оно считается с половиной сигмы (σ/2) и не увеличивает счётчик взвешиваний — это проверяет отдельный тест, чтобы эмулятор не сбивал статистику продукта.
Контроль весов может быть и причиной простоя: WeighControl снимает конвейер, и линия уходит в StateIdle — в «Диспетчере» появляется причина простоя «Контроль весов».
🔥 Системный брак
Единственная по-настоящему «умная» часть эмулятора — детектор систематического брака. На реальной линии серия одинаковых дефектов подряд означает не случайный сбой, а разъехавшуюся настройку — и линию надо остановить. Эмулятор делает это сам:
func (e *Engine) trackSystemic(ev CvaEvent) {
n := e.cfg.CVA.SystemicStreak
if n <= 0 { return }
t := systemicType(ev)
if t == "" { e.streakType = ""; e.streak = 0; return }
if t == e.streakType { e.streak++ } else { e.streakType = t; e.streak = 1 }
if e.streak != n || e.line.StopState() >= 0 { return }
e.line.SetStopCommand(systemicReason(e.streakType))
e.logEvent(EvLine, "Системный брак: остановка линии (причина: %s)", systemicReason(e.streakType))
}
Серия из systemicStreak (по умолчанию 5) одинаковых дефектных событий — одного вида дефекта, либо сбой кода, либо даты — сама жмёт «стоп» с правильной причиной: дефект → StopBrake, код → StopCode, дата → StopDate. Любое годное событие сбрасывает серию. systemicStreak=0 выключает детектор.
Это важный момент с точки зрения архитектуры теста: доменная логика живёт в эмуляторе, а «Диспетчер» только считает и показывает. Так мы проверяем, что продукт корректно реагирует на остановку по системному браку — включая DI, состояние линии и причину останова.
🖥️ TUI: дашборд, который видно с метра
Даже сервис для тестирования хочется открывать не только глазами тестов. Дашборд написан на Bubble Tea (фреймворк терминальных приложений на Go) и Lipgloss. На экране:
- Мнемосхема — ASCII-схема линии:
М1 ТАРА → М2 ПОЗИЦИОНИРОВАНИЕ → М3 РОЗЛИВ [счётчик] → М4 ВЕСЫ → М5 ЭТИКЕТКА → КАМЕРЫ СВА → ГОДНАЯ [счётчик]. Под весами и этикеткой — ветка «изолятор брака», активная подсвечивается красным. Цвета по состоянию: зелёный в производстве, красный при отклонении весов/ошибке СВА, жёлтый при выключенной СВА. - Параметры и счётчики — состояние, скорость, причина простоя, годные/брак по типам.
- Весы — вертикальный бар-чарт последних 60 взвешиваний (
▁▂▃▄▅▆▇█), вне допуска — красные столбцы. - Журнал событий — кольцевой буфер на 50 записей с цветом по типу события.
- Статусбар — такт, часы, счётчик Modbus-запросов и активность клиента («Диспетчер» опрашивает?).
Управление — с клавиатуры в русской раскладке (в скобках латинский аналог): Space вкл/выкл линию, Enter производство/возобновление, 1–9 продукт, +/- скорость, б/d меню «Новый брак» (7 типов), и/i интенсивность брака, ц/w контроль весов, а/f СВА вкл/выкл, с/c сброс счётчиков. Всё для того, чтобы руками прогнать сценарий и посмотреть, как его увидит «Диспетчер».
🧪 Тесты и бенчмарки
go test ./... — успешно, 33 теста и 10 бенчмарков:
internal/sim(22 теста) — переходы конечного автомата и простой по таймауту; рост счётчика розлива (скорость, дробные бутылки, заморозка на стопе); генерация СВА с вероятностью 1 (форсирует каждый дефект + поля kod/date/prod); весы (допуск, контрольное взвешивание без счётчика); движок (инжекты, изоляторы, интенсивность, производительность ~3600±10, сброс счётчиков, ошибка СВА, системный авт-стоп).internal/plc(5 интеграционных) — реальный Modbus TCP-клиент против запущенного сервера: чтение сырых данных, стоп по коду с проверкой флага, зеркала HR==IR и игнор записи в зеркала, обнуление событийных тегов при выключенной линии, запись уставок.internal/tui(6 тестов) — рендер вьюх, вёрстка на коротком окне, шапка графика весов.
Бенчмарки покрывают горячий путь: тик линии, генерация СВА, снимок состояния и полный TCP-раундтрип чтения регистров.
Детерминированные сиды — это ещё и способ тестировать «Диспетчер»: один сценарий, воспроизводимые логи, сравниваемые датасеты.
🧰 Стек
| Компонент | Роль |
|---|---|
| Go 1.26 | язык, одна статика на три подсистемы |
simonvetter/modbus v1.6.4 | Modbus TCP-сервер (FC01/02/03/04/05/0F) |
| Bubble Tea v1.3 + Lipgloss v2 | TUI-дашборд |
gopkg.in/yaml.v3 | конфиг (вероятности, такт, продукты) |
modbus_custom_params.xml | шаблон импорта параметров в «Диспетчер» |
Проект — один бинарник: go build -o bin/line-emulator.exe ./cmd/line-emulator. Два режима: --tui (дашборд + Modbus) и --no-tui (только сервер).
💭 Заключение
Что вынес из проекта:
- Эмулируй ПЛК, а не продукт. Эмулятор отдаёт сырые теги, «Диспетчер» считает KPI по своим формулам. Тестируется настоящая логика, а не «как мы думаем, она работает».
- Доменная логика в эмуляторе — это фича. Систематический брак детектирует эмулятор, потому что именно он моделирует поведение линии. Продукт лишь реагирует — и это ровно то, что мы проверяем.
- Детерминизм окупается. Фиксированные сиды превращают стохастику в воспроизводимый сценарий для отладки «Диспетчера».
Эмулятор закрыл главное: теперь мониторинг линий розлива можно разрабатывать и тестировать без завода — на столе, детерминированно и по всем сценариям. А если кто-то спросит, как выглядит линия розлива — просто покажем мнемосхему.
