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