DairyFeed · техническая спецификация

Архитектура, модель данных и логика учёта

Как система превращает показания весов и видеопоток в ответ на вопрос «сколько корма реально съели коровы и где потерялось остальное».

Версия 0.1 · черновик до выезда на объект 11 августа 2026 КХ «Шадиев»
01

Что система измеряет

Корм проходит четыре точки, и в каждой измеряется своим способом с разной точностью. Вся система построена вокруг того, чтобы эти четыре измерения свести и объяснить разницу между ними.

ТочкаЧто измеряетсяКакТочностьТип данных
Весовая Брутто на въезде, тара на выезде Индикатор весов по RS-232 + камера номера ±20 кг измерено
Склад Факт и длительность выгрузки, какой силос Камера зоны, детекция техники событие измерено
Измельчитель Вес партии, время работы, рацион Камера на табло, распознавание цифр ±1 цифра распознано
Зона раздачи · трактор Когда свалил корм кучами и уехал Камера на границе зоны, детекция входа и выхода факт, ±1 с измерено
Зона раздачи · человек Когда пришёл растаскивать кучи по лоткам и ушёл Тот же кадр, отдельный класс объекта факт, ±1 с измерено
Ключевое отличие от «просто дашборда». Три типа цифр не смешиваются нигде — ни в базе, ни в отчёте. Измеренное весами нельзя молча приравнять к распознанному камерой. Именно поэтому в каждой таблице есть поля source и confidence, а в интерфейсе — пометка источника.
02

Архитектура

Всё крутится на одном локальном сервере с GPU. Интернет на ферме ненадёжен, поэтому облако — только опциональная выгрузка отчётов, а не место, где живут данные.

ОБЪЕКТ СБОР ШИНА ЯДРО ВЫХОД Индикатор весов RS-232 → Ethernet Камера номера RTSP · въезд/выезд Камера табло RTSP · измельчитель Камера склада RTSP · выгрузка Камера зоны раздачи RTSP · кормовой стол scale-reader плато 3 сек → вес anpr-service номер + кадр display-ocr 7 сегментов + доверие vision-worker детекция техники, вход и выход из зоны, сшивание заездов GPU · батч по камерам MQTT брокер событий буфер при обрыве сети — на диске publish event-processor сшивает сырые события в рейсы, партии, кормления PostgreSQL + TimescaleDB факты и агрегаты Хранилище кадров фото номеров, табло ретенция 90 дней rules-engine пороги и аномалии sub insert путь к кадру проверка порогов Веб-интерфейс дашборд, рейсы, раздача, отчёты, подтверждения Telegram-бот тревоги зоотехнику Excel / PDF выгрузка за период REST push Всё слева от пунктира работает без интернета. Обрыв связи не теряет данные — коллекторы буферизуют на диск и досылают.
Один сервер, четыре независимых коллектора и одна шина. Каждый коллектор знает только про своё устройство и публикует сырое событие. Ядро ничего не знает про камеры и протоколы — оно работает с потоком событий. Поэтому замена OCR-камеры на RS-232 (или наоборот) не трогает бизнес-логику: меняется один коллектор.

Почему шина, а не прямые вызовы

Коллекторы падают и перезапускаются — камера отвалилась, кабель дёрнули. Шина развязывает их от ядра: события копятся и доходят позже, ничего не теряется.

Почему один сервер

14 камер, из них постоянного инференса требуют 3–4. Остальные работают по событию (техника в кадре). Одной GPU уровня RTX 4060 достаточно с запасом.

Почему не облако

Учёт не должен останавливаться из-за интернета. Локальный сервер — источник истины, наружу уходят только отчёты и тревоги.

03

Главный принцип данных

База делится на два уровня, и это единственное архитектурное решение, которое нельзя менять по ходу дела.

Сырой уровень

Только добавление, никогда изменение

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

Производный уровень

Пересчитывается из сырого

Рейсы, партии, кормления, суточный баланс. Если алгоритм сшивания улучшили — производные пересобираются из сырых событий заново, история не теряется.

Как обрабатываются правки оператора. Оператор не исправляет цифру в таблице. Он создаёт запись в corrections: что именно, старое значение, новое, причина, кто и когда. Производная сущность пересчитывается с учётом поправки, а исходное показание остаётся нетронутым. Без этого система бесполезна в споре «весы врут» — а именно ради таких споров её и ставят.

