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")
No description has been provided for this image
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.