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")
No description has been provided for this image
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 &rarr; %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'>&#8592; 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 &le; 0,5 km do 2º embarque, percurso &ge; 0,8 km na 1ª linha, espera &le; 60 min.</p>"
        "<table><tr><th>#</th><th>data/hora (1º embarque)</th><th>sentido (mudou para &rarr;)</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 &copy; 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&nbsp;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 &#9656;</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 &#9656;</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