Из этого следует ещё одно правило: любая производная цифра хранит ссылки на источники. У рейса есть gross_weighing_id и tare_weighing_id — из карточки рейса можно провалиться в конкретное показание весов и увидеть кадр табло в момент измерения.

04

Схема базы данных

PostgreSQL. Сырые события — гипертаблицы TimescaleDB (партиционирование по времени, автосжатие старых данных). Ниже — только значимые поля; служебные created_at, updated_at опущены.

Справочники

vehiclesГрузовикисправочник
ПолеТипСмысл
iduuid pk
platetext uniqueНомер в нормализованном виде, без пробелов и регистра
reference_tare_kgintЭталонная тара. Расхождение с ней на выезде — сигнал, что в кузове что-то осталось
carriertextПеревозчик или «свой»
is_knownboolfalse — номер увиден впервые, машина заведена автоматически
devicesВесы и камерысправочник
ПолеТипСмысл
iduuid pk
kindenumtruck_scale · mill_scale · anpr_cam · pen_cam · yard_cam
driverenumrs232 · ocr · manual — меняется в настройках, не в коде
connectionjsonbХост, порт, RTSP-строка, параметры протокола
accuracy_kgintПаспортная погрешность — попадает в отчёт
last_seen_attimestamptzHeartbeat. Молчит 5 минут — тревога

Сырые события — append-only

raw_weighingsПоказания любых весовсырое
ПолеТипСмысл
idbigserial pk
device_idfk → devicesКакие весы
weight_kgnumericСтабилизированное значение, не мгновенное
measured_attimestamptzМомент выхода на плато
sourceenumrs232 · ocr · manual
confidencenumeric1.0 для RS-232, реальная уверенность для OCR
frame_pathtextКадр табло в момент измерения — доказательство
raw_payloadjsonbИсходная строка индикатора как пришла. Позволит переразобрать протокол задним числом
raw_plate_readsРаспознанные номерасырое
ПолеТипСмысл
idbigserial pk
device_idfk → devicesКамера въезда или выезда
plate_texttextКак прочиталось, до сопоставления со справочником
confidencenumericНиже 0.85 — строка помечается на проверку
directionenumin · out
captured_attimestamptz
frame_pathtextКадр с рамкой вокруг номера
raw_zone_eventsПересечения границы зоны раздачисырое
ПолеТипСмысл
idbigserial pk
device_idfk → devicesКамера, смотрящая на въезд в зону
actorenumtractor или person — класс объекта из детектора
edgeenumenter или exit — что именно увидела камера
machine_idtextКакая техника, если различаем
detected_attimestamptzМомент пересечения границы
confidencenumericУверенность детектора
frame_pathtextКадр на момент пересечения — доказательство для спора
clip_pathtextКлип для разбора спорных случаев

Производные сущности — пересобираются из сырых

truck_visitsРейс: заезд, выгрузка, выездпроизводное
ПолеТипСмысл
iduuid pk
vehicle_idfk → vehicles
entered_at / exited_attimestamptzВыезд пуст, пока рейс открыт
gross_kg / tare_kgnumericОба значения — ссылки на конкретные взвешивания
net_kgnumeric generatedgross − tare. Вычисляемое поле, руками не пишется
gross_weighing_idfk → raw_weighingsПровалиться к исходному показанию и кадру
tare_weighing_idfk → raw_weighings
in_plate_read_idfk → raw_plate_readsФото номера на въезде
out_plate_read_idfk → raw_plate_readsФото номера на выезде — сверка, что уехала та же машина
storage_idfk → storagesКуда выгрузили — с камеры склада
statusenumopen · closed · anomaly · manual
anomaly_flagstext[]Что не так: tare_mismatch, no_exit, plate_mismatch
mill_batchesПартии измельчителяпроизводное
ПолеТипСмысл
iduuid pk
started_at / ended_attimestamptzГраницы партии по показаниям табло
weight_kgnumericИтоговый вес партии
weighing_idfk → raw_weighingsФинальное показание, из которого взят вес
ration_idfk → rationsДля какой группы готовили
ocr_confidencenumericСредняя за партию
review_statusenumauto · needs_review · confirmed
confirmed_by / atfk, timestamptzКто подтвердил спорное распознавание
zone_visitsЗаезд техники в зону раздачипроизводное
ПолеТипСмысл
iduuid pk
actorenumКто был в зоне: трактор или человек
machine_idtextДля трактора — какая единица техники
entered_attimestamptzВход в зону — из события enter
left_attimestamptzВыход. NULL — техника ещё в зоне
duration_sint generatedДлительность заезда. По ней ловится «уехал слишком быстро»
enter_event_id / exit_event_idbigintСсылки на сырые события — чтобы поднять кадр
statusenumopen пока не зафиксирован выход
Килограммов здесь нет и не будет. Весов между измельчителем и кормовым столом не стоит, а пересчитывать ковши или время работы в массу — значит выдавать догадку с погрешностью в тонны за измерение. Первый же спор с зоотехником такую цифру развалит и утащит за собой доверие ко всему отчёту. Система отвечает на то, что может доказать кадром: выезжала техника кормить или нет, во сколько и сколько раз.
daily_balanceСуточный баланс — материализованная сводкапроизводное
ПолеТипСмысл
datedate pk
brought_kgnumericСумма net_kg закрытых рейсов
milled_kgnumericСумма партий
mass_loss_kgnumeric generatedГлавная цифра по массе. brought_kg − milled_kg — обе величины измерены
zone_visitsintЗаездов техники в зону раздачи за сутки
zone_minutesintСуммарное время техники в зоне
short_visitsintЗаездов короче медианы — кандидаты на неполную раздачу
visits_normintОбычное число заездов по истории фермы
correctionsРучные правкиаудит
ПолеТипСмысл
entity_type / entity_idtext, uuidЧто правим
fieldtextКакое поле
old_value / new_valuejsonbБыло и стало
reasontextОбязательно. Без причины правка не сохраняется
author_idfk → usersКто
created_attimestamptzКогда

