В Ташкенте курсируют около 1 800 активных автобусов на 170 маршрутах. Их GPS-координаты поступают каждые 10 секунд. В сутки это составляет порядка 15 миллионов координатных пингов. Но объём – не самая сложная часть.
Настоящая проблема в том, что отдельный GPS-пинг сам по себе ничего не значит. Координата автобуса на заданной широте/долготе не отвечает на вопросы:
Автобус стоит на остановке или застрял в пробке?
Он едет по маршруту или заканчивает рейс?
Это точные данные или сбой GPS?
Чтобы ответить на эти вопросы, данные необходимо непрерывно собирать, очищать, привязывать к маршрутам и организовывать в структуру, пригодную для запросов. Именно это и решает мой проект.
Что я оставил – и от чего отказался
Стек менялся на протяжении всего проекта. Изначально я пробовал Kafka и ClickHouse, но в итоге отказался от них.
Kafka подходит для событийно-ориентированных архитектур, где источники данных непрерывно публикуют события. Эта же система опрашивает API по фиксированному таймеру раз в 10 секунд – это паттерн «pull». Kafka добавляла операционные расходы, не давая никаких преимуществ перед простым асинхронным загрузчиком с расписанием.
ClickHouse тоже оказался преждевременным: объём запросов на текущем этапе не оправдывает использование OLAP-движка.
Итоговый стек:
Ingestion (Загрузка): асинхронный Python-загрузчик (asyncio + aiohttp), запускаемый по systemd-таймерам, обращается к API 3TM каждые 10 секунд и записывает координаты в PostgreSQL.
Storage (Хранение): PostgreSQL с PostGIS берёт на себя всё – атрибуты маршрутов, расположение остановок и временные ряды GPS. Справляется лучше, чем многие ожидают от Postgres.
Orchestration (Оркестрация): Dagster управляет всей цепочкой зависимостей – маршруты загружаются раньше автобусов, автобусы раньше остановок, остановки раньше обновлений GPS. Эта прослеживаемость предотвратила немало скрытых сбоев.
Spatial Analytics (Пространственная аналитика): более тяжёлые трансформации – сопоставление GPS-треков с геометриями маршрутов, измерение порогов остановок, расчёт нагрузки на дорожную сеть – выполняются вне базы данных на PySpark.

ETL-процесс
Одна важная деталь схемы ETL: загрузчик API сначала записывает «сырой» JSON в слой Staging, а затем передаёт тот же пакет через буфер в памяти в ODS-загрузчик – таким образом данные очищаются и структурируются без лишнего чтения из базы. При скорости потока (1 800 автобусов × 2 записи за цикл опроса ÷ 10 секунд) это даёт ~360 вставок в секунду. На практике база справляется без нареканий на скромном железе (2 vCPU, 4 ГБ RAM). Строки в Staging удаляются через 1 день – хранить «сырой» JSON после обработки нет смысла.
Превращение сырых данных в настоящую модель
Именно здесь большинство проектов «Я построил ETL» рассыпается. Сложить координаты в базу данных – это не пайплайн, это куча. Получаешь миллионы строк и никакой возможности ответить на реальные вопросы.
Я структурировал данные по трём слоям:
ODS (Operational Data Storage – Операционное хранилище данных): Сюда первыми попадают маршруты, остановки и обновления автобусов. Хорошо для быстрых запросов, но без истории.
DDS (Detailed Data Storage – Детальное хранилище данных): здесь формируется настоящая структура. DDS смоделирован по Data Vault 2.0 – хабы, сателлиты и линки – обеспечивают чёткое разделение между ключевыми сущностями и их изменяющимися атрибутами. Автобусы связываются с маршрутами, расписания и геометрии маршрутов присоединяются как сателлиты, и всё становится аудируемым. Сами GPS-координаты здесь не хранятся – DDS нужен исключительно для сущностей: какие маршруты существуют, какие автобусы их обслуживают, где расположены остановки.
CDM (Common Data Marts – данных): слой, готовый для аналитики. Основная таблица, fct_bus_trips, хранит каждый рейс как PostGIS LineString – упорядоченную последовательность GPS-координат, представляющую путь автобуса от отправления до прибытия. Помимо геометрии, каждая строка содержит метаданные: временны́е метки начала и конца рейса, пробег в километрах, среднюю скорость в км/ч и процент соответствия маршруту. Именно эта таблица лежит в основе большей части последующего анализа.

