06 — Cadeias de Múltiplos Transbordos¶
A análise de pares (A→B) oculta jornadas A→B→C (o RIT de Curitiba permite até 3 linhas em integração). Aqui: detecta sequências de até 4 linhas por cartão/dia (DuckDB, pernas plausíveis até 1 km), acha triplas frequentes, verifica cobertura da matriz por ligação e monta um grafo de linhas para achar hubs.
In [1]:
import json
from pathlib import Path
import numpy as np, pandas as pd, duckdb
import matplotlib.pyplot as plt
from transbordo_utils import resolver_cache, carregar_pares_oficiais
PARQUET_USR = resolver_cache("usuarios_limpo_2026-01-01_2026-01-31.parquet")
MATRIZ_JSON = resolver_cache("matriz_integracao.json")
OUT_CADEIAS = str(Path(PARQUET_USR).parent / "cadeias_multiplas.parquet")
OUT_GRAFO = "grafo_linhas.json"
JMIN, JMAX = 3, 60 # janela de transbordo (min)
GAP_MAX_KM = 1.0 # distancia maxima entre validacoes consecutivas
try:
PARES_MESMA = set(tuple(sorted(map(str, x)))
for x in json.load(open(resolver_cache("pares_mesma_linha.json"))))
except FileNotFoundError:
PARES_MESMA = set()
print("parquet:", PARQUET_USR)
parquet: cache_urbs/usuarios_limpo_2026-01-01_2026-01-31.parquet
1. Detectar sequências via DuckDB¶
In [2]:
con = duckdb.connect(database=":memory:",
config={"memory_limit": "1GB",
"temp_directory": "/tmp/duckdb_spill"})
SQL = f"""
WITH seq AS (
SELECT
NUMEROCARTAO,
CAST(DATA_HORA_UTILIZACAO AS DATE) AS dia,
CODLINHA AS l1,
NOMELINHA AS n1,
LEAD(CODLINHA, 1) OVER w AS l2,
LEAD(NOMELINHA, 1) OVER w AS n2,
LEAD(CODLINHA, 2) OVER w AS l3,
LEAD(NOMELINHA, 2) OVER w AS n3,
LEAD(CODLINHA, 3) OVER w AS l4,
LEAD(NOMELINHA, 3) OVER w AS n4,
DATA_HORA_UTILIZACAO AS t1,
LEAD(DATA_HORA_UTILIZACAO, 1) OVER w AS t2,
LEAD(DATA_HORA_UTILIZACAO, 2) OVER w AS t3,
LEAD(DATA_HORA_UTILIZACAO, 3) OVER w AS t4,
LATITUDE AS lat1, LONGITUDE AS lon1,
LEAD(LATITUDE, 1) OVER w AS lat2, LEAD(LONGITUDE, 1) OVER w AS lon2,
LEAD(LATITUDE, 2) OVER w AS lat3, LEAD(LONGITUDE, 2) OVER w AS lon3,
LEAD(LATITUDE, 3) OVER w AS lat4, LEAD(LONGITUDE, 3) OVER w AS lon4
FROM read_parquet('{PARQUET_USR}')
WHERE NUMEROCARTEIRA IN ('1','2')
WINDOW w AS (
PARTITION BY NUMEROCARTAO, CAST(DATA_HORA_UTILIZACAO AS DATE)
ORDER BY DATA_HORA_UTILIZACAO
)
), gaps AS (
SELECT *,
2 * 6371.0088 * asin(sqrt(pow(sin((radians(lat2)-radians(lat1))/2),2)+cos(radians(lat1))*cos(radians(lat2))*pow(sin((radians(lon2)-radians(lon1))/2),2))) AS g12,
2 * 6371.0088 * asin(sqrt(pow(sin((radians(lat3)-radians(lat2))/2),2)+cos(radians(lat2))*cos(radians(lat3))*pow(sin((radians(lon3)-radians(lon2))/2),2))) AS g23,
2 * 6371.0088 * asin(sqrt(pow(sin((radians(lat4)-radians(lat3))/2),2)+cos(radians(lat3))*cos(radians(lat4))*pow(sin((radians(lon4)-radians(lon3))/2),2))) AS g34
FROM seq
)
SELECT
NUMEROCARTAO, dia,
l1, n1, l2, n2, l3, n3, l4, n4,
date_diff('minute', t1, t2) AS d12,
date_diff('minute', t2, t3) AS d23,
date_diff('minute', t3, t4) AS d34,
g12, g23, g34
FROM gaps
WHERE l2 IS NOT NULL
AND l1 <> l2
AND date_diff('minute', t1, t2) BETWEEN {JMIN} AND {JMAX}
AND g12 <= {GAP_MAX_KM}
"""
print("Executando query DuckDB...")
df_seq = con.execute(SQL).df()
con.close()
_mesma12 = [tuple(sorted([str(a), str(b)])) in PARES_MESMA for a, b in zip(df_seq["l1"], df_seq["l2"])]
df_seq = df_seq[[not x for x in _mesma12]].copy()
print(f"Sequências detectadas (perna 1 <= {GAP_MAX_KM:.1f} km): {len(df_seq):,}")
df_seq.head(3)
Executando query DuckDB...
Sequências detectadas (perna 1 <= 1.0 km): 1,339
Out[2]:
| NUMEROCARTAO | dia | l1 | n1 | l2 | n2 | l3 | n3 | l4 | n4 | d12 | d23 | d34 | g12 | g23 | g34 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3760779497 | 2026-01-12 | 205 | BARREIRINHA | 280 | NSA.SRA.DE NAZARÉ | 280 | NSA.SRA.DE NAZARÉ | NaN | NaN | 37 | 84 | <NA> | 0.802511 | 4.429843 | NaN |
| 1 | 3761783929 | 2026-01-06 | 182 | ABRANCHES | 274 | STA. GEMA | 924 | STA. FELICIDADE / STA. CÂNDIDA | NaN | NaN | 38 | 277 | <NA> | 0.388781 | 0.620427 | NaN |
| 2 | 61562721 | 2026-01-29 | 366 | ITUPAVA / HOSP.MILITAR | 707 | TATUQUARA / CENTRO | NaN | NaN | NaN | NaN | 36 | <NA> | <NA> | 0.902066 | NaN | NaN |
2. Classificar comprimento da cadeia¶
In [3]:
# Matriz oficial → set de pares com integração
pares_of = carregar_pares_oficiais(MATRIZ_JSON)
def tem_l3(row):
"""True se l3 existe, l2≠l3 e d23 está na janela."""
return (
pd.notna(row["l3"]) and
row["l2"] != row["l3"] and
tuple(sorted([str(row["l2"]), str(row["l3"])])) not in PARES_MESMA and
pd.notna(row["d23"]) and
JMIN <= row["d23"] <= JMAX and row["g23"] <= GAP_MAX_KM
)
def tem_l4(row):
"""True se l4 existe, l3≠l4 e d34 está na janela."""
return (
tem_l3(row) and
pd.notna(row["l4"]) and
row["l3"] != row["l4"] and
tuple(sorted([str(row["l3"]), str(row["l4"])])) not in PARES_MESMA and
pd.notna(row["d34"]) and
JMIN <= row["d34"] <= JMAX and row["g34"] <= GAP_MAX_KM
)
df_seq["tem_l3"] = df_seq.apply(tem_l3, axis=1)
df_seq["tem_l4"] = df_seq.apply(tem_l4, axis=1)
df_seq["n_linhas"] = 2 + df_seq["tem_l3"].astype(int) + df_seq["tem_l4"].astype(int)
# Cobertura integração por ligação
df_seq["of_12"] = [tuple([a,b]) in pares_of for a,b in zip(df_seq["l1"],df_seq["l2"])]
df_seq["of_23"] = [
(tuple([a,b]) in pares_of) if (pd.notna(a) and pd.notna(b)) else False
for a,b in zip(df_seq["l2"], df_seq["l3"])
]
df_seq["of_34"] = [
(tuple([a,b]) in pares_of) if (pd.notna(a) and pd.notna(b)) else False
for a,b in zip(df_seq["l3"], df_seq["l4"])
]
# Cadeia 100% coberta pela matriz?
def cadeia_coberta(row):
if row["n_linhas"] == 2: return row["of_12"]
if row["n_linhas"] == 3: return row["of_12"] and row["of_23"]
return row["of_12"] and row["of_23"] and row["of_34"]
df_seq["cadeia_coberta"] = df_seq.apply(cadeia_coberta, axis=1)
df_seq.to_parquet(OUT_CADEIAS, index=False)
print(f"Salvo → {OUT_CADEIAS}")
print("\n── distribuição de comprimento de cadeia ──")
print(df_seq["n_linhas"].value_counts().sort_index())
print("\n── % de cadeias 100% cobertas por integração oficial ──")
print(df_seq.groupby("n_linhas")["cadeia_coberta"].mean().map("{:.1%}".format))
Salvo → cache_urbs/cadeias_multiplas.parquet ── distribuição de comprimento de cadeia ── n_linhas 2 1311 3 23 4 5 Name: count, dtype: int64 ── % de cadeias 100% cobertas por integração oficial ── n_linhas 2 14.4% 3 0.0% 4 0.0% Name: cadeia_coberta, dtype: str
3. TOP triplas (A→B→C) mais frequentes¶
In [4]:
df_triplas = df_seq[df_seq["tem_l3"]].copy()
df_triplas["tripla"] = df_triplas["n1"].str[:15] + " → " + \
df_triplas["n2"].str[:15] + " → " + \
df_triplas["n3"].str[:15]
top_triplas = (
df_triplas
.groupby(["l1","n1","l2","n2","l3","n3","of_12","of_23"])
.size()
.reset_index(name="volume")
.sort_values("volume", ascending=False)
)
top_triplas["cobertura"] = top_triplas.apply(
lambda r: "ambas" if r["of_12"] and r["of_23"]
else ("só 1ª" if r["of_12"]
else ("só 2ª" if r["of_23"] else "nenhuma")),
axis=1
)
print("TOP 20 triplas mais frequentes:")
print(top_triplas.head(20)[["n1","n2","n3","volume","cobertura"]].to_string(index=False))
TOP 20 triplas mais frequentes:
n1 n2 n3 volume cobertura
INTERBAIRR II H BOSCH INTERBAIRROS IV 1 nenhuma
INTERBAIRROS IV INTERBAIRROS V INTERBAIRROS IV 1 nenhuma
INTERBAIRROS IV PORTÃO / CIC INTERBAIRROS IV 1 nenhuma
INTERBAIRROS IV BOSCH PORTÃO / CIC 1 nenhuma
INTERBAIRROS IV FAZENDINHA/CAIUÁ-B.VISTA ITATIAIA 1 nenhuma
JD. KOSMOS V. NORI PRIMAVERA 1 nenhuma
BOM RETIRO / PUC CABRAL / OSÓRIO SAGRADO CORAÇÃO 1 nenhuma
SOLAR V. ESPERANÇA SOLAR 1 nenhuma
ITUPAVA / HOSP.MILITAR V. CUBAS FAZENDINHA 1 nenhuma
NIVALDO BRAGA INTERBAIRROS III MARINGÁ 1 nenhuma
E.VERÍS/PANTANAL OSTERNACK/BOQ. E.VERÍS/PANTANAL 1 nenhuma
BAIRRO NOVO B CENTENÁRIO/HAUER BAIRRO NOVO B 1 nenhuma
PORTÃO / CIC INTERBAIRR II H BOSCH 1 nenhuma
PORTÃO / CIC INTERBAIRROS IV V. VERDE 1 nenhuma
PORTÃO / CIC FORMOSA INTERBAIRROS IV 1 nenhuma
BOSCH INTERBAIRROS IV PORTÃO / CIC 1 nenhuma
POMPÉIA / JANAÍNA JD. ORDEM JD. LUDOVICA 1 nenhuma
STA.RITA/PINHEIRINHO TATUQUARA / CENTRO STA.RITA/PINHEIRINHO 1 nenhuma
V. VERDE CAMPO ALEGRE V. TIRADENTES / CIC 1 nenhuma
CAMPO ALEGRE INTERBAIRROS IV BOSCH 1 nenhuma
4. Grafo de linhas (nós = linhas, arestas = volume de passageiros)¶
In [5]:
# Arestas = integrações INFERIDAS (baldeação real, desembarque estimado) do caderno 02
tb = pd.read_parquet(resolver_cache("transbordos_2026-01-01_2026-01-31.parquet"))
bal = tb[tb["baldeacao_real"]]
df_edges = pd.DataFrame({
"src": bal["CODLINHA_ORIGEM"].astype(str).values,
"dst": bal["CODLINHA_DESTINO"].astype(str).values})
df_edges["oficial"] = [(a, b) in pares_of for a, b in zip(df_edges["src"], df_edges["dst"])]
df_edges["par"] = [
"|".join(sorted([a, b])) for a, b in zip(df_edges["src"], df_edges["dst"])
]
# Agregamos em arestas não-direcionadas
agg_edges = (
df_edges
.groupby("par")
.agg(volume=("par","count"), oficial=("oficial","any"))
.reset_index()
)
agg_edges[["src","dst"]] = agg_edges["par"].str.split("|", expand=True)
agg_edges = agg_edges.drop(columns="par")
# Grau dos nós (soma de volumes de arestas incidentes)
nodes_df = pd.concat([
agg_edges.rename(columns={"src":"linha"})[["linha","volume"]],
agg_edges.rename(columns={"dst":"linha"})[["linha","volume"]],
]).groupby("linha")["volume"].sum().reset_index(name="grau")
# Nome da linha
nome_map = dict(zip(bal["CODLINHA_ORIGEM"].astype(str), bal["NOMELINHA_ORIGEM"]))
nome_map.update(dict(zip(bal["CODLINHA_DESTINO"].astype(str), bal["NOMELINHA_DESTINO"])))
nodes_df["nome"] = nodes_df["linha"].map(nome_map).fillna(nodes_df["linha"])
nodes_df = nodes_df.sort_values("grau", ascending=False)
# Exportar grafo como JSON (para D3.js ou Gephi)
grafo = {
"nodes": nodes_df.to_dict(orient="records"),
"links": agg_edges.to_dict(orient="records")
}
with open(OUT_GRAFO, "w") as f:
json.dump(grafo, f, ensure_ascii=False, indent=1)
print(f"Grafo salvo → {OUT_GRAFO}")
print(f" nós: {len(nodes_df):,} | arestas: {len(agg_edges):,}")
print("\n── TOP 15 hubs estruturais (linhas de maior grau) ──")
print(nodes_df.head(15)[["linha","nome","grau"]].to_string(index=False))
Grafo salvo → grafo_linhas.json nós: 235 | arestas: 1,554 ── TOP 15 hubs estruturais (linhas de maior grau) ── linha nome grau 170 BRACATINGA 576 021 INTERB II ANTI H 394 040 INTERBAIRROS IV 389 020 INTERBAIRR II H 369 166 V. NORI 276 860 V. SANDRA 240 876 SAVÓIA 232 703 CAIUÁ 228 011 INTERBAIRROS I A 179 010 INTERBAIRROS I H 178 924 STA. FELICIDADE / STA. CÂNDIDA 166 160 J.MERCES/GUANABA 165 801 CAMP.SIQ./BATEL 161 901 STA. FELICIDADE 154 171 PRIMAVERA 154
5. Visualização: hubs por grau (barplot horizontal)¶
In [6]:
top_hubs = nodes_df.head(15).copy()
top_hubs["label"] = top_hubs["nome"].str[:25]
fig, ax = plt.subplots(figsize=(10, 6))
bars = ax.barh(range(len(top_hubs)), top_hubs["grau"],
color="#2980b9", alpha=0.85)
ax.set_yticks(range(len(top_hubs)))
ax.set_yticklabels(top_hubs["label"], fontsize=9)
ax.invert_yaxis()
ax.set_xlabel("Volume total de passageiros (soma das arestas)")
ax.set_title("Hubs estruturais da rede — linhas com mais conexões de transbordo (jan/2026)")
# Anotação do valor
for bar, val in zip(bars, top_hubs["grau"]):
ax.text(val + 5, bar.get_y() + bar.get_height()/2,
f"{val:,}", va="center", fontsize=8)
plt.tight_layout()
plt.savefig("hubs_estruturais.png", dpi=150, bbox_inches="tight")
plt.show()
print("Salvo: hubs_estruturais.png")
Salvo: hubs_estruturais.png
6. Cadeias com cobertura parcial ("elo quebrado")¶
In [7]:
# Triplas onde a 1ª ligação TEM integração mas a 2ª NÃO tem
# → passageiro pagou integração em A→B mas paga de novo em B→C
elo_quebrado = top_triplas[
top_triplas["of_12"] & ~top_triplas["of_23"]
].head(15)
print("Triplas com 'elo quebrado' (A→B integrado, B→C NÃO integrado):")
print(elo_quebrado[["n1","n2","n3","volume","cobertura"]].to_string(index=False))
print()
print("SUGESTÃO: esses pares B→C são candidatos prioritários para nova integração.")
print("O passageiro já usa o sistema de integração — adicionar B→C não muda seu comportamento,")
print("apenas reduz o custo de uma ligação que ele já faz.")
Triplas com 'elo quebrado' (A→B integrado, B→C NÃO integrado): Empty DataFrame Columns: [n1, n2, n3, volume, cobertura] Index: [] SUGESTÃO: esses pares B→C são candidatos prioritários para nova integração. O passageiro já usa o sistema de integração — adicionar B→C não muda seu comportamento, apenas reduz o custo de uma ligação que ele já faz.
7. Interpretação¶
- Hubs estruturais: linhas no centro de muitas cadeias são gargalos tarifários — qualquer par não coberto que passe por elas atinge muitos passageiros.
- Elo quebrado (A→B integrado, B→C não): mais urgente que pares simples — o passageiro já usa integração e paga duplo em só uma etapa (percebido como injusto). Esses B→C são candidatos prioritários.
- % de cadeias 100% cobertas: mede a efetividade da matriz. Se a maioria das jornadas de 3 linhas não está coberta, é revisão sistêmica, não ajuste pontual.