Также: alerts (тревоги и подтверждения), storages (силосы), rations (рационы), users, settings (пороги тревог).

05

Как рождается рейс

Самая нетривиальная часть системы. Никто не нажимает кнопку «начать рейс» — система сама понимает, что четыре разрозненных события относятся к одной машине.

Камера номера Весы (RS-232) event-processor truck_visits 08:15:02 08:15:04 08:42:11 08:42:13 plate_read 123ABC05 · 0.98 weighing 18 720 кг · плато окно сопоставления ±10 с одно устройство · direction=in visit · status = open gross=18 720 · tare=null · net=null insert рейс висит открытым · таймаут 6 ч weighing 6 350 кг · плато plate_read 123ABC05 · 0.96 поиск открытого рейса тот же vehicle_id · direction=out visit · status = closed net = 18 720 − 6 350 = 12 370 кг update в баланс
Рейс собирается из четырёх независимых событий по совпадению времени и номера. Ни камера, ни весы не знают друг о друге — связь возникает только в процессоре. Если одно из четырёх событий не пришло, рейс не закрывается и попадает в тревоги, а не тихо теряется.

Три алгоритма, от которых зависит всё

1 Стабилизация веса

Индикатор шлёт значение непрерывно, и в момент заезда оно скачет. Брать первое пришедшее нельзя. Коллектор ждёт, пока показание не перестанет меняться больше чем на 10 кг в течение 3 секунд — и только тогда публикует событие. То же для OCR: пока цифры на табло «пляшут», партия не фиксируется.

plateau(window=3s, tolerance=10kg) → publish weighing
2 Сопоставление номера со справочником

OCR путает похожие символы — 0 и O, B и 8. Прочитанный текст нормализуется и ищется в справочнике с допуском в один символ. Не нашлось — машина заводится автоматически с флагом is_known = false, оператор потом решает, новая это машина или ошибка распознавания.

normalize(text) → levenshtein ≤ 1 против vehicles.plate
3 Сшивание в рейс

Взвешивание и распознавание номера считаются одним событием, если они пришли с устройств одной точки в пределах 10 секунд. Дальше: direction = in открывает рейс, direction = out ищет открытый рейс той же машины и закрывает его. Если открытых рейсов у машины два — это аномалия, оба уходят на разбор.

match(weighing, plate_read) where |Δt| ≤ 10s and same_gate
06

Состояния и аномалии

Рейс проходит через конечный набор состояний. Ни одно из них не является тупиком без оператора — любая аномалия либо разрешается автоматически, либо ждёт человека, но не исчезает.

