08 — Comparativo entre meses (sazonalidade)¶
Opcional / requer 2 meses. Compara métricas de transbordo/integração entre meses (ex.: jan × fev) usando os
usuarios_limpo_*.parquet. Responde à ressalva de sazonalidade. Só roda quando o parquet de cada mês existir (o de fevereiro pode ainda não ter sido gerado).
In [1]:
import os, json
from pathlib import Path
import duckdb, pandas as pd
import numpy as np
from transbordo_utils import resolver_cache, carregar_pares_oficiais, carregar_paradas, adicionar_baldeacao_real
os.makedirs("/tmp/duckdb_spill", exist_ok=True)
MATRIZ = resolver_cache("matriz_integracao.json")
PARES = carregar_pares_oficiais(MATRIZ)
STOPS = carregar_paradas(resolver_cache("itinerarios/pontos"))
# Meses a comparar (parquet de usuarios por mês). Ajuste se faltar algum.
MESES = {
"jan": "usuarios_limpo_2026-01-01_2026-01-31.parquet",
"fev": "usuarios_limpo_2026-02-01_2026-02-28.parquet",
}
JANELA_MIN, JANELA_MAX = 3, 60
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()
In [2]:
def analisar(parquet_nome):
pq = resolver_cache(parquet_nome)
con = duckdb.connect(config={"memory_limit":"500MB","temp_directory":"/tmp/duckdb_spill","threads":"2"})
od = con.execute(f"""
WITH seq AS (
SELECT NUMEROCARTAO, CODLINHA, DATA_HORA_UTILIZACAO dh, LATITUDE, LONGITUDE,
LEAD(CODLINHA) OVER w cd, LEAD(DATA_HORA_UTILIZACAO) OVER w dhd,
LEAD(LATITUDE) OVER w lat_d, LEAD(LONGITUDE) OVER w lon_d
FROM read_parquet('{pq}')
WINDOW w AS (PARTITION BY NUMEROCARTAO ORDER BY DATA_HORA_UTILIZACAO))
SELECT CODLINHA CODLINHA_ORIGEM, cd CODLINHA_DESTINO,
LATITUDE LATITUDE_ORIGEM, LONGITUDE LONGITUDE_ORIGEM, lat_d LATITUDE_DESTINO, lon_d LONGITUDE_DESTINO,
date_diff('second', dh, dhd)/60.0 espera_min
FROM seq WHERE dhd IS NOT NULL
AND date_diff('second', dh, dhd)/60.0 BETWEEN {JANELA_MIN} AND {JANELA_MAX}
""").df()
n_user = con.execute(f"SELECT count(*) FROM read_parquet('{pq}')").fetchone()[0]
con.close()
od["CODLINHA_ORIGEM"]=od["CODLINHA_ORIGEM"].astype(str); od["CODLINHA_DESTINO"]=od["CODLINHA_DESTINO"].astype(str)
# coordenadas fora de Curitiba -> NaN (mesmo tratamento do 02)
bb = lambda la, lo: la.between(-25.75, -25.25) & lo.between(-49.55, -49.10)
for p in ("ORIGEM", "DESTINO"):
ok = bb(od[f"LATITUDE_{p}"], od[f"LONGITUDE_{p}"])
od.loc[~ok, [f"LATITUDE_{p}", f"LONGITUDE_{p}"]] = np.nan
mesma = [tuple(sorted([o, d])) in PARES_MESMA for o, d in zip(od["CODLINHA_ORIGEM"], od["CODLINHA_DESTINO"])]
od = od[[not x for x in mesma]].reset_index(drop=True)
od = adicionar_baldeacao_real(od, STOPS) # desembarque estimado (REGRAS §3)
bald_temporal = od[od["CODLINHA_ORIGEM"] != od["CODLINHA_DESTINO"]]
bald = od[od["baldeacao_real"]].copy()
bald["o"], bald["d"] = bald["CODLINHA_ORIGEM"], bald["CODLINHA_DESTINO"]
bald["par"]=list(zip(bald["o"],bald["d"]))
com = bald["par"].isin(PARES)
return {
"registros_usuario": n_user,
"transbordos": len(od),
"baldeacoes_temporais": len(bald_temporal),
"baldeacoes": len(bald),
"com_integracao": int(com.sum()),
"sem_integracao": int((~com).sum()),
"_bald": bald, "_com": com,
}
In [3]:
res = {}
for nome, pq in MESES.items():
try:
res[nome] = analisar(pq)
print(f"{nome}: ok")
except FileNotFoundError:
print(f"{nome}: parquet ainda não existe ({pq}) — pule até a coleta terminar")
jan: ok
fev: ok
In [4]:
# --- Tabela comparativa ---
rows=[]
for nome, r in res.items():
b=r["baldeacoes"]
rows.append({"mes":nome, "reg_usuario":r["registros_usuario"], "transbordos":r["transbordos"],
"baldeacoes":b, "com_integ":r["com_integracao"], "sem_integ":r["sem_integracao"],
"%_sem_integ": round(100*r["sem_integracao"]/max(b,1),1)})
comp = pd.DataFrame(rows).set_index("mes")
display(comp)
if len(comp)==2:
a,b = comp.index[0], comp.index[1]
print(f"\nVariação {b} vs {a}:")
for col in ["reg_usuario","transbordos","baldeacoes","sem_integ"]:
va,vb=comp.loc[a,col],comp.loc[b,col]
print(f" {col}: {va} -> {vb} ({100*(vb-va)/max(va,1):+.0f}%)")
| reg_usuario | transbordos | baldeacoes | com_integ | sem_integ | %_sem_integ | |
|---|---|---|---|---|---|---|
| mes | ||||||
| jan | 1102970 | 23562 | 5084 | 1189 | 3895 | 76.6 |
| fev | 1396670 | 26323 | 6234 | 825 | 5409 | 86.8 |
Variação fev vs jan: reg_usuario: 1102970 -> 1396670 (+27%) transbordos: 23562 -> 26323 (+12%) baldeacoes: 5084 -> 6234 (+23%) sem_integ: 3895 -> 5409 (+39%)
In [5]:
# --- Top pares SEM integração: jan vs fev (estabilidade dos candidatos) ---
def ranking(nome):
r=res[nome]; sem=r["_bald"][~r["_com"]]
g=sem.groupby(["o","d"]).size().reset_index(name=f"qtd_{nome}")
g["par"]=g["o"]+"->"+g["d"]
return g[["par",f"qtd_{nome}"]]
if len(res)==2:
ms=list(res.keys())
m=ranking(ms[0]).merge(ranking(ms[1]), on="par", how="outer").fillna(0)
m["total"]=m[f"qtd_{ms[0]}"]+m[f"qtd_{ms[1]}"]
m=m.sort_values("total",ascending=False).head(20)
print("Top 20 pares sem integração (presença nos 2 meses):")
display(m)
else:
print("Comparação de pares requer os 2 meses.")
Top 20 pares sem integração (presença nos 2 meses):
| par | qtd_jan | qtd_fev | total | |
|---|---|---|---|---|
| 3233 | 967->205 | 10.0 | 40.0 | 50.0 |
| 3099 | 876->205 | 17.0 | 29.0 | 46.0 |
| 3042 | 870->040 | 13.0 | 32.0 | 45.0 |
| 3090 | 876->040 | 33.0 | 11.0 | 44.0 |
| 421 | 171->170 | 35.0 | 8.0 | 43.0 |
| 1268 | 380->801 | 15.0 | 28.0 | 43.0 |
| 2406 | 701->703 | 18.0 | 23.0 | 41.0 |
| 2445 | 703->701 | 24.0 | 13.0 | 37.0 |
| 2828 | 822->040 | 12.0 | 22.0 | 34.0 |
| 1843 | 609->679 | 8.0 | 25.0 | 33.0 |
| 149 | 040->876 | 21.0 | 8.0 | 29.0 |
| 412 | 170->901 | 22.0 | 6.0 | 28.0 |
| 2415 | 703->040 | 7.0 | 21.0 | 28.0 |
| 1067 | 366->380 | 0.0 | 26.0 | 26.0 |
| 299 | 166->170 | 24.0 | 2.0 | 26.0 |
| 3257 | 967->965 | 3.0 | 21.0 | 24.0 |
| 2804 | 812->040 | 15.0 | 8.0 | 23.0 |
| 3217 | 965->967 | 2.0 | 21.0 | 23.0 |
| 148 | 040->870 | 7.0 | 16.0 | 23.0 |
| 961 | 345->030 | 10.0 | 13.0 | 23.0 |