Паттерн хаб/сателлит/линк означает, что добавление нового атрибута автобуса – например, нового атрибута телеметрии из API – вообще не затрагивает основную модель. Просто добавляешь сателлит. Такая гибкость важна, когда источник данных не находится под твоим контролем.
Сегментация рейсов
Последний шаг – сегментация рейсов в CDM – безусловно самая сложная часть всего проекта. Автобус не объявляет, когда начинает или заканчивает рейс. Это приходится вычислять из самих данных.
Подход сочетает три сигнала:
Порог скорости – если автобус стоит вблизи начальной/конечной остановки достаточно долго, это граница рейса.
Порог временного разрыва – промежуток более 5 минут между двумя последовательными пингами одного автобуса означает разрыв непрерывности.
Близость к остановке – последовательность пингов должна находиться вблизи реальной конечной точки маршрута, а не любой промежуточной остановки.
При выполнении всех условий поток разрезается и открывается новая запись рейса. Это не идеально – длительный пропуск GPS может разбить один реальный рейс на два – но достаточно стабильно, чтобы количество и продолжительность рейсов выдерживали ручную проверку.
Как сделать GPS-точки значимыми
Сырые GPS-данные по своей природе "шумные". Пропадание сигнала, запаздывание оборудования и отклонение автобусов от заданных маршрутов – всё это норма, и без очистки эти артефакты вводят в заблуждение любой последующий анализ.
Перед пространственным анализом сырые пинги фильтруются по трём правилам:
Любое показание, подразумевающее скорость выше 100 км/ч между соседними пингами, отбрасывается как аппаратный сбой.
Точки, расположенные более чем в 200 метрах от известного пути маршрута, помечаются как выбросы и исключаются из восстановления рейса.
Разрывы длиннее пяти минут трактуются как границы рейсов, а не заполняются интерполяцией – восстановление положения автобуса во время длительного пропадания сигнала вносит больше ошибок, чем устраняет.
Но линии на карте рассказывают лишь часть истории. Чтобы по-настоящему смоделировать сеть, я построил точный граф с временны́ми бакетами с помощью PySpark, выйдя далеко за рамки базовых пространственных пересечений. Пайплайн работает в три этапа:
Привязка к маршруту: официальные геометрии остановок проецируются на заранее определённые пути маршрутов, устанавливая точную упорядоченную последовательность остановок.
Нарезка рейсов: начальные и конечные точки каждого фактического рейса пространственно сопоставляются с этой последовательностью, привязывая реальное движение к структуре сети.
Интерполяция времени: пайплайн линейно интерполирует точный момент, когда автобус пересёк каждое ребро между остановками, добавляя временно́е измерение к каждому движению.
В результате происходит переход от шумного облака точек к взвешенному ориентированному сетевому графу. Агрегирование прохождений рёбер по десяткам тысяч ежедневных рейсов даёт высокоточную визуализацию транспортного пульса города – показывает именно те коридоры, которые несут наибольшую нагрузку, и когда именно.

Что показывают данные
С работающим пайплайном я проанализировал все рейсы, завершённые 25 марта 2026 года, и создал пространственную схему потоков завершённых автобусных рейсов по городу.
Вот один из результатов проекта. То, что начиналось как 15 миллионов «шумных» пингов, теперь представляет собой структурированную, доступную для запросов картину того, как город реально движется. Стрелки показывают направление и объём автобусного трафика — более толстые и яркие линии означают больше рейсов на данном коридоре.
Из этого можно напрямую увидеть:

Какие коридоры несут наибольший поток автобусов – кандидаты для выделенных полос BRT (Bus Rapid Transit).
Где маршруты перекрываются или скапливаются – полезная информация для планирования сети маршрутов.
Какие районы имеют низкую частоту автобусов относительно среднего по сети – пространственные пробелы в транспортном обслуживании, неочевидные без полного вида на сеть.
Это не гипотетические сценарии. Именно такой анализ нужен специалистам по транспортному планированию – и он получается непосредственно из данных, которые прежде существовали лишь как поток сырых координат без какой-либо структуры.
Что я бы сказал себе в начале
Не храни то, что тебе не нужно. Сырые JSON – расходный материал после обработки. Политика хранения на 1 день в слое Staging была верным решением. Хранение всего «на всякий случай» заполнило бы диск в течение нескольких дней.
Моделирование – не опциональный этап. Перейти напрямую от сырых данных API к дашборду кажется быстрым – пока не понадобится ответить на реальные вопросы и не осознаешь, что это невозможно. Структура ODS → DDS → CDM поначалу казалась лишней работой. Но именно она делает данные по-настоящему пригодными для использования.
Заключение
Этот проект начался как личное любопытство о городе, в котором я живу. Он превратился в полноценную систему пространственной инженерии данных, способную теперь отвечать на реальные вопросы:
Какие маршруты опаздывают больше всего в час пик?
Каково фактическое время в пути между двумя остановками в 8:00 и в 14:00?
Если бы у вас были чистые, структурированные данные о каждом движении автобуса по всему городу – что бы вы с ними сделали? Оптимизация маршрутов, прогнозирование задержек, городское планирование – возможности безграничны.
