Frota do Rio em Tempo Real, do GPS dos Ônibus a um Painel com Kafka e Spark

Frota do Rio em Tempo Real, do GPS dos Ônibus a um Painel com Kafka e Spark

Setor
Mobilidade urbana
Ferramentas
Python, Apache Kafka, Apache Spark, PostgreSQL, DuckDB, Docker
Entregas
  • Coleta de um dia inteiro do feed, a cada 15 segundos
  • Tópico no Kafka com produtor ao vivo e replay
  • Três jobs de Spark Structured Streaming conferidos contra um cálculo em lote
  • Gravação no PostgreSQL que não duplica quando um job cai
  • Painel interativo em HTML, com mapas

Desafio

A Prefeitura do Rio publica como dados abertos a posição de cada ônibus e de cada veículo do BRT em viagem, no padrão GTFS-Realtime, atualizada a cada 30 segundos. O GTFS publicado junto traz as linhas, os traçados e o intervalo planejado entre as partidas. Com os dois dá para responder perguntas de quem pega ônibus no Rio: onde o trânsito trava, quanto tempo os veículos ficam parados longe do terminal e se a linha passa no intervalo prometido.

O feed não entrega essas respostas prontas. Ele não diz quando o ônibus saiu do ponto inicial: a Prefeitura encaixa cada veículo num horário da grade planejada, e no dia coletado 4.526 desses horários apareceram com dois ou mais ônibus. A posição chega com 33 segundos de atraso na mediana, metade das linhas repete a consulta anterior, e há saltos que exigiriam mais de 120 km/h e posições fora do município.

Este projeto de estudo trata o feed como um fluxo. As posições passam por um tópico do Kafka, três jobs de Spark Structured Streaming calculam velocidade, paradas e viagens enquanto os dados chegam, os resultados vão para o PostgreSQL e terminam num painel. Tudo roda em contêineres com Docker Compose.

Solução

Arquitetura do pipelinePasse o mouse em cada etapa. Clique num job do Spark para seguir o caminho dele.
Fontes
Feed dos ônibusGTFS-Realtime
Feed do BRTGTFS-Realtime
GTFSlinhas e traçados
Ingestão
ProdutorPythonao vivo ou replay
ColetorPython · Parquetum dia guardado
Kafka
frota.posicoesApache Kafka11,7 mimensagens do dia
Spark Structured Streaming
Gravação e conferência
PostgreSQLupsert por lote3 tabelas e o registro de lotes
ConferênciaDuckDBmesmo cálculo, em lote
Análise e painel
Intervalo realDuckDB · GTFS32.216 partidas
PainelHTML · JavaScript4 páginas, 2 mapas

Escolha um job do Spark para ver de onde ele lê, como agrupa as posições e o que gravou no dia.

  1. Coleta. Um coletor em Python consultou os dois feeds a cada 15 segundos durante a sexta-feira, 6 de novembro de 2026, das 0h às 23h05, sem nenhuma falha. Guardou cada resposta e uma tabela Parquet a cada 5 minutos: 11,3 milhões de linhas de ônibus e 1,3 milhão do BRT. Esse dia guardado é a base do replay e da conferência dos jobs.
  2. Kafka. O Kafka roda num contêiner em modo KRaft, sem ZooKeeper. O produtor publica uma mensagem JSON por veículo em cada consulta, ao vivo ou reenviando um dia guardado. A chave da mensagem é o modo e o veículo, então as posições de um veículo ficam sempre na mesma partição e chegam em ordem. O replay do dia inteiro pôs 11.698.813 mensagens no tópico em 366 segundos.
  3. Spark. Os três jobs leem o mesmo tópico. Antes de agrupar, descartam posições adiantadas mais de 30 segundos ou com mais de 5 minutos de idade e removem as repetidas. A marca d'água é de 6 minutos: a maior idade aceita mais os 30 segundos de folga. Cada resultado é gravado uma vez só, quando a marca d'água passa do fim dele.
