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

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

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

Версия 0.1 · черновик до выезда на объект 11 августа 2026 Ферма «Алтын-Сут», Алматинская обл.
01

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

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

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

Архитектура

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

ОБЪЕКТ СБОР ШИНА ЯДРО ВЫХОД Индикатор весов RS-232 → Ethernet Камера номера RTSP · въезд/выезд Камера табло RTSP · измельчитель Камера склада RTSP · выгрузка Камеры локов ×12 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 — номер увиден впервые, машина заведена автоматически
pensЛоки — места кормлениясправочник
ПолеТипСмысл
idint pk
nametext«Лок №7»
head_countintГолов сейчас. Меняется — ведём историю отдельной таблицей
ration_idfk → rationsКакой рацион положен этой группе
kg_per_headnumericНорма на голову в сутки — из неё считается план
schedulejsonbПлановое время кормлений, например ["06:30","14:00"]
camera_zone_idfk → devicesКакая камера смотрит на этот лок
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_bucket_eventsКовши экскаваторасырое
ПолеТипСмысл
idbigserial pk
device_idfk → devicesКамера лока
pen_idfk → pensЛок, определённый по зоне камеры
machine_idtextКакая техника — если различаем
detected_attimestamptzМомент опрокидывания ковша
confidencenumericУверенность детектора
clip_pathtext3-секундный клип — для разбора спорных случаев

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

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Кто подтвердил спорное распознавание
feedingsРаздача корма в локпроизводное
ПолеТипСмысл
iduuid pk
pen_idfk → pens
batch_idfk → mill_batchesИз какой партии кормили — по времени
started_at / ended_attimestamptzПервый и последний ковш
bucket_countintСколько ковшей насчитали камеры
estimated_kgnumericbucket_count × bucket_capacity_kg
planned_kgnumericСнимок плана на момент кормления — план может меняться
deviation_pctnumeric generatedОтклонение от плана. По нему срабатывают тревоги
delay_minintОпоздание от графика лока
daily_balanceСуточный баланс — материализованная сводкапроизводное
ПолеТипСмысл
datedate pk
brought_kgnumericСумма net_kg закрытых рейсов
milled_kgnumericСумма партий
delivered_kgnumericСумма кормлений
planned_kgnumericСумма планов по локам
explained_loss_kgnumericНедокорм и пропуски локов — объяснимая часть разрыва
unexplained_kgnumericГлавная цифра отчёта. Всё, что не объясняется
correctionsРучные правкиаудит
ПолеТипСмысл
entity_type / entity_idtext, uuidЧто правим
fieldtextКакое поле
old_value / new_valuejsonbБыло и стало
reasontextОбязательно. Без причины правка не сохраняется
author_idfk → usersКто
created_attimestamptzКогда

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

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_bucket_events с трёхсекундным клипом.
  3. Пересчёт в килограммыbucket_count × bucket_capacity_kg. Ёмкость ковша калибруется в первые недели: сравниваем сумму по локам с весом партии измельчителя и подгоняем коэффициент.
  4. Сверка с планом — план берётся из pens.kg_per_head × head_count на момент кормления.
  5. Тревоги — отклонение больше 15 %, либо лок не посещался спустя 45 минут после планового времени.
Почему оценка по ковшам — это нормально. Точность ±10 % на этом узле достаточна, потому что задача здесь не «взвесить», а обнаружить пропуск и грубый недокорм. Разницу между 5 480 и 5 520 кг никто искать не будет; разницу между 5 520 и нулём — будут. Если позже понадобится точность, ставятся весы на ковш экскаватора и меняется только драйвер в devices.
08

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

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

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

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

-- 3. что вынесли на кормовой стол
delivered = Σ feedings.estimated_kg
planned   = Σ feedings.planned_kg

-- 4. объяснимая часть разрыва: пропуски и недокормы
explained = Σ (planned_kg − estimated_kg) WHERE deviation_pct < -15

-- 5. главная цифра отчёта
unexplained = milled − delivered − explained

На примере из прототипа: измельчили 67,90 т, вынесли 59,11 т, разрыв 8,79 т. Из него 7,65 т — это непокормленный лок №12 и недокорм лока №7. Остаётся 1,14 т необъяснённого — при погрешности оценки ковшей ±10 % это шум, а не пропажа.

Как читать эту цифру на практике. Один день с большим unexplained — почти всегда сбой распознавания. Неделя подряд — уже разговор с людьми. Именно поэтому цифра хранится по дням и выводится трендом, а не разовым числом: смысл появляется в динамике.
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 — решаемо; стрелочное — придётся ставить весы отдельно Драйвер узла меняется в настройках, ядро не трогается
Направление корма Знак в формуле нетто Хранятся оба веса, знак вычисляется при отчёте
Реальное число локов и голов Количество камер и стоимость железа Локи — строки в справочнике, добавляются без разработки
Ёмкость ковша экскаватора Точность оценки раздачи Коэффициент калибруется в первые 2 недели по сверке с партиями измельчителя
Главная боль заказчика Куда вкладывать 80 % усилий: в доказательность или в аналитику Ядро одинаковое; различается объём работ по фотофиксации и отчётам
Что можно начинать без ответов. Схема базы, шина, event-processor, веб-интерфейс и логика сшивания рейсов не зависят ни от одного из этих вопросов. Это примерно 60 % работы, и её можно вести параллельно с выездом на объект.