02 — Análise: transbordo + matriz oficial de integração¶

Lê usuarios_*.parquet, detecta transbordo temporal (DuckDB), marca o transbordo_fisicamente_plausivel (validações consecutivas a até 1 km), compara com a matriz oficial, ranqueia pares sem integração e avalia as integrações oficiais. Grava transbordos_*.parquet para os notebooks seguintes.

In [1]:
import os, json
from pathlib import Path
import duckdb, pandas as pd, gc
from transbordo_utils import (GAP_VALIDACAO_MAX_KM, adicionar_plausibilidade_fisica,
                              resolver_cache, carregar_pares_oficiais,
                              carregar_paradas, adicionar_baldeacao_real)

START_DATE, END_DATE = "2026-01-01", "2026-01-31"
os.makedirs("/tmp/duckdb_spill", exist_ok=True)
PARQUET = resolver_cache(f"usuarios_limpo_{START_DATE}_{END_DATE}.parquet")
MATRIZ  = resolver_cache("matriz_integracao.json")
print("parquet:", PARQUET)
print("matriz :", MATRIZ)
parquet: cache_urbs/usuarios_limpo_2026-01-01_2026-01-31.parquet
matriz : matriz_integracao.json
In [2]:
# --- Detecção de TRANSBORDO via DuckDB (out-of-core, sem OOM) -> df_origem_destino ---
# Transbordo = usos consecutivos do mesmo cartão dentro da janela [MIN, MAX] min.
JANELA_MIN, JANELA_MAX = 3, 60
con = duckdb.connect(config={"memory_limit":"500MB","temp_directory":"/tmp/duckdb_spill","threads":"2"})
df_origem_destino = con.execute(f"""
WITH seq AS (
  SELECT NUMEROCARTAO, CODLINHA, NOMELINHA, CODVEICULO, DATA_HORA_UTILIZACAO AS dh, LATITUDE, LONGITUDE,
    LEAD(CODLINHA) OVER w CODLINHA_DESTINO, LEAD(NOMELINHA) OVER w NOMELINHA_DESTINO,
    LEAD(CODVEICULO) OVER w CODVEICULO_DESTINO, LEAD(DATA_HORA_UTILIZACAO) OVER w DATA_HORA_DESTINO,
    LEAD(LATITUDE) OVER w LATITUDE_DESTINO, LEAD(LONGITUDE) OVER w LONGITUDE_DESTINO
  FROM read_parquet('{PARQUET}')
  WINDOW w AS (PARTITION BY NUMEROCARTAO ORDER BY DATA_HORA_UTILIZACAO))
SELECT NUMEROCARTAO,
  CODLINHA CODLINHA_ORIGEM, NOMELINHA NOMELINHA_ORIGEM, CODVEICULO CODVEICULO_ORIGEM,
  dh DATA_HORA_ORIGEM, LATITUDE LATITUDE_ORIGEM, LONGITUDE LONGITUDE_ORIGEM,
  CODLINHA_DESTINO, NOMELINHA_DESTINO, CODVEICULO_DESTINO, DATA_HORA_DESTINO, LATITUDE_DESTINO, LONGITUDE_DESTINO,
  date_diff('second', dh, DATA_HORA_DESTINO)/60.0 espera_min
FROM seq WHERE DATA_HORA_DESTINO IS NOT NULL
  AND date_diff('second', dh, DATA_HORA_DESTINO)/60.0 BETWEEN {JANELA_MIN} AND {JANELA_MAX}
""").df()
con.close()

# salva p/ o 03_mapas
import numpy as _np
# limpa coordenadas inválidas (0,0 / fora de Curitiba) -> NaN; a baldeação ainda conta no volume
_bb = lambda la, lo: la.between(-25.75, -25.25) & lo.between(-49.55, -49.10)
for _p in ("ORIGEM", "DESTINO"):
    _ok = _bb(df_origem_destino[f"LATITUDE_{_p}"], df_origem_destino[f"LONGITUDE_{_p}"])
    df_origem_destino.loc[~_ok, [f"LATITUDE_{_p}", f"LONGITUDE_{_p}"]] = _np.nan
df_origem_destino = adicionar_plausibilidade_fisica(df_origem_destino, GAP_VALIDACAO_MAX_KM)

import json  # necessário para ler pares_mesma_linha.json
# remove pares de MESMA LINHA (ida/volta — mesmas duas pontas) = não são baldeação real
try:
    _sl = set(tuple(sorted(map(str,x))) for x in json.load(open(resolver_cache("pares_mesma_linha.json"))))
    _k = [tuple(sorted([str(o), str(d)])) for o, d in
          zip(df_origem_destino["CODLINHA_ORIGEM"].astype(str), df_origem_destino["CODLINHA_DESTINO"].astype(str))]
    _n0 = len(df_origem_destino)
    df_origem_destino = df_origem_destino[[t not in _sl for t in _k]].reset_index(drop=True)
    print(f"removidos {_n0-len(df_origem_destino)} transbordos de mesma-linha (ida/volta)")
except FileNotFoundError:
    print("pares_mesma_linha.json ausente — pulando filtro de mesma-linha")

