🚀 Введение

Я работаю системным аналитиком в компании «Цифра» (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
  1. internal/sim — движок: конечный автомат линии, счётчик розлива, генератор событий СВА, весы. Тикает раз в секунду (sim.tick), симулирует физику и ведёт журнал.
  2. internal/plc — Modbus TCP-сервер на simonvetter/modbus: отдаёт снимок состояния как регистры и принимает команды и уставки от «Диспетчера».
  3. 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/0FCoilsкоманды «Диспетчера»: CmdStop, CmdDefect, CmdReset
FC02Discrete inputsсостояния: линия включена, конвейер, производство, простой, остановки, СВА, изолятор
FC04Input registers«сырые» данные (зеркалируются в holding)
FC03/06/10Holding 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% от текущей.


📷 События СВА

Система видеоаналитики (СВА) на реальной линии фиксирует дефекты. Эмулятор генерирует событие на каждую бутылку — с вероятностями из конфига:

ПараметрЗначениеЧто
probNoCap0.005крышка отсутствует / недокручена
probCrookedLabel0.004этикетка криво
probNoLabel0.002нет этикетки
probKodFail0.01штрихкод не расшифрован
probDateNo0.005дата не расшифрована
probDateWrong0.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.4Modbus TCP-сервер (FC01/02/03/04/05/0F)
Bubble Tea v1.3 + Lipgloss v2TUI-дашборд
gopkg.in/yaml.v3конфиг (вероятности, такт, продукты)
modbus_custom_params.xmlшаблон импорта параметров в «Диспетчер»

Проект — один бинарник: go build -o bin/line-emulator.exe ./cmd/line-emulator. Два режима: --tui (дашборд + Modbus) и --no-tui (только сервер).


💭 Заключение

Что вынес из проекта:

  • Эмулируй ПЛК, а не продукт. Эмулятор отдаёт сырые теги, «Диспетчер» считает KPI по своим формулам. Тестируется настоящая логика, а не «как мы думаем, она работает».
  • Доменная логика в эмуляторе — это фича. Систематический брак детектирует эмулятор, потому что именно он моделирует поведение линии. Продукт лишь реагирует — и это ровно то, что мы проверяем.
  • Детерминизм окупается. Фиксированные сиды превращают стохастику в воспроизводимый сценарий для отладки «Диспетчера».

Эмулятор закрыл главное: теперь мониторинг линий розлива можно разрабатывать и тестировать без завода — на столе, детерминированно и по всем сценариям. А если кто-то спросит, как выглядит линия розлива — просто покажем мнемосхему.