Como cada job recorta o tempoExemplo ilustrativo de um ônibus em 50 minutos. Escolha um job e passe o mouse nos pontos e nas janelas.
02040km/h08:0008:0508:1008:1508:2008:2508:3008:3508:4008:4508:5024226018122224Janela fixa de 5 minutos por linha · número = velocidade média da janelaparada de 8 min1 min, descartadaSessão de 5 minutos por veículo e ponto · só as posições com velocidade zeroviagem de 33 min13 min sem posiçãoSessão de 10 minutos por veículo, linha e sentido · fica se durar 20 min ou maismarca d’água 08:44no estadoO que já pode ser gravado e o que ainda espera posições atrasadas

A janela fixa corta o tempo em blocos de 5 minutos iguais para todas as linhas. Cada bloco vira uma linha na tabela de velocidade, com a média das posições que caíram nele. Um bloco sem posição não gera linha.

A sessão não tem tamanho fixo. Ela começa na primeira posição parada e continua enquanto a próxima vier em até 5 minutos, no mesmo ponto. A parada só fica se durar 5 minutos ou mais.

Na viagem, a sessão aceita até 10 minutos entre uma posição e outra. Um buraco maior no feed fecha a viagem, e a próxima posição abre outra. Viagens com menos de 20 minutos costumam ser troca de sentido no terminal e ficam de fora.

A marca d'água é o maior horário de posição já visto menos 6 minutos. O que termina antes dela já pode ser gravado, porque nenhuma posição válida chega tão atrasada. O resto fica guardado no estado do Spark, e o checkpoint permite continuar de onde parou depois de uma queda.

  1. Conferência. Cada job foi refeito em lote com DuckDB a partir dos Parquet da coleta e comparado resultado a resultado com a saída do Spark. A comparação achou duas diferenças de regra. O Spark junta na mesma sessão duas posições que estão exatamente 5 minutos uma da outra, e o lote passou a quebrar a sessão só acima disso. Com o ônibus parado, o feed às vezes troca a viagem atribuída e volta minutos depois. Com a viagem na chave da parada, uma parada longa virava vários pedaços, e a viagem saiu da chave. Depois dos ajustes, os três jobs chegaram exatamente aos mesmos resultados do lote no dia inteiro.
  2. PostgreSQL sem duplicar. Com a opção de banco, cada lote do Spark vai para uma tabela temporária, entra na tabela final com INSERT ... ON CONFLICT DO UPDATE na chave do resultado e fica registrado numa tabela de lotes, tudo numa transação. Se o job cair depois de gravar e antes de confirmar o lote no checkpoint, ele refaz o mesmo lote ao voltar, e a chave faz as linhas repetidas sobrescreverem as que já estavam lá. Quatro testes de falha conferiram esse caminho:
Testes de falhaEscolha um teste e passe o mouse nos lotes.
18ok
19ok
20gravado
queda
20refeito
21ok

O job de viagens morreu logo depois de gravar o lote 20 no banco. O checkpoint tinha o lote 20 planejado e só o 19 confirmado. Na execução seguinte, o Spark refez o lote 20 com as mesmas 775 linhas, e o banco continuou igual à saída em Parquet.

24ok
25ok
docker kill
26ok
27ok

Com o contêiner do job de paradas morto logo depois do lote 25, a nova execução continuou do lote 26 e o resultado final ficou igual.

14ok
15ok
16erro
banco volta
16refeito
17ok

Com o PostgreSQL parado depois do lote 15 do job de velocidade, o lote 16 falhou ao conectar e o job terminou com erro, sem confirmar o lote. Com o banco de volta, a nova execução começou pelo lote 16 e o resultado final ficou igual.

9ok
10avisos
11ok
…
59ok

Com o Kafka reiniciado durante o lote 10 do job de viagens, o Spark registrou avisos de conexão por alguns segundos e seguiu sozinho até o lote 59. O tópico manteve as 11.698.813 mensagens e o banco ficou igual à saída em Parquet.