# desembarque estimado (trip-chaining) -> BALDEAÇÃO REAL (transferência física). Ver REGRAS §3.
_stops = carregar_paradas(resolver_cache("itinerarios/pontos"))
df_origem_destino = adicionar_baldeacao_real(df_origem_destino, _stops)
print(f"plausíveis (board->board <= {GAP_VALIDACAO_MAX_KM:.1f} km): {df_origem_destino['transbordo_fisicamente_plausivel'].sum():,}")
print(f"baldeações REAIS (desembarque <=0.5km, andou >=0.8km, espera <=60min): {df_origem_destino['baldeacao_real'].sum():,}")

OUT_TB = Path(PARQUET).parent / f"transbordos_{START_DATE}_{END_DATE}.parquet"
df_origem_destino.to_parquet(OUT_TB, index=False)
print(f"transbordos: {len(df_origem_destino)} | salvo em {OUT_TB}")
removidos 105 transbordos de mesma-linha (ida/volta)
plausíveis (board->board <= 1.0 km): 1,330
baldeações REAIS (desembarque <=0.5km, andou >=0.8km, espera <=60min): 5,084
transbordos: 23562 | salvo em cache_urbs/transbordos_2026-01-01_2026-01-31.parquet
In [3]:
# --- Matriz oficial (Liberada, bidirecional; TEMPOLIMITE ignorado) ---
pares_integracao_oficial = carregar_pares_oficiais(MATRIZ)
print(f"pares oficiais (bidirecional): {len(pares_integracao_oficial)}")
pares oficiais (bidirecional): 2589
In [4]:
# --- Classifica baldeações REAIS (desembarque) com/sem integração oficial ---
od = df_origem_destino.copy()
od["par"] = list(zip(od["CODLINHA_ORIGEM"].astype(str), od["CODLINHA_DESTINO"].astype(str)))
df_bald_temporal = od[od["CODLINHA_ORIGEM"].astype(str) != od["CODLINHA_DESTINO"].astype(str)].copy()
df_bald = od[od["baldeacao_real"]].copy()
df_com = df_bald[df_bald["par"].isin(pares_integracao_oficial)].copy()
df_sem = df_bald[~df_bald["par"].isin(pares_integracao_oficial)].copy()
print(f"transbordos temporais: {len(od)} | baldeações temporais: {len(df_bald_temporal)}")
print(f"baldeações REAIS (desembarque): {len(df_bald)}")
print(f"  COM integração oficial: {len(df_com)} ({100*len(df_com)/max(len(df_bald),1):.1f}%)")
print(f"  SEM integração oficial: {len(df_sem)} ({100*len(df_sem)/max(len(df_bald),1):.1f}%)")
transbordos temporais: 23562 | baldeações temporais: 8581
baldeações REAIS (desembarque): 5084
  COM integração oficial: 1189 (23.4%)
  SEM integração oficial: 3895 (76.6%)
In [5]:
# --- Ranking SEM integração (candidatos a nova integração) ---
ranking_sem = (df_sem.groupby(["CODLINHA_ORIGEM","NOMELINHA_ORIGEM","CODLINHA_DESTINO","NOMELINHA_DESTINO"], as_index=False)
    .agg(qtd=("NUMEROCARTAO","count")).sort_values("qtd",ascending=False).reset_index(drop=True))
ranking_sem.index += 1
print("TOP 20 pares SEM integração:")
display(ranking_sem.head(20))
TOP 20 pares SEM integração:
CODLINHA_ORIGEM NOMELINHA_ORIGEM CODLINHA_DESTINO NOMELINHA_DESTINO qtd
1 171 PRIMAVERA 170 BRACATINGA 35
2 876 SAVÓIA 040 INTERBAIRROS IV 33
3 166 V. NORI 170 BRACATINGA 24
4 703 CAIUÁ 701 FAZENDINHA 24
5 170 BRACATINGA 901 STA. FELICIDADE 22
6 040 INTERBAIRROS IV 876 SAVÓIA 21
7 701 FAZENDINHA 703 CAIUÁ 18
8 876 SAVÓIA 205 BARREIRINHA 17
9 812 MONTANA 040 INTERBAIRROS IV 15
10 380 DETRAN/V.MACHADO 801 CAMP.SIQ./BATEL 15
11 170 BRACATINGA 876 SAVÓIA 14
12 967 JÚLIO GRAF 901 STA. FELICIDADE 13
13 870 SÃO BRAZ 040 INTERBAIRROS IV 13
14 021 INTERB II ANTI H 703 CAIUÁ 12
15 822 GABINETO 040 INTERBAIRROS IV 12
16 216 CABRAL / PORTÃO 050 INTERBAIRROS V 12
17 658 C.RASO/CAIUÁ 040 INTERBAIRROS IV 12
18 170 BRACATINGA 171 PRIMAVERA 12
19 170 BRACATINGA 166 V. NORI 10
20 528 BOQUEIRÃO/PINHEIRINHO 641 LUIZ NICHELE 10
In [6]:
# --- Etapa 5: avaliação das integrações oficiais (relevância) ---
uso_oficial = (df_com.groupby(["CODLINHA_ORIGEM","NOMELINHA_ORIGEM","CODLINHA_DESTINO","NOMELINHA_DESTINO"], as_index=False)
    .agg(qtd=("NUMEROCARTAO","count")).sort_values("qtd",ascending=False).reset_index(drop=True))
