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 |