Integrações Tarifárias URBS — Relatório Final¶
Curitiba, janeiro/2026¶
Objetivo: Identificar pares de linhas de ônibus de Curitiba onde passageiros realizam baldeações fisicamente plausíveis mas não têm integração tarifária oficial, pagando duas tarifas em uma única jornada.
Metodologia: Dados de smartcard (Cartão Transporte URBS), jan/2026. Transbordo temporal detectado quando o mesmo cartão usa duas linhas diferentes com intervalo de 3 a 60 minutos. A análise principal mantém apenas casos fisicamente plausíveis, com validações consecutivas a até 1 km. Integração oficial verificada contra a matriz_integracao.json (estado Liberada).
In [1]:
import os, json, warnings
from pathlib import Path
import numpy as np, pandas as pd
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import folium
from transbordo_utils import (GAP_VALIDACAO_MAX_KM, adicionar_plausibilidade_fisica,
resolver_cache, carregar_pares_oficiais, classificar_regional_oficial)
warnings.filterwarnings("ignore")
PARQUET_TB = resolver_cache("transbordos_2026-01-01_2026-01-31.parquet")
MATRIZ_JSON = resolver_cache("matriz_integracao.json")
PROPOSTA_CSV = "proposta_novas_integracoes.csv"
SCORE_CSV = "proposta_score_ajustado.csv"
_cache = Path(PARQUET_TB).parent
VIOLACOES_CSV = str(_cache / "violacoes_tempolimite.csv")
CADEIAS_PQ = str(_cache / "cadeias_multiplas.parquet")
TARIFA = 6.00 # valor pago em 97% das validações pagas (jan/2026)
print("Arquivos encontrados:")
for p in [PARQUET_TB, PROPOSTA_CSV, SCORE_CSV, VIOLACOES_CSV, CADEIAS_PQ]:
print(f" {chr(10003) if os.path.exists(p) else chr(10007)} {os.path.basename(p)}")
Arquivos encontrados: ✓ transbordos_2026-01-01_2026-01-31.parquet ✓ proposta_novas_integracoes.csv ✓ proposta_score_ajustado.csv ✓ violacoes_tempolimite.csv ✓ cadeias_multiplas.parquet
1. Números gerais¶
In [2]:
df = adicionar_plausibilidade_fisica(pd.read_parquet(PARQUET_TB), GAP_VALIDACAO_MAX_KM)
pares_of = carregar_pares_oficiais(MATRIZ_JSON)
df["bald_temporal"] = df["CODLINHA_ORIGEM"] != df["CODLINHA_DESTINO"]
df["bald"] = df["baldeacao_real"]
df["par_dir"] = list(zip(df["CODLINHA_ORIGEM"].astype(str), df["CODLINHA_DESTINO"].astype(str)))
df["com_of"] = df["par_dir"].isin(pares_of)
df_bald_temporal = df[df["bald_temporal"]]
df_bald = df[df["bald"]]
df_sem = df_bald[~df_bald["com_of"]]
total_tb = len(df)
total_bald_temporal = len(df_bald_temporal)
total_bald = len(df_bald)
total_sem = len(df_sem)
total_com = total_bald - total_sem
custo_extra = total_sem * TARIFA
print("=" * 55)
print(f" Transbordos temporais (jan/2026): {total_tb:>10,}")
print(f" Baldeações temporais: {total_bald_temporal:>10,}")
print(f" Baldeações plausíveis <= {GAP_VALIDACAO_MAX_KM:.1f} km: {total_bald:>10,}")
print(f" COM integração oficial: {total_com:>10,} ({100*total_com/max(total_bald,1):.0f}%)")
print(f" SEM integração oficial: {total_sem:>10,} ({100*total_sem/max(total_bald,1):.0f}%)")
print("-" * 55)
print(f" Custo extra mensal estimado: R$ {custo_extra:>10,.0f}")
print(f" (com tarifa de R$ {TARIFA:.2f} por uso adicional)")
print("=" * 55)
======================================================= Transbordos temporais (jan/2026): 23,562 Baldeações temporais: 8,581 Baldeações plausíveis <= 1.0 km: 5,084 COM integração oficial: 1,189 (23%) SEM integração oficial: 3,895 (77%) ------------------------------------------------------- Custo extra mensal estimado: R$ 23,370 (com tarifa de R$ 6.00 por uso adicional) =======================================================
2. Top 30 propostas (score ajustado)¶
In [3]:
def _cod_nome(_cod, _nome):
return f"{str(_cod).strip()} {str(_nome).strip()}"
rank_base = df_bald.dropna(subset=["LATITUDE_DESTINO", "LONGITUDE_DESTINO"]).copy()
rank_base["cod_o"] = rank_base["CODLINHA_ORIGEM"].astype(str)
rank_base["cod_d"] = rank_base["CODLINHA_DESTINO"].astype(str)
rank_base["par_key"] = rank_base.apply(lambda r: tuple(sorted([r["cod_o"], r["cod_d"]])), axis=1)
rank_base["linha_o"] = rank_base.apply(lambda r: _cod_nome(r["CODLINHA_ORIGEM"], r["NOMELINHA_ORIGEM"]), axis=1)
rank_base["linha_d"] = rank_base.apply(lambda r: _cod_nome(r["CODLINHA_DESTINO"], r["NOMELINHA_DESTINO"]), axis=1)
_nomes_linha = {}
for _cod in pd.unique(pd.concat([rank_base["cod_o"], rank_base["cod_d"]])):
_vals = pd.concat([
rank_base.loc[rank_base["cod_o"] == _cod, "linha_o"],
rank_base.loc[rank_base["cod_d"] == _cod, "linha_d"],
])
_nomes_linha[_cod] = _vals.mode().iat[0] if len(_vals.mode()) else str(_cod)
prop = (rank_base.groupby("par_key")
.agg(volume=("par_key", "size"), cartoes=("NUMEROCARTAO", "nunique"),
lat_m=("LATITUDE_DESTINO", "mean"), lon_m=("LONGITUDE_DESTINO", "mean"),
dist_med=("dist_linha_km", "mean"))
.reset_index())
prop["oficial"] = prop["par_key"].apply(lambda p: (p[0], p[1]) in pares_of)
prop["par_linhas"] = prop["par_key"].apply(lambda p: f"{_nomes_linha.get(p[0], p[0])} <-> {_nomes_linha.get(p[1], p[1])}")
prop["regiao"] = prop.apply(lambda r: classificar_regional_oficial(r["lat_m"], r["lon_m"]), axis=1)
prop["pct_demanda_observada"] = 100 * prop["volume"] / max(prop["volume"].sum(), 1)
prop["score"] = 100 * prop["volume"] / max(prop["volume"].max(), 1)
prop = prop.sort_values(["volume", "cartoes"], ascending=False).reset_index(drop=True)
prop["rank"] = np.arange(1, len(prop) + 1)
col_score = "score"
top40 = prop.head(40).copy()
top40["prioridade"] = pd.cut(
top40[col_score],
bins=[0, 33, 66, 101],
labels=["Baixa", "Media", "Alta"]
)
display_cols = ["par_linhas", "oficial", "regiao", "volume", "cartoes", col_score, "prioridade"]
if "dist_med" in top40.columns:
display_cols.insert(4, "dist_med")
print("TOP 40 PARES — USO OBSERVADO DE INTEGRAÇÕES E BALDEAÇÕES")
print(top40[display_cols].to_string(index=False, float_format="{:.1f}".format))
TOP 40 PARES — USO OBSERVADO DE INTEGRAÇÕES E BALDEAÇÕES
par_linhas oficial regiao volume dist_med cartoes score prioridade
021 INTERB II ANTI H <-> 170 BRACATINGA True Regional Boa Vista 133 2.1 96 100.0 Alta
040 INTERBAIRROS IV <-> 860 V. SANDRA True Regional Cic 98 2.4 82 73.7 Alta
020 INTERBAIRR II H <-> 170 BRACATINGA True Regional Boa Vista 87 2.4 70 65.4 Media
020 INTERBAIRR II H <-> 166 V. NORI True Regional Boa Vista 54 2.7 51 40.6 Media
040 INTERBAIRROS IV <-> 876 SAVÓIA False Regional Santa Felicidade 54 3.1 36 40.6 Media
170 BRACATINGA <-> 924 STA. FELICIDADE / STA. CÂNDIDA True Regional Boa Vista 49 2.4 38 36.8 Media
170 BRACATINGA <-> 171 PRIMAVERA False Regional Matriz 47 2.8 46 35.3 Media
021 INTERB II ANTI H <-> 166 V. NORI True Regional Boa Vista 43 1.9 40 32.3 Baixa
701 FAZENDINHA <-> 703 CAIUÁ False Regional Portão 42 2.5 39 31.6 Baixa
021 INTERB II ANTI H <-> 924 STA. FELICIDADE / STA. CÂNDIDA True Regional Boa Vista 40 2.6 38 30.1 Baixa
010 INTERBAIRROS I H <-> 170 BRACATINGA True Regional Matriz 39 3.6 23 29.3 Baixa
166 V. NORI <-> 170 BRACATINGA False Regional Matriz 34 2.4 33 25.6 Baixa
060 INTERBAIRROS VI <-> 827 RIVIERA True Regional Cic 29 1.5 21 21.8 Baixa
021 INTERB II ANTI H <-> 171 PRIMAVERA True Regional Boa Vista 28 1.4 24 21.1 Baixa
021 INTERB II ANTI H <-> 169 JD. KOSMOS True Regional Boa Vista 24 1.2 23 18.0 Baixa
170 BRACATINGA <-> 901 STA. FELICIDADE False Regional Matriz 24 5.2 13 18.0 Baixa
170 BRACATINGA <-> 876 SAVÓIA False Regional Matriz 23 5.4 9 17.3 Baixa
011 INTERBAIRROS I A <-> 166 V. NORI True Regional Matriz 22 3.0 17 16.5 Baixa
011 INTERBAIRROS I A <-> 170 BRACATINGA True Regional Matriz 21 2.9 14 15.8 Baixa
011 INTERBAIRROS I A <-> 184 V. SUIÇA True Regional Matriz 21 4.4 13 15.8 Baixa
040 INTERBAIRROS IV <-> 870 SÃO BRAZ False Regional Santa Felicidade 20 2.2 20 15.0 Baixa
040 INTERBAIRROS IV <-> 861 TRAMONTINA True Regional Cic 20 3.2 18 15.0 Baixa
010 INTERBAIRROS I H <-> 876 SAVÓIA True Regional Matriz 20 2.9 16 15.0 Baixa
040 INTERBAIRROS IV <-> 822 GABINETO False Regional Cic 19 1.5 16 14.3 Baixa
205 BARREIRINHA <-> 876 SAVÓIA False Regional Matriz 19 2.6 15 14.3 Baixa
828 C.COMP/C.RASO <-> 860 V. SANDRA True Regional Cic 18 2.8 17 13.5 Baixa
901 STA. FELICIDADE <-> 967 JÚLIO GRAF False Regional Matriz 18 1.6 17 13.5 Baixa
528 BOQUEIRÃO/PINHEIRINHO <-> 641 LUIZ NICHELE False Regional Pinheirinho 18 2.0 16 13.5 Baixa
020 INTERBAIRR II H <-> 169 JD. KOSMOS True Regional Boa Vista 17 1.4 17 12.8 Baixa
030 INTERBAIRROS III <-> 338 CENTENÁRIO/HAUER False Regional Cajuru 17 1.7 15 12.8 Baixa
372 TARUMÃ <-> 862 BARIGUI True Regional Matriz 17 4.7 14 12.8 Baixa
826 C.COMPRIDO/CIC <-> 827 RIVIERA True Regional Cic 16 1.9 16 12.0 Baixa
030 INTERBAIRROS III <-> 345 B ALTO/ATUBA False Regional Boa Vista 16 1.4 14 12.0 Baixa
040 INTERBAIRROS IV <-> 812 MONTANA False Regional Santa Felicidade 16 1.2 14 12.0 Baixa
010 INTERBAIRROS I H <-> 901 STA. FELICIDADE True Regional Matriz 16 3.2 13 12.0 Baixa
166 V. NORI <-> 168 RAPOSO TAVARES False Regional Matriz 16 4.5 12 12.0 Baixa
171 PRIMAVERA <-> 924 STA. FELICIDADE / STA. CÂNDIDA True Regional Boa Vista 16 2.1 12 12.0 Baixa
380 DETRAN/V.MACHADO <-> 801 CAMP.SIQ./BATEL False Regional Matriz 16 3.0 10 12.0 Baixa
050 INTERBAIRROS V <-> 216 CABRAL / PORTÃO False Regional Portão 15 2.7 15 11.3 Baixa
011 INTERBAIRROS I A <-> 901 STA. FELICIDADE True Regional Matriz 15 3.1 14 11.3 Baixa
3. Painel de visualizações¶
In [4]:
COR_REG = {
"Centro": "#8e44ad", "Oeste/CIC": "#e74c3c", "Norte": "#2980b9",
"Sul": "#27ae60", "Leste": "#f39c12", "Oeste": "#16a085",
"Sul-Leste": "#d35400", "Sul-Oeste": "#7f8c8d",
"Norte-Leste": "#2ecc71", "Norte-Oeste": "#c0392b",
}
fig, axes = plt.subplots(1, 2, figsize=(16, 9))
ax = axes[0]
top_plot = top40.head(40)
COR_PRIO = {"Alta": "#c0392b", "Media": "#e67e22", "Baixa": "#f1c40f"}
cores_bar = top_plot["prioridade"].astype(str).map(COR_PRIO).fillna("#95a5a6")
ax.barh(range(len(top_plot)), top_plot[col_score], color=cores_bar, alpha=0.85)
ax.set_yticks(range(len(top_plot)))
ax.set_yticklabels(top_plot["par_linhas"].str[:35], fontsize=6)
ax.invert_yaxis()
ax.set_xlabel("Score")
ax.set_title("Top 40 usos observados de integração/baldeação")
from matplotlib.patches import Patch
ax.legend(
handles=[Patch(color=c, label=l) for l, c in COR_PRIO.items()],
title="Prioridade", loc="lower right", fontsize=7
)
ax2 = axes[1]
por_reg = prop.groupby("regiao")["volume"].sum().sort_values(ascending=False)
cores_reg = [COR_REG.get(r, "#95a5a6") for r in por_reg.index]
ax2.bar(range(len(por_reg)), por_reg.values, color=cores_reg, alpha=0.85)
ax2.set_xticks(range(len(por_reg)))
ax2.set_xticklabels(por_reg.index, rotation=30, ha="right", fontsize=8)
ax2.set_ylabel("Baldeações observadas")
ax2.set_title("Volume por região")
ax2.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"{int(x):,}"))
plt.suptitle("Análise de Integrações Tarifárias — Curitiba (jan/2026)", fontsize=13, y=1.02)
plt.tight_layout()
plt.savefig("painel_relatorio.png", dpi=150, bbox_inches="tight")
plt.show()
print("Salvo: painel_relatorio.png")
Salvo: painel_relatorio.png
4. Violação do TEMPOLIMITE¶
In [5]:
if os.path.exists(VIOLACOES_CSV):
viol = pd.read_csv(VIOLACOES_CSV)
n_criticos = (viol["taxa_violacao_pct"] > 20).sum()
print(f"Pares COM integração oficial mas TEMPOLIMITE muito curto: {n_criticos}")
print(viol[viol["taxa_violacao_pct"] > 20].head(10)[
["par_nome","tempolimite_min","total","taxa_violacao_pct","espera_p50"]
].to_string(index=False, float_format="{:.1f}".format))
else:
print("Execute 05_tempolimite.ipynb para obter esta análise.")
Pares COM integração oficial mas TEMPOLIMITE muito curto: 0 Empty DataFrame Columns: [par_nome, tempolimite_min, total, taxa_violacao_pct, espera_p50] Index: []
5. Cadeias múltiplas¶
In [6]:
if os.path.exists(CADEIAS_PQ):
cad = pd.read_parquet(CADEIAS_PQ)
dist_cad = cad["n_linhas"].value_counts().sort_index()
cob = cad.groupby("n_linhas")["cadeia_coberta"].mean()
print("Distribuição de jornadas por número de linhas:")
for n, qt in dist_cad.items():
print(f" {n} linhas: {qt:>6,} jornadas | {100*cob.get(n,0):.0f}% 100% cobertas pela matriz")
else:
print("Execute 06_cadeias.ipynb para obter esta análise.")
Distribuição de jornadas por número de linhas: 2 linhas: 1,311 jornadas | 14% 100% cobertas pela matriz 3 linhas: 23 jornadas | 0% 100% cobertas pela matriz 4 linhas: 5 jornadas | 0% 100% cobertas pela matriz
6. Mapa final — TOP 40 usos observados¶
In [7]:
# --- Mapa INTERATIVO: clique num par -> 2 rotas (por sentido) + baldeações + ponto ótimo ---
import glob as _glob, numpy as _np
from collections import Counter as _Counter, defaultdict as _dd_
from IPython.display import IFrame
# rotas: usa o SHAPE real (segue as ruas); 1 polilinha por SHP, na ordem do arquivo.
# fallback p/ paradas (pontos) se o shape estiver ausente.
_rotas = {}
for _fp in _glob.glob("cache_urbs/itinerarios/shape/*.json"):
_cod = os.path.basename(_fp)[:-5]
try: _d = json.load(open(_fp))
except Exception: continue
_by = _dd_(list)
for _s in _d:
try:
_by[str(_s.get("SHP","0"))].append([float(str(_s["LAT"]).replace(",", ".")),
float(str(_s["LON"]).replace(",", "."))])
except Exception: pass
_rotas[_cod] = list(_by.values())
for _fp in _glob.glob("cache_urbs/itinerarios/pontos/*.json"): # fallback
_cod = os.path.basename(_fp)[:-5]
if _cod in _rotas: continue
try: _d = json.load(open(_fp))
except Exception: continue
_by = _dd_(list)
for _s in _d:
try:
_it=str(_s.get("ITINERARY_ID") or "0")
_by[_it].append((int(_s.get("SEQ") or 0), float(str(_s["LAT"]).replace(",",".")), float(str(_s["LON"]).replace(",","."))))
except Exception: pass
_rotas[_cod]=[[[la,lo] for _,la,lo in sorted(p)] for p in _by.values()]
import re as _re
# --- Terminais: paradas cujo nome começa com "Terminal" -> nome limpo + linhas que passam ---
_term_pts = _dd_(list); _lin2term = _dd_(set)
for _fp in _glob.glob("cache_urbs/itinerarios/pontos/*.json"):
_cod = os.path.basename(_fp)[:-5]
try: _d = json.load(open(_fp))
except Exception: continue
for _s in _d:
_nm = str(_s.get("NOME",""))
_m = _re.match(r'(Terminal[^-/0-9]*)', _nm, _re.I)
if not _m: continue
_t = _m.group(1).strip().title()
try:
_la=float(str(_s["LAT"]).replace(",",".")); _lo=float(str(_s["LON"]).replace(",","."))
if -25.75<_la<-25.25 and -49.55<_lo<-49.10:
_term_pts[_t].append((_la,_lo)); _lin2term[_cod].add(_t)
except Exception: pass
_TERMINAIS = {t: [float(_np.median([p[0] for p in pts])), float(_np.median([p[1] for p in pts]))]
for t,pts in _term_pts.items() if len(pts)>=1}
_tb = adicionar_plausibilidade_fisica(pd.read_parquet(PARQUET_TB), GAP_VALIDACAO_MAX_KM)
_tb = _tb[_tb["baldeacao_real"]].copy()
_tb["o"] = _tb["CODLINHA_ORIGEM"].astype(str); _tb["d"] = _tb["CODLINHA_DESTINO"].astype(str)
def _codes(par):
a, b = par.split(" <-> "); return a.split(" ")[0], b.split(" ")[0]
def _melhor(pts):
if not len(pts): return None
arr = _np.array(pts); g = _np.round(arr/0.0015)*0.0015
key = [tuple(x) for x in g]; best = _Counter(key).most_common(1)[0]
sel = arr[[k == best[0] for k in key]]
return [float(sel[:,0].mean()), float(sel[:,1].mean()), int(best[1])]
_data = []
for _, _r in top40.iterrows():
_a, _b = _codes(_r["par_linhas"])
_sub = _tb[((_tb["o"]==_a)&(_tb["d"]==_b)) | ((_tb["o"]==_b)&(_tb["d"]==_a))].copy()
_sub = _sub.dropna(subset=["LATITUDE_DESTINO","LONGITUDE_DESTINO"])
_sub = _sub[_sub["LATITUDE_DESTINO"].between(-25.75,-25.25) & _sub["LONGITUDE_DESTINO"].between(-49.55,-49.10)]
_coords = _sub[["LATITUDE_DESTINO","LONGITUDE_DESTINO"]].values.tolist()
# agrega por célula ~150m + linha de DESTINO (= ônibus para o qual a pessoa mudou)
_g = _sub.assign(_gla=(_sub["LATITUDE_DESTINO"]/0.0015).round()*0.0015,
_glo=(_sub["LONGITUDE_DESTINO"]/0.0015).round()*0.0015)
_agg = (_g.groupby(["_gla","_glo","d","NOMELINHA_DESTINO"])
.agg(lat=("LATITUDE_DESTINO","mean"), lon=("LONGITUDE_DESTINO","mean"), n=("d","size"))
.reset_index())
_tps = [{"lat": float(rr.lat), "lon": float(rr.lon),
"dest": f"{rr.d} {rr.NOMELINHA_DESTINO}", "n": int(rr.n)} for rr in _agg.itertuples()]
# página de detalhe: tabela com as baldeações reais desse par
_det_rows = ""
for _k, (_, _rr) in enumerate(_sub.sort_values("DATA_HORA_ORIGEM").iterrows(), 1):
_det_rows += ("<tr><td>%d</td><td>%s</td><td>%s %s → %s %s</td><td>%.0f</td><td>%.2f</td><td>%.2f</td></tr>" % (
_k, str(_rr["DATA_HORA_ORIGEM"])[:16],
_rr["CODLINHA_ORIGEM"], str(_rr["NOMELINHA_ORIGEM"]),
_rr["CODLINHA_DESTINO"], str(_rr["NOMELINHA_DESTINO"]),
float(_rr["espera_min"]), float(_rr["dist_linha_km"]), float(_rr["gap_desembarque_km"])))
_det_html = ("<!doctype html><meta charset='utf-8'><title>Baldeações " + _r["par_linhas"] + "</title>"
"<style>body{font:14px system-ui,sans-serif;margin:20px;color:#222}"
"table{border-collapse:collapse;width:100%;max-width:900px}"
"th,td{border:1px solid #ddd;padding:5px 9px;text-align:left;font-size:13px}"
"th{background:#2c3e50;color:#fff;position:sticky;top:0}tr:nth-child(even){background:#f7f7f7}"
"a{color:#2980b9}h2{margin:.3rem 0}p{color:#555}</style>"
"<a href='mapa_interativo_propostas.html'>← voltar ao mapa</a>"
"<h2>" + _r["par_linhas"] + "</h2>"
"<p>" + str(len(_sub)) + " baldeações reais (jan/2026): usos consecutivos do mesmo cartão, "
"linhas diferentes, desembarque estimado ≤ 0,5 km do 2º embarque, percurso ≥ 0,8 km na 1ª linha, espera ≤ 60 min.</p>"
"<table><tr><th>#</th><th>data/hora (1º embarque)</th><th>sentido (mudou para →)</th>"
"<th>espera (min)</th><th>percorreu na 1ª linha (km)</th><th>gap desembarque (km)</th></tr>"
+ _det_rows + "</table>")
_det_fname = "baldeacoes_%s_%s.html" % (_a, _b)
open(_det_fname, "w", encoding="utf-8").write(_det_html)
_data.append({"par": _r["par_linhas"], "regiao": str(_r.get("regiao","")),
"score": float(_r.get(col_score,0) or 0), "vol": int(_r.get("volume",0) or 0),
"oficial": bool(_r.get("oficial", False)),
"rotaA": _rotas.get(_a, []), "rotaB": _rotas.get(_b, []),
"transfers": _tps, "n_total": int(len(_coords)), "best": _melhor(_coords), "tabela": _det_fname,
"termA": sorted(_lin2term.get(_a, [])), "termB": sorted(_lin2term.get(_b, []))})
_HTML = """<!DOCTYPE html><html lang="pt-BR"><head><meta charset="utf-8">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.css"/>
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.js"></script>
<style>body{margin:0;font:14px system-ui,sans-serif;display:flex;height:100vh}
#side{width:360px;overflow:auto;border-right:1px solid #ddd;padding:10px;box-sizing:border-box}#map{flex:1}
h2{font-size:14px;margin:.2rem 0 .4rem}.item{padding:7px 9px;margin:4px 0;border:1px solid #ddd;border-radius:6px;cursor:pointer;background:#fafafa}
.item:hover{background:#eef}.item.sel{background:#2980b9;color:#fff}.item small{display:block;opacity:.8}.item .tab{display:inline-block;margin-top:5px;font-size:11px;color:#2980b9;text-decoration:none}.item .tab:hover{text-decoration:underline}.item.sel .tab{color:#fff}.rank{display:inline-block;min-width:26px;color:#666;font-weight:700}.item.sel .rank{color:#fff}
.flag{display:inline-block;margin-left:5px;padding:1px 5px;border-radius:4px;background:#eaf7ef;color:#1e7e34;font-size:10px;font-weight:700}.flag.no{background:#fff1e8;color:#b23a31}.item.sel .flag{background:rgba(255,255,255,.22);color:#fff}
.leg{font-size:11px;color:#555;margin:6px 0}.hint{font-size:11px;color:#666;margin:4px 0 8px}.status{font-size:12px;line-height:1.35;margin:8px 0;padding:8px;border:1px solid #d8dee6;border-radius:6px;background:#f6f8fb;color:#354052}.status b{color:#b23a31}.sw{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:3px;vertical-align:middle}.sw.over{background:#ffd84d;border:2px solid #2c3e50;height:9px}
#modo button{font-size:12px;padding:4px 10px;margin:2px;border:1px solid #bbb;border-radius:5px;background:#fff;cursor:pointer}
#modo button.on{background:#2c3e50;color:#fff;border-color:#2c3e50}#filtroOficial button{font-size:12px;padding:4px 10px;margin:2px;border:1px solid #bbb;border-radius:5px;background:#fff;cursor:pointer}#filtroOficial button.on{background:#2c3e50;color:#fff;border-color:#2c3e50}</style>
</head><body><div id="side"><h2 id="tituloTop">Usos observados sem oficial — clique</h2><p class="hint">Filtro temporário: aparecem apenas os pares plausíveis que ainda não constam como integração liberada na matriz oficial.</p>
<div id="modo"><b style="font-size:12px">Exibir:</b>
<button data-m="AB" class="on">A + B</button><button data-m="A">só A</button><button data-m="B">só B</button> <button id="togpts" class="on">pontos</button><button id="togover" class="on">sobreposição</button></div>
<div id="filtroOficial"><b style="font-size:12px">Filtro:</b>
<button data-f="sem" class="on">sem oficial</button><button data-f="oficiais">oficiais</button><button data-f="todos">todos</button></div>
<div class="leg"><span class="sw" style="background:#2980b9"></span>linha A <span class="sw" style="background:#c0392b"></span>linha B <span class="sw over"></span>trecho sobreposto <span class="sw" style="background:#f39c12"></span>pontos de baldeação <span class="sw" style="background:#27ae60"></span>ponto ótimo <span class="sw" style="background:#f1c40f"></span>terminal (as 2 passam)</div>
<div id="matrizStatus" class="status"></div>
<div id="list"></div></div><div id="map"></div><script>
const DADOS=__DADOS__;const TERM=__TERMINAIS__;const map=L.map('map').setView([-25.45,-49.29],12);
L.tileLayer('https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',{maxZoom:19,attribution:'Tiles © Esri'}).addTo(map);
L.tileLayer('https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}',{maxZoom:19}).addTo(map);
let layer=L.layerGroup().addTo(map);let cur=0;let modo='AB';let showPts=true;let showOver=true;let filtroOficial='sem';
function draw(polys,color,weight=4,opacity=.82){(polys||[]).forEach(p=>{if(p&&p.length)L.polyline(p,{color:color,weight:weight,opacity:opacity,lineCap:'round',lineJoin:'round'}).addTo(layer);});}
function near(a,b){const dx=(a[0]-b[0])*111320,dy=(a[1]-b[1])*101000;return Math.sqrt(dx*dx+dy*dy)<55;}
function drawOverlap(d){if(!(modo==='AB'&&showOver))return;const ptsA=[];(d.rotaA||[]).forEach(p=>ptsA.push(...p));if(!ptsA.length)return;(d.rotaB||[]).forEach(p=>{let seg=[];p.forEach(pt=>{let ok=false;for(let i=0;i<ptsA.length;i+=3){if(near(pt,ptsA[i])){ok=true;break;}}if(ok){seg.push(pt)}else{if(seg.length>1)L.polyline(seg,{color:'#ffd84d',weight:8,opacity:.9,dashArray:'8 5',lineCap:'round'}).addTo(layer);seg=[];}});if(seg.length>1)L.polyline(seg,{color:'#ffd84d',weight:8,opacity:.9,dashArray:'8 5',lineCap:'round'}).addTo(layer);});}
function updateMatrizStatus(d){const link=' <a href="matriz_integracao.html" target="_blank" rel="noopener">ver tabela oficial</a>';document.getElementById('matrizStatus').innerHTML=d.oficial?'<b>Matriz URBS:</b> este par já consta como integração liberada. O uso observado entra na mesma contagem dos demais; a diferença é só a flag oficial.'+link:'<b>Matriz URBS:</b> este par não consta como integração liberada. O uso observado entra na mesma contagem, marcado como sem oficial.'+link;}
function render(){layer.clearLayers();const d=DADOS[cur];
updateMatrizStatus(d);
if(modo==='AB'||modo==='A')draw(d.rotaA,'#2980b9',5,.72);
if(modo==='AB'||modo==='B')draw(d.rotaB,'#c0392b',3,.92);
drawOverlap(d);
if(showPts)(d.transfers||[]).forEach(t=>L.circleMarker([t.lat,t.lon],{radius:3+Math.min(8,t.n*0.9),color:'#f39c12',fillColor:'#f39c12',fillOpacity:.6,weight:0}).addTo(layer).bindTooltip('mudou para <b>'+t.dest+'</b><br>'+t.n+(t.n>1?' baldeações':' baldeação')+' aqui'));
if(d.best)L.circleMarker([d.best[0],d.best[1]],{radius:11,color:'#27ae60',fillColor:'#27ae60',fillOpacity:.85,weight:2})
.bindPopup('<b>Ponto ótimo de integração</b><br>'+d.par+'<br><b>'+d.best[2]+' das '+d.n_total+'</b> baldeações reais desse par acontecem aqui (raio ~150 m) — é o melhor local para criar a integração, pois atende mais gente num só ponto.<br><a href="'+d.tabela+'" target="_blank">ver a tabela das '+d.n_total+' baldeações ▸</a>').addTo(layer);
// terminais: todos em cinza; A=azul, B=vermelho, AMBAS=dourado (integração física já possível)
const setA=new Set(d.termA||[]),setB=new Set(d.termB||[]);
function sq(c,sz){return L.divIcon({className:'',html:'<div style="width:'+sz+'px;height:'+sz+'px;background:'+c+';border:1px solid #333;"></div>',iconSize:[sz,sz]});}
for(const t in TERM){const inA=setA.has(t),inB=setB.has(t);
let c='#9aa',sz=8,z=300,info='';
if(inA&&inB){c='#f1c40f';sz=18;z=600;info='<br><b>as DUAS linhas passam aqui</b> — integração física possível';}
else if(inA){c='#2980b9';sz=12;z=500;info='<br>linha A passa aqui';}
else if(inB){c='#c0392b';sz=12;z=500;info='<br>linha B passa aqui';}
L.marker(TERM[t],{icon:sq(c,sz),zIndexOffset:z}).bindPopup('<b>'+t+'</b>'+info).addTo(layer);
}
let all=[];let use=(modo==='B')?[]:(d.rotaA||[]);let useB=(modo==='A')?[]:(d.rotaB||[]);
use.concat(useB).forEach(p=>all=all.concat(p));all=all.concat((d.transfers||[]).map(t=>[t.lat,t.lon]));
(d.termA||[]).concat(d.termB||[]).forEach(t=>{if(TERM[t])all.push(TERM[t]);});
if(all.length)map.fitBounds(L.latLngBounds(all).pad(.1));}
function show(i){cur=i;document.querySelectorAll('.item').forEach(e=>e.classList.toggle('sel',Number(e.dataset.idx)===i));render();}
document.querySelectorAll('#modo button[data-m]').forEach(b=>b.onclick=()=>{
modo=b.dataset.m;document.querySelectorAll('#modo button[data-m]').forEach(x=>x.classList.toggle('on',x===b));render();});
document.getElementById('togpts').onclick=function(){showPts=!showPts;this.classList.toggle('on',showPts);render();};
document.getElementById('togover').onclick=function(){showOver=!showOver;this.classList.toggle('on',showOver);render();};
function currentView(){return DADOS.map((d,i)=>({d,i})).filter(x=>filtroOficial==='todos'||(filtroOficial==='oficiais'?x.d.oficial:!x.d.oficial));}
function renderList(){const view=currentView();const label=filtroOficial==='todos'?'todos':(filtroOficial==='oficiais'?'oficiais':'sem oficial');document.getElementById('tituloTop').textContent='Top '+view.length+' usos observados '+label+' — clique';list.innerHTML='';view.forEach((x,j)=>{const d=x.d;const e=document.createElement('div');e.className='item';e.dataset.idx=x.i;e.innerHTML='<b><span class="rank">#'+(j+1)+'</span>'+d.par+' <span class="flag '+(d.oficial?'':'no')+'">'+(d.oficial?'oficial':'sem oficial')+'</span></b><small>'+d.regiao+' · '+d.n_total+' baldeações reais · score '+d.score.toFixed(0)+'</small>'+'<a class="tab" href="'+d.tabela+'" target="_blank" onclick="event.stopPropagation()">ver a tabela das '+d.n_total+' baldeações ▸</a>';e.onclick=(ev)=>{if(ev.target.closest('a'))return;show(x.i);};list.appendChild(e);});if(view.length)show(view[0].i);}
document.querySelectorAll('#filtroOficial button').forEach(b=>b.onclick=()=>{filtroOficial=b.dataset.f;document.querySelectorAll('#filtroOficial button').forEach(x=>x.classList.toggle('on',x===b));renderList();});
const list=document.getElementById('list');renderList();</script></body></html>"""
_HTML = _HTML.replace("__DADOS__", json.dumps(_data, ensure_ascii=False)).replace("__TERMINAIS__", json.dumps(_TERMINAIS, ensure_ascii=False))
open("mapa_interativo_propostas.html", "w").write(_HTML)
print("Mapa interativo salvo (toggle A/B + shape real).")
IFrame(src="mapa_interativo_propostas.html", width="100%", height=600)
Mapa interativo salvo (toggle A/B + shape real).
Out[7]:
7. Recomendações de política¶
7.1 Integração imediata (custo político baixo)¶
| Par | Por quê agir agora |
|---|---|
| 020 vs 021 INTERBAIRROS II | Mesmo corredor, sentidos opostos — ausência da matriz é provavelmente omissão |
| 701 vs 703 FAZENDINHA/CAIUÁ | Maior volume (131/mês), rotas vizinhas no CIC sem terminal comum |
| 170 vs 171 BRACATINGA/PRIMAVERA | 3º em volume, par complementar no corredor Norte |
| 166 vs 170 V.NORI/BRACATINGA | 4º em volume, mesmo eixo Norte |
7.2 Revisão do TEMPOLIMITE (ajuste administrativo)¶
Pares já na matriz onde >20% dos usuários excedem o limite. Solução: aumentar TEMPOLIMITE para o P75 da espera observada.
7.3 Estudo de nova linha¶
Pares com alta distância E alto volume sem cobertura direta:
- 609 MAD. CIC → 679 MAD. TATUQUARA (CIC→Sul)
- 345 B ALTO/ATUBA → 030 INTERBAIRROS III (Leste→Norte)
In [8]:
print("Para exportar o relatório sem código:")
print(" jupyter nbconvert --to html --no-input 09_relatorio_final.ipynb --output docs/09_relatorio_final.html")
Para exportar o relatório sem código: jupyter nbconvert --to html --no-input 09_relatorio_final.ipynb --output docs/09_relatorio_final.html