uso_oficial.index += 1
print("TOP 20 integrações oficiais MAIS usadas:")
display(uso_oficial.head(20))

cods_reais = set(pd.read_parquet(PARQUET, columns=["CODLINHA"])["CODLINHA"].astype(str).unique())
pares_obs = set(zip(df_com["CODLINHA_ORIGEM"].astype(str), df_com["CODLINHA_DESTINO"].astype(str)))
pares_of_reais = {(o,d) for (o,d) in pares_integracao_oficial if o in cods_reais and d in cods_reais}
sem_uso = pares_of_reais - pares_obs
print(f"\npares oficiais entre linhas reais: {len(pares_of_reais)} | com demanda: {len(pares_of_reais & pares_obs)} | SEM demanda: {len(sem_uso)}")
TOP 20 integrações oficiais MAIS usadas:
CODLINHA_ORIGEM NOMELINHA_ORIGEM CODLINHA_DESTINO NOMELINHA_DESTINO qtd
1 170 BRACATINGA 021 INTERB II ANTI H 107
2 860 V. SANDRA 040 INTERBAIRROS IV 66
3 170 BRACATINGA 020 INTERBAIRR II H 64
4 166 V. NORI 020 INTERBAIRR II H 36
5 166 V. NORI 021 INTERB II ANTI H 35
6 924 STA. FELICIDADE / STA. CÂNDIDA 021 INTERB II ANTI H 33
7 040 INTERBAIRROS IV 860 V. SANDRA 32
8 170 BRACATINGA 924 STA. FELICIDADE / STA. CÂNDIDA 28
9 827 RIVIERA 060 INTERBAIRROS VI 27
10 021 INTERB II ANTI H 170 BRACATINGA 26
11 170 BRACATINGA 010 INTERBAIRROS I H 25
12 171 PRIMAVERA 021 INTERB II ANTI H 24
13 020 INTERBAIRR II H 170 BRACATINGA 23
14 924 STA. FELICIDADE / STA. CÂNDIDA 170 BRACATINGA 21
15 169 JD. KOSMOS 021 INTERB II ANTI H 21
16 184 V. SUIÇA 011 INTERBAIRROS I A 18
17 010 INTERBAIRROS I H 876 SAVÓIA 18
18 020 INTERBAIRR II H 166 V. NORI 18
19 166 V. NORI 011 INTERBAIRROS I A 17
20 010 INTERBAIRROS I H 901 STA. FELICIDADE 16
pares oficiais entre linhas reais: 1027 | com demanda: 180 | SEM demanda: 847
In [7]:
# --- Série diária ---
od["dia"] = pd.to_datetime(od["DATA_HORA_ORIGEM"]).dt.date
serie = (od.assign(baldeacao_temporal=od["CODLINHA_ORIGEM"].astype(str)!=od["CODLINHA_DESTINO"].astype(str),
                   baldeacao_real=od["baldeacao_real"],
                   com_of=od["baldeacao_real"] & od["par"].isin(pares_integracao_oficial))
   .groupby("dia").agg(transbordos_temporais=("NUMEROCARTAO","size"),
                       baldeacoes_temporais=("baldeacao_temporal","sum"),
                       baldeacoes_reais=("baldeacao_real","sum"),
                       com_oficial=("com_of","sum")).reset_index())
display(serie)
dia transbordos_temporais baldeacoes_temporais baldeacoes_reais com_oficial
0 2026-01-01 75 20 12 4
1 2026-01-02 337 122 81 17
2 2026-01-03 435 159 98 5
3 2026-01-04 67 15 4 2
4 2026-01-05 798 315 198 72
5 2026-01-06 2540 735 429 123
6 2026-01-07 536 243 157 22
7 2026-01-08 758 349 202 36
8 2026-01-09 2544 880 513 113
9 2026-01-10 208 46 17 4
10 2026-01-11 111 23 18 1
11 2026-01-12 3052 1427 774 141
12 2026-01-13 1226 145 82 6
13 2026-01-14 886 291 180 37
14 2026-01-15 1236 673 418 90
15 2026-01-16 657 307 206 108
16 2026-01-17 172 26 10 0
17 2026-01-18 81 16 11 6
18 2026-01-19 306 87 43 8
19 2026-01-20 844 32 19 4
20 2026-01-21 346 91 58 5
21 2026-01-22 678 262 171 43
22 2026-01-23 1660 969 575 164
23 2026-01-24 74 14 10 2
24 2026-01-25 57 12 8 2
25 2026-01-26 236 40 21 2
26 2026-01-27 261 70 41 14
27 2026-01-28 432 111 52 1
28 2026-01-29 899 98 54 10
29 2026-01-30 637 250 140 22
30 2026-01-31 1412 752 481 125
31 2026-02-01 1 1 1 0