open машина на территории closed нетто посчитан anomaly в очереди на разбор closed_manual с записью в corrections второе взвешивание, номер совпал нет выезда 6 ч тара разошлась с эталоном > 200 кг или номер на выезде не совпал со въездом оператор разобрал данные догрузились В суточный баланс попадают только closed и closed_manual. Всё, что в anomaly, показано в отчёте отдельной строкой «не учтено».
Аномалия — это состояние, а не ошибка записи. Незакрытый рейс не портит баланс и не исчезает: он висит в очереди на разбор и виден в отчёте отдельно. Так цифра «привезено за сутки» всегда означает ровно то, что означает.
ФлагКогда ставитсяЧто делает система
no_exitМашина въехала, но не выехала за 6 часовТревога оператору, рейс не идёт в баланс
tare_mismatchТара отличается от эталонной больше чем на 200 кгТревога: возможно, часть груза осталась в кузове
plate_mismatchНа выезде распознан другой номерОба кадра рядом, оператор подтверждает или разделяет рейсы
low_confidenceУверенность распознавания ниже 0,85Запись помечается, но учитывается — оператор проверяет постфактум
double_openУ машины два открытых рейсаОба на разбор — обычно значит пропущенный выезд
device_silentУстройство молчит больше 5 минутТревога технику, в отчёте — пометка о неполноте данных за период
07

Измельчитель и раздача

Партия на измельчителе

Здесь нет события «начать партию» — его надо вывести из показаний табло. Логика такая же по духу, как со стабилизацией веса, но на другом масштабе времени.

  1. Начало партии — вес на табло пошёл вверх от нуля и держит рост дольше 30 секунд.
  2. Накопление — OCR читает табло раз в секунду, значения пишутся в raw_weighings с уверенностью каждого кадра.
  3. Конец партии — вес перестал расти и держится стабильным 2 минуты, либо табло обнулилось.
  4. Итоговый вес — максимальное стабильное значение за партию, а не последнее прочитанное.
  5. Проверка доверия — если средняя уверенность за партию ниже 80 %, партия получает review_status = needs_review и всплывает у оператора с кадрами табло.
Семисегментное табло — не обычный OCR. Готовые движки на нём работают плохо: блики, угол, ночная подсветка, «восьмёрка» вместо «нуля» при выгорании сегмента. Нужна отдельная небольшая модель, обученная на кадрах именно этого табло. Это отдельная задача на 1–2 недели, и первые пару недель эксплуатации оператор будет подтверждать спорные партии вручную — на этих подтверждениях модель и дообучится.

Раздача корма

Единственный узел цепочки, где счёт идёт не в килограммах, а в событиях — и это сделано намеренно. Важно, что операций здесь две, и вторая не менее существенна.

  1. Граница зоны — на кадре с камеры мышью обводится въезд в зону раздачи. Настройка на объекте за десять минут, кода не требует.
  2. Два класса объектов — детектор различает трактор и человека. Каждое пересечение границы пишется в raw_zone_events с полем actor и кадром.
  3. Сшивание визита — пара enter + exit одного actor превращается в строку zone_visits. Вход без выхода оставляет визит открытым и уходит в тревоги.
  4. Сопоставление операций — за каждой выгрузкой трактора должен последовать выход человека не позже spread_lag_minutes. Представление v_unspread_drops возвращает кучи, до которых никто не дошёл.
  5. Норма — медиана числа выгрузок, выходов и длительностей за 30 дней. Ферма сама задаёт себе эталон.
Трактор выгрузил — это ещё не кормление. Он сваливает корм кучами вдоль стола, и коровы у дальних лотков до такой кучи попросту не дотягиваются. Кормление состоялось только после того, как человек растащил кучи по лоткам. Система, которая считает раздачей один заезд трактора, будет бодро рапортовать об успехе в тот самый день, когда часть стада осталась голодной.
Почему здесь нет килограммов. Весов между измельчителем и кормовым столом не стоит, и ставить их ради учёта дорого. Соблазн посчитать массу косвенно — по ковшам, по времени работы, по объёму — есть всегда, но любая такая цифра остаётся догадкой с погрешностью в тонны. Выданная за измерение, она разваливается на первом же споре с зоотехником. Поэтому система отвечает на то, что подтверждается кадром: кто был в зоне, когда и сколько раз.
08

Ночной пересчёт баланса

В 23:59 фоновая задача собирает суточный баланс. Это единственное место, где три измерения встречаются в одной формуле — и главная ценность всей системы.

-- 1. что реально заехало (только закрытые рейсы)
brought   = Σ truck_visits.net_kg WHERE status IN ('closed', 'closed_manual')

-- 2. что приготовили
milled    = Σ mill_batches.weight_kg

