05 — Auditoria do TEMPOLIMITE nas Integrações Oficiais¶
Cada par da matriz tem um TEMPOLIMITE (min). Passageiros que excedem pagam tarifa dupla mesmo tendo integração oficial ("falsos COM"). Responde: quantas baldeações COM integração violam o limite? Quais pares têm maior taxa (candidatos a ampliar o tempo)? O limite é realista frente à espera observada?
In [1]:
import json
from pathlib import Path
import numpy as np, pandas as pd
import matplotlib.pyplot as plt
from transbordo_utils import (GAP_VALIDACAO_MAX_KM, adicionar_plausibilidade_fisica,
resolver_cache, carregar_pares_oficiais)
PARQUET_TB = resolver_cache("transbordos_2026-01-01_2026-01-31.parquet")
MATRIZ_JSON = resolver_cache("matriz_integracao.json")
OUT_CSV = str(Path(PARQUET_TB).parent / "violacoes_tempolimite.csv")
print("parquet:", PARQUET_TB)
parquet: cache_urbs/transbordos_2026-01-01_2026-01-31.parquet
1. Carregar dados e matriz¶
In [2]:
df = adicionar_plausibilidade_fisica(pd.read_parquet(PARQUET_TB), GAP_VALIDACAO_MAX_KM)
print(f"transbordos totais : {len(df):,}")
# Apenas baldeações fisicamente plausíveis (linhas diferentes e gap <= 1 km)
df_bald_temporal = df[df["CODLINHA_ORIGEM"] != df["CODLINHA_DESTINO"]].copy()
df_bald = df[df["baldeacao_real"]].copy()
print(f"baldeações temporais (linhas diferentes): {len(df_bald_temporal):,}")
print(f"baldeações fisicamente plausíveis (<= {GAP_VALIDACAO_MAX_KM:.1f} km): {len(df_bald):,}")
transbordos totais : 23,562 baldeações temporais (linhas diferentes): 8,581 baldeações fisicamente plausíveis (<= 1.0 km): 5,084
In [3]:
# Matriz oficial: pares + TEMPOLIMITE (ambos sentidos)
_, tl = carregar_pares_oficiais(MATRIZ_JSON, com_tempolimite=True)
df_matriz = (
pd.DataFrame([{"orig": o, "dest": d, "tempolimite_min": m} for (o, d), m in tl.items()])
.drop_duplicates(subset=["orig", "dest"])
)
print(f"pares na matriz (bidirecional): {len(df_matriz):,}")
print("valores únicos de TEMPOLIMITE (min):", sorted(df_matriz["tempolimite_min"].unique()))
pares na matriz (bidirecional): 2,589 valores únicos de TEMPOLIMITE (min): [np.int64(5), np.int64(10), np.int64(30), np.int64(60), np.int64(90), np.int64(120), np.int64(240), np.int64(1440)]
2. Join: baldeações COM integração × TEMPOLIMITE¶
In [4]:
df_com = df_bald.merge(
df_matriz,
left_on=["CODLINHA_ORIGEM", "CODLINHA_DESTINO"],
right_on=["orig", "dest"],
how="inner" # apenas pares que estão NA matriz
).drop(columns=["orig", "dest"])
print(f"baldeações COM integração oficial: {len(df_com):,}")
df_com["violou_limite"] = df_com["espera_min"] > df_com["tempolimite_min"]
n_viola = df_com["violou_limite"].sum()
pct = 100 * n_viola / len(df_com)
print(f"\nViolações do TEMPOLIMITE: {n_viola:,} de {len(df_com):,} ({pct:.1f}%)")
baldeações COM integração oficial: 1,189 Violações do TEMPOLIMITE: 0 de 1,189 (0.0%)
3. Ranking de pares com maior taxa de violação¶
In [5]:
por_par = (
df_com
.groupby(["CODLINHA_ORIGEM", "NOMELINHA_ORIGEM",
"CODLINHA_DESTINO", "NOMELINHA_DESTINO",
"tempolimite_min"])
.agg(
total = ("espera_min", "count"),
violacoes = ("violou_limite", "sum"),
espera_p50 = ("espera_min", "median"),
espera_p75 = ("espera_min", lambda x: x.quantile(0.75)),
espera_p95 = ("espera_min", lambda x: x.quantile(0.95)),
)
.reset_index()
)
por_par["taxa_violacao_pct"] = 100 * por_par["violacoes"] / por_par["total"]
por_par["par_nome"] = (
por_par["NOMELINHA_ORIGEM"].str[:20] + " → " +
por_par["NOMELINHA_DESTINO"].str[:20]
)
# Ordenar por taxa de violação (entre pares com volume mínimo)
por_par_filtrado = por_par[por_par["total"] >= 5].sort_values(
"taxa_violacao_pct", ascending=False
)
por_par_filtrado.to_csv(OUT_CSV, index=False)
print(f"Salvo → {OUT_CSV}")
print("\n── TOP 20 pares com maior taxa de violação ──")
por_par_filtrado.head(20)[[
"par_nome", "tempolimite_min", "total", "violacoes",
"taxa_violacao_pct", "espera_p50", "espera_p95"
]].to_string(index=False, float_format="{:.1f}".format)
Salvo → cache_urbs/violacoes_tempolimite.csv ── TOP 20 pares com maior taxa de violação ──
Out[5]:
' par_nome tempolimite_min total violacoes taxa_violacao_pct espera_p50 espera_p95\n INTERBAIRROS I H → RAPOSO TAVARES 120 5 0 0.0 20.8 40.9\n INTERBAIRROS I H → BRACATINGA 120 14 0 0.0 29.5 42.9\n INTERBAIRROS I H → SAVÓIA 120 18 0 0.0 27.1 41.6\n INTERBAIRROS I H → STA. FELICIDADE 120 16 0 0.0 27.1 45.5\n INTERBAIRROS I H → JÚLIO GRAF 120 5 0 0.0 16.6 35.5\n INTERBAIRROS I H → JD. ITÁLIA 120 8 0 0.0 21.4 36.2\n INTERBAIRROS I A → V. NORI 60 5 0 0.0 39.0 48.2\n INTERBAIRROS I A → BRACATINGA 120 10 0 0.0 29.1 39.6\n INTERBAIRROS I A → CABRAL / PORTÃO 120 5 0 0.0 41.8 57.1\n INTERBAIRR II H → V. NORI 60 18 0 0.0 31.2 52.6\n INTERBAIRR II H → BRACATINGA 60 23 0 0.0 33.9 50.8\n INTERB II ANTI H → V. NORI 60 8 0 0.0 41.2 50.1\n INTERB II ANTI H → BRACATINGA 60 26 0 0.0 29.1 49.3\nINTERB II ANTI H → STA. FELICIDADE / ST 60 7 0 0.0 26.0 51.3\n INTERBAIRROS IV → V. SANDRA 120 32 0 0.0 19.6 43.3\n V. NORI → INTERBAIRROS I A 60 17 0 0.0 21.6 30.6\n V. NORI → INTERBAIRR II H 60 36 0 0.0 15.3 25.4\n V. NORI → INTERB II ANTI H 60 35 0 0.0 16.8 28.8\n JD. KOSMOS → INTERBAIRR II H 60 16 0 0.0 17.1 22.6\n JD. KOSMOS → INTERB II ANTI H 60 21 0 0.0 20.0 25.4'
4. Histograma: espera_min vs. TEMPOLIMITE¶
In [6]:
# Agrupamos por TEMPOLIMITE para ver o encaixe com a distribuição
limites_comuns = sorted(df_com["tempolimite_min"].value_counts().nlargest(4).index)
fig, axes = plt.subplots(1, len(limites_comuns), figsize=(5 * len(limites_comuns), 4),
sharey=False)
if len(limites_comuns) == 1:
axes = [axes]
for ax, tl in zip(axes, limites_comuns):
sub = df_com[df_com["tempolimite_min"] == tl]["espera_min"]
pct_viola = 100 * (sub > tl).mean()
ax.hist(sub, bins=40, color="#2980b9", alpha=0.75, edgecolor="white")
ax.axvline(tl, color="#c0392b", linewidth=2, linestyle="--",
label=f"Limite = {tl} min")
ax.set_title(f"TEMPOLIMITE = {tl} min\n{pct_viola:.0f}% ultrapassam", fontsize=10)
ax.set_xlabel("Espera (min)")
ax.set_ylabel("Ocorrências" if ax == axes[0] else "")
ax.legend(fontsize=8)
fig.suptitle("Distribuição de espera_min por TEMPOLIMITE (pares COM integração oficial)",
fontsize=12, y=1.02)
plt.tight_layout()
plt.savefig("hist_tempolimite.png", dpi=150, bbox_inches="tight")
plt.show()
print("Salvo: hist_tempolimite.png")
Salvo: hist_tempolimite.png
5. Scatter: TEMPOLIMITE × mediana de espera por par¶
In [7]:
fig, ax = plt.subplots(figsize=(8, 6))
sc = ax.scatter(
por_par["tempolimite_min"],
por_par["espera_p50"],
c=por_par["taxa_violacao_pct"],
cmap="RdYlGn_r",
s=por_par["total"] * 3,
alpha=0.7,
edgecolors="gray",
linewidth=0.4
)
# Linha diagonal: espera mediana == limite (pontos acima = limite subestimado)
lim_max = max(por_par["tempolimite_min"].max(), por_par["espera_p50"].max()) + 5
ax.plot([0, lim_max], [0, lim_max], "k--", linewidth=1, alpha=0.4, label="espera = limite")
plt.colorbar(sc, ax=ax, label="Taxa de violação (%)")
ax.set_xlabel("TEMPOLIMITE oficial (min)")
ax.set_ylabel("Espera mediana observada (min)")
ax.set_title("Pares COM integração: limite oficial vs. espera real")
ax.legend()
# Anotar os pares mais críticos (acima da diagonal + alta taxa)
criticos = por_par_filtrado[por_par_filtrado["taxa_violacao_pct"] > 30].head(5)
for _, row in criticos.iterrows():
ax.annotate(
row["par_nome"][:25],
(row["tempolimite_min"], row["espera_p50"]),
fontsize=6, alpha=0.8,
xytext=(5, 3), textcoords="offset points"
)
plt.tight_layout()
plt.savefig("scatter_tempolimite.png", dpi=150, bbox_inches="tight")
plt.show()
print("Salvo: scatter_tempolimite.png")
Salvo: scatter_tempolimite.png
6. Recomendações derivadas¶
In [8]:
# Pares candidatos a aumento de TEMPOLIMITE (violação > 20% e volume >= 10)
candidatos_aumento = por_par_filtrado[
(por_par_filtrado["taxa_violacao_pct"] > 20) &
(por_par_filtrado["total"] >= 10)
][["par_nome", "tempolimite_min", "total", "taxa_violacao_pct",
"espera_p50", "espera_p75", "espera_p95"]]
print(f"Pares com integração oficial mas TEMPOLIMITE muito curto ({len(candidatos_aumento)} pares):")
print(candidatos_aumento.to_string(index=False, float_format="{:.1f}".format))
print()
print("SUGESTÃO: ajustar TEMPOLIMITE para o P75 da espera observada nestes pares.")
print("Isso cobriria ~75% dos usuários sem custo de criar nova integração.")
Pares com integração oficial mas TEMPOLIMITE muito curto (0 pares): Empty DataFrame Columns: [par_nome, tempolimite_min, total, taxa_violacao_pct, espera_p50, espera_p75, espera_p95] Index: [] SUGESTÃO: ajustar TEMPOLIMITE para o P75 da espera observada nestes pares. Isso cobriria ~75% dos usuários sem custo de criar nova integração.
7. Interpretação¶
Pares acima da diagonal (scatter): a espera mediana já supera o limite oficial — mais da metade paga tarifa dupla mesmo com integração.
| Situação | Ação |
|---|---|
Violação >20%, espera_p75 < 2×tempolimite |
Aumentar TEMPOLIMITE para o P75 observado |
Violação >20%, espera_p75 ≥ 2×tempolimite |
Investigar operação (intervalo do ônibus longo? cobertura?) |
Diferença da proposta de novas integrações: aqui o par já está na matriz — é ajuste administrativo (baixo custo político), não ausência de política.