gravado e confirmadogravado sem confirmar ou com avisofalharefeito na nova execução
  1. Intervalo real. O intervalo entre partidas fica fora do streaming de propósito. Ele depende da partida anterior da mesma linha, e as viagens saem do Spark só quando terminam, fora da ordem de partida. Um script com DuckDB conta como partida a viagem cuja primeira posição apareceu a até 1 km do ponto inicial, calcula o intervalo com uma função de janela e compara com o planejado no GTFS, no tipo de dia certo (dia útil, sábado, domingo ou feriado).
  2. Painel. Um script em Python lê as tabelas do PostgreSQL, agrega com DuckDB e grava um arquivo HTML com os dados embutidos. O painel é HTML, CSS e JavaScript, sem biblioteca. Os mapas são desenhados a partir da malha do município do IBGE e dos traçados do BRT no GTFS, sem mapa de fundo de terceiros. São quatro páginas (visão geral, velocidade, regularidade e paradas) com filtros de modo, linha e hora que valem para todas, gráficos que filtram ao clique e um botão Documentação que percorre cada visual. O painel abaixo é o mesmo do botão Ver projeto:

Resultado

  • Frota: de 200 ônibus às 2h a quase 3.600 nos picos das 7h às 8h e das 17h às 18h.
  • Velocidade: os ônibus caem de 34 km/h de madrugada para 15 km/h às 17h. O BRT, que roda em corredor próprio, fica entre 28 e 31 km/h das 6h às 23h. No mapa dos ônibus, os quadrados de cerca de 550 m com média abaixo de 10 km/h passam de 37 às 8h para 154 às 17h.
  • Paradas: 2.555 vezes um veículo ficou parado no mesmo lugar por 5 minutos ou mais. 193 paradas passaram de 15 minutos, a maior de ônibus com 126 minutos e a maior do BRT com 62. Só 82 das 2.555 estavam perto do início ou do fim da linha.
  • Partidas: das 5h às 20h, o pipeline encontrou de 68% a 72% das partidas de ônibus previstas no GTFS e de 55% a 61% das do BRT. A diferença junta viagens que o feed começou a mostrar longe do ponto inicial e viagens que não aconteceram, e os dados não separam as duas.
  • Intervalo real: nos ônibus, o intervalo mediano entre as partidas encontradas foi de 13,8 minutos para 12,0 planejados; no BRT, de 7,6 para 6,0. Saíram em comboio, abaixo de um quarto do planejado, 5,5% dos intervalos de ônibus e 3,7% dos do BRT. Na linha SP864, planejada a cada 4 minutos das 6h às 20h, o intervalo mediano foi de 4,0 minutos, mas 10% passaram de 9,2 minutos e 22% ficaram abaixo de 1 minuto.
  • Engenharia: os três jobs chegaram aos mesmos resultados do cálculo em lote no dia inteiro, e os quatro testes de falha terminaram com o banco igual à saída em Parquet, sem linha perdida nem duplicada.

Os números têm limites que o painel também mostra. O GTFS público é de junho de 2026, e o feed usa uma programação mais nova em parte das linhas. Cada partida que o pipeline não encontra junta dois intervalos num só, então a parte de intervalos acima do dobro do planejado sai inflada. Os dois feeds em tempo real ainda estão em versão beta.

O botão Ver projeto abre o painel em tela cheia. O código está no GitHub, com a licença MIT e as instruções para subir Kafka, Spark e PostgreSQL com Docker e repetir o dia 6 de novembro pelo replay.

Projeto de estudo da Dome Analytics com dados abertos da Prefeitura do Rio de Janeiro, sob a licença CC BY 4.0. Não tem relação com a Prefeitura, com as empresas de ônibus nem com o BRT.

Ver projeto

Projetos relacionados

« Voltar aos projetos