04 — Análise Temporal dos Transbordos Sem Integração¶
Segmenta as baldeações fisicamente plausíveis (até 1 km) sem integração por período do dia e tipo de dia (útil vs. fim de semana): quais pares são pendulares (pico) vs. lazer (fds)? Em que horário a rede "dói" mais?
In [1]:
import json
from pathlib import Path
import numpy as np, pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
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_RANKING = str(Path(PARQUET_TB).parent / "ranking_temporal.parquet")
# feriados de janeiro/2026 em Curitiba (tratados como fim de semana)
FERIADOS = ["2026-01-01", "2026-01-20", "2026-01-25"]
print("parquet:", PARQUET_TB)
parquet: cache_urbs/transbordos_2026-01-01_2026-01-31.parquet
1. Carregar e enriquecer¶
In [2]:
df = pd.read_parquet(PARQUET_TB)
print(f"transbordos totais: {len(df):,}")
print(df.dtypes)
df.head(2)
transbordos totais: 23,562 NUMEROCARTAO str CODLINHA_ORIGEM str NOMELINHA_ORIGEM str CODVEICULO_ORIGEM str DATA_HORA_ORIGEM datetime64[us] LATITUDE_ORIGEM float32 LONGITUDE_ORIGEM float32 CODLINHA_DESTINO str NOMELINHA_DESTINO str CODVEICULO_DESTINO str DATA_HORA_DESTINO datetime64[us] LATITUDE_DESTINO float32 LONGITUDE_DESTINO float32 espera_min float64 gap_validacao_km float64 transbordo_fisicamente_plausivel bool desemb_lat float64 desemb_lon float64 gap_desembarque_km float64 dist_linha_km float64 baldeacao_real bool dtype: object
Out[2]:
| NUMEROCARTAO | CODLINHA_ORIGEM | NOMELINHA_ORIGEM | CODVEICULO_ORIGEM | DATA_HORA_ORIGEM | LATITUDE_ORIGEM | LONGITUDE_ORIGEM | CODLINHA_DESTINO | NOMELINHA_DESTINO | CODVEICULO_DESTINO | ... | LATITUDE_DESTINO | LONGITUDE_DESTINO | espera_min | gap_validacao_km | transbordo_fisicamente_plausivel | desemb_lat | desemb_lon | gap_desembarque_km | dist_linha_km | baldeacao_real | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 65587000602813189 | 680 | RURBANA | HA192 | 2026-01-13 17:56:27 | -25.565083 | -49.315193 | 680 | RURBANA | HA192 | ... | -25.584700 | -49.318356 | 33.416667 | 2.204264 | False | -25.584463 | -49.319040 | 0.073692 | 2.189396 | False |
| 1 | 65587000604257029 | 528 | BOQUEIRÃO/PINHEIRINHO | GI601 | 2026-01-09 06:44:29 | -25.556835 | -49.282822 | 528 | BOQUEIRÃO/PINHEIRINHO | GI601 | ... | -25.546194 | -49.293713 | 6.633333 | 1.610523 | False | -25.547451 | -49.293365 | 0.144102 | 1.485875 | False |
2 rows × 21 columns
In [3]:
# ── detectar nome da coluna de timestamp de origem ───────────────────────────
TS_COL = next((c for c in df.columns
if "HORA" in c.upper() and "ORIGEM" in c.upper()), None)
if TS_COL is None:
# fallback: primeira coluna de datetime
TS_COL = df.select_dtypes(include="datetime").columns[0]
print("coluna de timestamp:", TS_COL)
ts = pd.to_datetime(df[TS_COL])
df["hora"] = ts.dt.hour
df["dia_semana"] = ts.dt.dayofweek # 0=Seg … 6=Dom
df["data"] = ts.dt.date.astype(str)
# Tipo de dia: feriados tratados como fim de semana
df["tipo_dia"] = df.apply(
lambda r: "fds" if r["dia_semana"] >= 5 or r["data"] in FERIADOS else "util",
axis=1
)
# Período do dia
bins = [0, 6, 9, 12, 15, 19, 24]
labels = ["madrugada", "pico_manha", "meia_manha", "almoco", "pico_tarde", "noturno"]
df["periodo"] = pd.cut(df["hora"], bins=bins, labels=labels, right=False)
# Matriz oficial → pares COM integração
pares_oficiais = carregar_pares_oficiais(MATRIZ_JSON)
df = adicionar_plausibilidade_fisica(df, GAP_VALIDACAO_MAX_KM)
# Coluna par e flag sem integração
COD_O = next(c for c in df.columns if "CODLINHA" in c and "ORIG" in c.upper())
COD_D = next(c for c in df.columns if "CODLINHA" in c and "DEST" in c.upper())
df["par"] = list(zip(df[COD_O].astype(str), df[COD_D].astype(str)))
df["baldeacao_temporal"] = df[COD_O].astype(str) != df[COD_D].astype(str)
df["baldeacao"] = df["baldeacao_real"]
df["sem_integracao"] = df["baldeacao"] & ~df["par"].isin(pares_oficiais)
df_sem = df[df["sem_integracao"]].copy()
print(f"baldeações temporais: {df['baldeacao_temporal'].sum():,}")
print(f"baldeações fisicamente plausíveis (<= {GAP_VALIDACAO_MAX_KM:.1f} km) sem integração: {len(df_sem):,}")
df_sem[["hora", "tipo_dia", "periodo"]].value_counts().head(10)
coluna de timestamp: DATA_HORA_ORIGEM
baldeações temporais: 8,581 baldeações fisicamente plausíveis (<= 1.0 km) sem integração: 3,895
Out[3]:
hora tipo_dia periodo 6 util pico_manha 322 7 util pico_manha 315 16 util pico_tarde 248 10 util meia_manha 243 9 util meia_manha 241 15 util pico_tarde 228 8 util pico_manha 223 11 util meia_manha 219 14 util almoco 218 12 util almoco 212 Name: count, dtype: int64
2. Ranking por período¶
In [4]:
# Nome de exibição do par (ex.: "701 FAZENDINHA ↔ 703 CAIUÁ")
NOME_O = next((c for c in df.columns if "NOME" in c.upper() and "ORIG" in c.upper()), COD_O)
NOME_D = next((c for c in df.columns if "NOME" in c.upper() and "DEST" in c.upper()), COD_D)
df_sem["par_und"] = [
" ↔ ".join(sorted([str(o), str(d)]))
for o, d in zip(df_sem[NOME_O].astype(str), df_sem[NOME_D].astype(str))
]
ranking_temporal = (
df_sem
.groupby(["par_und", "periodo", "tipo_dia"], observed=True)
.size()
.reset_index(name="volume")
.sort_values("volume", ascending=False)
)
ranking_temporal.to_parquet(OUT_RANKING, index=False)
print(f"ranking_temporal salvo → {OUT_RANKING}")
ranking_temporal.head(15)
ranking_temporal salvo → cache_urbs/ranking_temporal.parquet
Out[4]:
| par_und | periodo | tipo_dia | volume | |
|---|---|---|---|---|
| 488 | BRACATINGA ↔ PRIMAVERA | meia_manha | util | 20 |
| 1596 | INTERBAIRROS IV ↔ SAVÓIA | pico_manha | util | 15 |
| 729 | CAIUÁ ↔ FAZENDINHA | meia_manha | util | 15 |
| 498 | BRACATINGA ↔ SAVÓIA | pico_manha | util | 14 |
| 828 | CAMP.SIQ./BATEL ↔ DETRAN/V.MACHADO | pico_manha | util | 12 |
| 1597 | INTERBAIRROS IV ↔ SAVÓIA | meia_manha | util | 11 |
| 1598 | INTERBAIRROS IV ↔ SAVÓIA | almoco | util | 11 |
| 1599 | INTERBAIRROS IV ↔ SAVÓIA | pico_tarde | util | 11 |
| 492 | BRACATINGA ↔ PRIMAVERA | pico_tarde | util | 10 |
| 728 | CAIUÁ ↔ FAZENDINHA | pico_manha | util | 10 |
| 730 | CAIUÁ ↔ FAZENDINHA | almoco | util | 10 |
| 2441 | STA. RITA / CIC ↔ TRABALHADOR | pico_tarde | util | 9 |
| 515 | BRACATINGA ↔ V. NORI | meia_manha | fds | 9 |
| 516 | BRACATINGA ↔ V. NORI | meia_manha | util | 8 |
| 1570 | INTERBAIRROS IV ↔ MONTANA | pico_manha | util | 8 |
In [5]:
# TOP 10 pares por volume total (independente de período)
top10 = (
df_sem.groupby("par_und", observed=True)
.size()
.nlargest(10)
.index.tolist()
)
print("Top 10 pares:", top10[:3], "...")
Top 10 pares: ['INTERBAIRROS IV ↔ SAVÓIA', 'BRACATINGA ↔ PRIMAVERA', 'CAIUÁ ↔ FAZENDINHA'] ...
3. Barras empilhadas: TOP 10 pares × período¶
In [6]:
COR_PERIODO = {
"madrugada": "#2c3e50",
"pico_manha": "#e74c3c",
"meia_manha": "#f39c12",
"almoco": "#27ae60",
"pico_tarde": "#c0392b",
"noturno": "#8e44ad",
}
pivot = (
df_sem[df_sem["par_und"].isin(top10)]
.groupby(["par_und", "periodo"], observed=True)
.size()
.unstack(fill_value=0)
.reindex(top10) # ordem do ranking
.reindex(columns=labels) # ordem temporal
)
fig, ax = plt.subplots(figsize=(14, 6))
bottom = np.zeros(len(pivot))
for periodo in labels:
vals = pivot[periodo].values
ax.bar(range(len(pivot)), vals, bottom=bottom,
label=periodo, color=COR_PERIODO[periodo])
bottom += vals
ax.set_xticks(range(len(pivot)))
ax.set_xticklabels(
[p.replace(" ↔ ", "\n↔\n") for p in pivot.index],
fontsize=7, ha="center"
)
ax.set_ylabel("Baldeações sem integração")
ax.set_title("Top 10 pares sem integração por período do dia (jan/2026)")
ax.legend(title="Período", bbox_to_anchor=(1.01, 1), loc="upper left", fontsize=8)
plt.tight_layout()
plt.savefig("barras_periodo.png", dpi=150, bbox_inches="tight")
plt.show()
print("Salvo: barras_periodo.png")
Salvo: barras_periodo.png
4. Heatmap hora × dia da semana¶
In [7]:
hm = (
df_sem
.groupby(["dia_semana", "hora"])
.size()
.unstack(fill_value=0)
.reindex(range(7), fill_value=0) # Seg=0 … Dom=6
)
dias_nomes = ["Seg","Ter","Qua","Qui","Sex","Sáb","Dom"]
fig, ax = plt.subplots(figsize=(16, 4))
sns.heatmap(
hm, ax=ax,
cmap="YlOrRd",
linewidths=0.3,
yticklabels=dias_nomes,
cbar_kws={"label": "Baldeações sem integração"}
)
ax.set_xlabel("Hora do dia")
ax.set_ylabel("Dia da semana")
ax.set_title("'Horário de dor' da rede — baldeações sem integração (jan/2026)")
# Linhas verticais nos picos
for h in [7, 17]:
ax.axvline(h + 0.5, color="dodgerblue", linewidth=1.5, linestyle="--", alpha=0.7)
plt.tight_layout()
plt.savefig("heatmap_hora_dia.png", dpi=150, bbox_inches="tight")
plt.show()
print("Salvo: heatmap_hora_dia.png")
Salvo: heatmap_hora_dia.png
5. Série diária com média móvel e feriados¶
In [8]:
serie = (
df_sem
.groupby("data")
.size()
.reset_index(name="baldeacoes_sem_integracao")
.sort_values("data")
)
serie["data_dt"] = pd.to_datetime(serie["data"])
serie["mm7"] = serie["baldeacoes_sem_integracao"].rolling(7, min_periods=1, center=True).mean()
fig, ax = plt.subplots(figsize=(14, 4))
ax.bar(serie["data_dt"], serie["baldeacoes_sem_integracao"],
color="#c0392b", alpha=0.6, label="Diário")
ax.plot(serie["data_dt"], serie["mm7"],
color="#2c3e50", linewidth=2, label="Média móvel 7 dias")
# Marcar feriados
for f in FERIADOS:
fd = pd.to_datetime(f)
ax.axvline(fd, color="orange", linestyle=":", linewidth=1.5)
ax.text(fd, ax.get_ylim()[1]*0.92, f.split("-")[2]+"/01",
ha="center", fontsize=7, color="darkorange")
# Sombrear fins de semana
for _, row in serie.iterrows():
if row["data_dt"].dayofweek >= 5:
ax.axvspan(row["data_dt"] - pd.Timedelta(hours=12),
row["data_dt"] + pd.Timedelta(hours=12),
alpha=0.08, color="gray")
ax.set_xlabel("Data")
ax.set_ylabel("Baldeações plausíveis sem integração")
ax.set_title("Série diária — baldeações plausíveis sem integração (jan/2026)")
ax.legend()
plt.tight_layout()
plt.savefig("serie_diaria.png", dpi=150, bbox_inches="tight")
plt.show()
print("Salvo: serie_diaria.png")
Salvo: serie_diaria.png
6. Interpretação¶
- Pico manhã (7–9h, dias úteis) concentra o maior volume sem integração → impacto pendular direto; pico tarde (17–19h) vem em seguida (casa↔trabalho).
- Fins de semana: distribuição mais plana → padrão lazer, menor urgência tarifária.
- Pares como 701↔703 e 020↔021 dominam nos picos — candidatos prioritários.
- Política: priorizar pares com >60% do volume em pico (dias úteis) — maximiza impacto sobre o trabalhador pendular.