-- 3. главная цифра по массе: обе величины измерены
mass_loss = brought − milled

-- 4. что происходило на раздаче — событиями, не массой
drops     = count(zone_visits WHERE actor = 'tractor')
shifts    = count(zone_visits WHERE actor = 'person')

-- 5. главный признак сбоя: куча, до которой никто не дошёл
unspread  = count(v_unspread_drops WHERE spread_visit_id IS NULL)

На примере из прототипа: привезли 69,85 т, измельчили 67,90 т — потеря по массе 1,95 т, это 2,8 % и обычный уровень для выгрузки и хранения. Дальше масса не считается вовсе. За сутки трактор сделал 11 выгрузок вместо обычных 12, человек выходил разравнивать 5 раз вместо 6, и последнюю выгрузку в 17:01 никто не разровнял — корм остался кучей. Это и есть событие дня, и оно подтверждается кадрами, а не арифметикой.

Как читать эти цифры на практике. Один день с большим mass_loss — почти всегда сбой распознавания. Неделя подряд — уже разговор с людьми. Именно поэтому цифра хранится по дням и выводится трендом, а не разовым числом: смысл появляется в динамике.
09

Стек и железо

СлойЧем делаемПочему
Сбор с камерPython, GStreamer / OpenCV, RTSPСтандарт для видеопотоков, стабильно держит переподключения
Модели CVYOLO для детекции, отдельная модель под семисегментное табло, ANPR-модель под местные номераГотовые ANPR плохо работают на казахстанских номерах — нужен дообученный вариант
Чтение весовpyserial поверх Ethernet-serial конвертераКонвертер вместо длинного COM-кабеля — меньше точек отказа
ШинаMQTT (Mosquitto)Лёгкий, переживает обрывы, буферизует на диск
Ядро и APIPython, FastAPI, SQLAlchemy, AlembicТот же язык, что и CV — одна команда, один стек
БазаPostgreSQL 16 + TimescaleDBСырые события — это временной ряд: автопартиционирование и сжатие из коробки
КадрыЛокальный диск или MinIO, ретенция 90 днейКадры тяжёлые; спорные записи хранятся дольше по флагу
ИнтерфейсВеб, тот что в прототипеРаботает с любого устройства в локальной сети, ничего не устанавливать
ОповещенияTelegram Bot APIВсе уже в Telegram, отдельное приложение никто ставить не будет
РазвёртываниеDocker Compose на одном сервереОбновление и откат одной командой, без администратора на ферме

Железо на площадке

Сервер

GPU уровня RTX 4060 (12 ГБ), 32 ГБ ОЗУ, 2 ТБ SSD под кадры, ИБП обязательно. Ставится в помещение с розеткой и хоть каким-то охлаждением.

Камеры

Въезд и выезд — с ИК-подсветкой и коротким затвором, иначе ночью номер смажет. Табло измельчителя — жёстко зафиксированная, с блендой от бликов. Зона раздачи — обзорная камера, без особых требований.

Сеть

PoE-коммутаторы, витая пара до камер. Wi-Fi для видеопотоков не годится — будут разрывы и потерянные события.

Весовая

Ethernet-serial конвертер у индикатора (~50–100 $) или мини-ПК в будке. Нужна розетка 220 В рядом с индикатором.

10

Что решится только на объекте

Всё выше спроектировано так, чтобы пережить любой ответ на эти вопросы. Но пока ответов нет, часть цифр в смете будет с диапазоном.

ВопросНа что влияетКак страхуемся
Марка и протокол индикатора весов Сроки на драйвер: известный протокол — день, экзотика — неделя Сырая строка индикатора пишется в raw_payload, протокол можно переразобрать задним числом
Тип табло измельчителя Семисегментное LED — решаемо; стрелочное — придётся ставить весы отдельно Драйвер узла меняется в настройках, ядро не трогается
Направление корма Знак в формуле нетто Хранятся оба веса, знак вычисляется при отчёте
Границы зоны раздачи Где на кадре проходит линия входа и выхода Рисуется мышью в настройках по кадру с камеры, кода не требует
Главная боль заказчика Куда вкладывать 80 % усилий: в доказательность или в аналитику Ядро одинаковое; различается объём работ по фотофиксации и отчётам
Что можно начинать без ответов. Схема базы, шина, event-processor, веб-интерфейс и логика сшивания рейсов не зависят ни от одного из этих вопросов. Это примерно 60 % работы, и её можно вести параллельно с выездом на объект.