03 — Propostas priorizadas de novas integrações¶

Consolida a entrega de proposta a partir das baldeações fisicamente plausíveis (até 1 km) SEM integração oficial: ranking por par/região (volume mensal), score multidimensional (volume + distância, com fator de vulnerabilidade social IBGE opcional) e mapas.

Lê transbordos_*.parquet (do 02_analise). Exporta proposta_novas_integracoes.csv/.md, proposta_score_ajustado.csv e mapas .html.

In [1]:
import os, json, math
from pathlib import Path
import numpy as np, pandas as pd
import folium
from folium.plugins import HeatMap
import matplotlib.pyplot as plt
from transbordo_utils import (novo_mapa, GAP_VALIDACAO_MAX_KM, adicionar_plausibilidade_fisica,
                              resolver_cache, carregar_pares_oficiais,
                              classificar_regional_oficial, CORES_REGIONAL)

START_DATE, END_DATE = "2026-01-01", "2026-01-31"
TB     = resolver_cache(f"transbordos_{START_DATE}_{END_DATE}.parquet")  # gerado pelo 02_analise
MATRIZ = resolver_cache("matriz_integracao.json")

df = adicionar_plausibilidade_fisica(pd.read_parquet(TB), GAP_VALIDACAO_MAX_KM)
pares = carregar_pares_oficiais(MATRIZ)
print("transbordos:", len(df), "| pares oficiais:", len(pares))
transbordos: 23562 | pares oficiais: 2589

1. Baldeações plausíveis sem integração¶

In [2]:
# --- Baldeações fisicamente plausíveis (<=1 km) SEM integração oficial ---
df["o"] = df["CODLINHA_ORIGEM"].astype(str)
df["d"] = df["CODLINHA_DESTINO"].astype(str)
df["par"] = list(zip(df["o"], df["d"]))
bald_temporal = df[df["o"] != df["d"]]
bald = df[df["baldeacao_real"]]
sem  = bald[[p not in pares for p in bald["par"]]].copy()
print(f"baldeações temporais: {len(bald_temporal)}")
print(f"plausíveis (<= {GAP_VALIDACAO_MAX_KM:.1f} km): {len(bald)} | sem integração: {len(sem)}")
baldeações temporais: 8581
plausíveis (<= 1.0 km): 5084 | sem integração: 3895

2. Proposta priorizada (volume + região)¶

In [3]:
# --- Agrega por PAR de linhas (não-direcionado A<->B) -> proposta priorizada ---
sem["lat_m"] = (sem["LATITUDE_ORIGEM"] + sem["LATITUDE_DESTINO"]) / 2
sem["lon_m"] = (sem["LONGITUDE_ORIGEM"] + sem["LONGITUDE_DESTINO"]) / 2
nome = {}
for _, r in sem.iterrows():
    nome.setdefault(r["o"], str(r["NOMELINHA_ORIGEM"]))
    nome.setdefault(r["d"], str(r["NOMELINHA_DESTINO"]))
sem["par_und"] = [" <-> ".join(sorted([o, d])) for o, d in zip(sem["o"], sem["d"])]

prop = (sem.groupby("par_und")
        .agg(volume=("NUMEROCARTAO", "size"), cartoes=("NUMEROCARTAO", "nunique"),
             lat_m=("lat_m", "median"), lon_m=("lon_m", "median"))
        .reset_index())
prop["regiao"] = [classificar_regional_oficial(la, lo) for la, lo in zip(prop["lat_m"], prop["lon_m"])]

total = prop["volume"].sum()
MIN_VOLUME = 5  # piso: pares com <5 baldeações/mês são ruído
prop = prop[(prop["volume"] >= MIN_VOLUME) & prop["lat_m"].notna()].copy()
prop["pct_demanda_nao_atendida"] = (100 * prop["volume"] / total).round(2)
prop["score"] = (100 * prop["volume"] / prop["volume"].max()).round(1)
prop = prop.sort_values("volume", ascending=False).reset_index(drop=True)
prop.insert(0, "rank", prop.index + 1)

def nomear(par):
    a, b = par.split(" <-> ")
    return f"{a} {nome.get(a,'')} <-> {b} {nome.get(b,'')}"
prop["par_linhas"] = prop["par_und"].map(nomear)

cols = ["rank","par_linhas","regiao","volume","cartoes","pct_demanda_nao_atendida","score","lat_m","lon_m"]
proposta = prop[cols]
print(f"pares distintos sem integração: {len(proposta)} | volume total: {total}")
display(proposta.head(25))
pares distintos sem integração: 202 | volume total: 3895
rank par_linhas regiao volume cartoes pct_demanda_nao_atendida score lat_m lon_m
0 1 040 INTERBAIRROS IV <-> 876 SAVÓIA Regional Santa Felicidade 54 36 1.39 100.0 -25.419500 -49.344543
1 2 170 BRACATINGA <-> 171 PRIMAVERA Regional Matriz 47 46 1.21 87.0 -25.406597 -49.280079
2 3 701 FAZENDINHA <-> 703 CAIUÁ Regional Portão 42 39 1.08 77.8 -25.450100 -49.290314
3 4 166 V. NORI <-> 170 BRACATINGA Regional Santa Felicidade 34 33 0.87 63.0 -25.403843 -49.285419
4 5 170 BRACATINGA <-> 901 STA. FELICIDADE Regional Santa Felicidade 24 13 0.62 44.4 -25.405066 -49.286873
5 6 170 BRACATINGA <-> 876 SAVÓIA Regional Santa Felicidade 23 9 0.59 42.6 -25.411976 -49.286598
6 7 040 INTERBAIRROS IV <-> 870 SÃO BRAZ Regional Santa Felicidade 20 20 0.51 37.0 -25.423895 -49.350636
7 8 205 BARREIRINHA <-> 876 SAVÓIA Regional Matriz 19 15 0.49 35.2 -25.429817 -49.281471
8 9 040 INTERBAIRROS IV <-> 822 GABINETO Regional Cic 19 16 0.49 35.2 -25.437876 -49.353348
9 10 528 BOQUEIRÃO/PINHEIRINHO <-> 641 LUIZ NICHELE Regional Bairro Novo 18 16 0.46 33.3 -25.548700 -49.289421
10 11 901 STA. FELICIDADE <-> 967 JÚLIO GRAF Regional Matriz 18 17 0.46 33.3 -25.420534 -49.288296
11 12 030 INTERBAIRROS III <-> 338 CENTENÁRIO/HAUER Regional Cajuru 17 15 0.44 31.5 -25.486351 -49.219372
12 13 030 INTERBAIRROS III <-> 345 B ALTO/ATUBA Regional Boa Vista 16 14 0.41 29.6 -25.403233 -49.203129
13 14 380 DETRAN/V.MACHADO <-> 801 CAMP.SIQ./BATEL Regional Matriz 16 10 0.41 29.6 -25.431225 -49.266796
14 15 040 INTERBAIRROS IV <-> 812 MONTANA Regional Santa Felicidade 16 14 0.41 29.6 -25.418865 -49.347237
15 16 166 V. NORI <-> 168 RAPOSO TAVARES Regional Santa Felicidade 16 12 0.41 29.6 -25.410007 -49.285828
16 17 372 TARUMÃ <-> 801 CAMP.SIQ./BATEL Regional Matriz 15 14 0.39 27.8 -25.436525 -49.276154
17 18 050 INTERBAIRROS V <-> 216 CABRAL / PORTÃO Regional Portão 15 15 0.39 27.8 -25.460695 -49.273315
18 19 040 INTERBAIRROS IV <-> 703 CAIUÁ Regional Santa Felicidade 14 13 0.36 25.9 -25.469894 -49.329811
19 20 020 INTERBAIRR II H <-> 184 V. SUIÇA Regional Boa Vista 14 12 0.36 25.9 -25.386562 -49.274857
20 21 170 BRACATINGA <-> 207 CABRAL / OSÓRIO Regional Matriz 14 13 0.36 25.9 -25.422010 -49.277866
21 22 040 INTERBAIRROS IV <-> 652 V. VERDE Regional Cic 13 11 0.33 24.1 -25.524538 -49.329079
22 23 545 TRABALHADOR <-> 619 STA. RITA / CIC Regional Cic 13 11 0.33 24.1 -25.541122 -49.320099
23 24 545 TRABALHADOR <-> 627 BOSCH Regional Cic 13 13 0.33 24.1 -25.536854 -49.323296
24 25 338 CENTENÁRIO/HAUER <-> 472 UBERABA Regional Cajuru 13 12 0.33 24.1 -25.482044 -49.225231
In [4]:
# --- Exporta CSV + MD (top 30) ---
proposta.to_csv("proposta_novas_integracoes.csv", index=False)

top = proposta.head(30)
linhas_md = ["# Proposta de novas integrações tarifárias — Curitiba (jan/2026)",
    "",
    f"Baseado em **{int(total)} baldeações fisicamente plausíveis sem integração oficial** (pares de linhas diferentes, "
    f"usos consecutivos 3–60 min, gap <= {GAP_VALIDACAO_MAX_KM:.1f} km, fora da matriz oficial liberada).",
    "Priorizado por volume mensal. Score = volume normalizado (0–100).",
    "",
    "| # | Par de linhas (A ↔ B) | Região | Volume/mês | Cartões | % demanda | Score |",
    "|---|---|---|---|---|---|---|"]
for _, r in top.iterrows():
    linhas_md.append(f"| {r['rank']} | {r['par_linhas']} | {r['regiao']} | {r['volume']} | "
                     f"{r['cartoes']} | {r['pct_demanda_nao_atendida']}% | {r['score']} |")
porreg = (proposta.groupby("regiao").agg(pares=("rank","size"), volume=("volume","sum"))
          .sort_values("volume", ascending=False).reset_index())
linhas_md += ["", "## Volume por região (sem integração)", "",
              "| Região | Pares | Volume/mês |", "|---|---|---|"]
for _, r in porreg.iterrows():
    linhas_md.append(f"| {r['regiao']} | {r['pares']} | {r['volume']} |")
Path("proposta_novas_integracoes.md").write_text("\n".join(linhas_md), encoding="utf-8")
print("exportado: proposta_novas_integracoes.csv e .md")
display(porreg)
exportado: proposta_novas_integracoes.csv e .md
regiao pares volume
0 Regional Matriz 83 654
1 Regional Santa Felicidade 21 308
2 Regional Portão 25 218
3 Regional Cic 23 201
4 Regional Cajuru 17 139
5 Regional Boa Vista 14 114
6 Regional Bairro Novo 6 49
7 Regional Tatuquara 6 44
8 Regional Boqueirão 5 31
9 Regional Pinheirinho 2 13

3. Score multidimensional (volume + distância + vulnerabilidade)¶

In [5]:
# --- Score composto: volume + distância (+ vulnerabilidade social IBGE, opcional) ---
W_VOL, W_DIST, W_VULN = 0.50, 0.25, 0.25
USE_IBGE = False  # True após instalar geopandas + shapefile do Censo 2022 (PR)

sem["dist_km"] = sem["dist_linha_km"]  # distância de fato percorrida na 1ª linha
sem["par_und_nome"] = [" <-> ".join(sorted([str(o), str(d)]))
                       for o, d in zip(sem["NOMELINHA_ORIGEM"].astype(str),
                                       sem["NOMELINHA_DESTINO"].astype(str))]
agg = (sem.groupby("par_und_nome")
       .agg(volume=("NUMEROCARTAO","count"), cartoes=("NUMEROCARTAO","nunique"),
            dist_med=("dist_km","median"),
            lat_orig=("LATITUDE_ORIGEM","median"), lon_orig=("LONGITUDE_ORIGEM","median"))
       .reset_index())

if USE_IBGE:
    import geopandas as gpd
    from shapely.geometry import Point
    setores = gpd.read_file("/tmp/setores_pr/PR_setores_CD2022.shp")
    pts = gpd.GeoDataFrame(agg[["par_und_nome","lat_orig","lon_orig"]],
        geometry=[Point(lo, la) for la, lo in zip(agg["lat_orig"], agg["lon_orig"])], crs="EPSG:4326")
    joined = gpd.sjoin(pts, setores[["geometry","V009"]], how="left")
    agg["renda_setor"] = joined["V009"].values
    rmax = agg["renda_setor"].max()
    agg["vuln"] = 1.0 - agg["renda_setor"].fillna(rmax/2) / rmax
else:
    agg["vuln"] = np.nan
    W_VOL, W_DIST, W_VULN = 0.60, 0.40, 0.0

def norm01(s):
    mn, mx = s.min(), s.max()
    return s * 0.0 if mx == mn else (s - mn) / (mx - mn)
agg["norm_vol"]  = norm01(agg["volume"])
agg["norm_dist"] = norm01(agg["dist_med"].fillna(0))
agg["norm_vuln"] = norm01(agg["vuln"].fillna(0)) if USE_IBGE else 0.0
agg["score_ajustado"] = (W_VOL*agg["norm_vol"] + W_DIST*agg["norm_dist"] + W_VULN*agg["norm_vuln"]) * 100

# casa com a proposta (chave por NOMES, sem o código de linha)
proposta_orig = proposta.copy()
def _names_key(p):
    return " <-> ".join(sorted(" ".join(s.split(" ")[1:]) for s in str(p).split(" <-> ")))
proposta_orig["par_und_nome"] = proposta_orig["par_linhas"].map(_names_key)
merged = proposta_orig.merge(
    agg[["par_und_nome","dist_med","norm_dist","vuln","score_ajustado"]],
    on="par_und_nome", how="left")
merged["rank_orig"] = merged["score"].rank(ascending=False, na_option="bottom").astype(int)
merged["rank_novo"] = merged["score_ajustado"].rank(ascending=False, na_option="bottom").astype(int)
merged["delta_rank"] = merged["rank_orig"] - merged["rank_novo"]
merged = merged.sort_values("rank_novo")
merged.to_csv("proposta_score_ajustado.csv", index=False)
print(f"salvo: proposta_score_ajustado.csv  (IBGE={'on' if USE_IBGE else 'off'})")
print("\n── TOP 20 por score ajustado ──")
print(merged.head(20)[["par_linhas","regiao","volume","dist_med","score","score_ajustado","delta_rank"]]
      .to_string(index=False, float_format="{:.1f}".format))
salvo: proposta_score_ajustado.csv  (IBGE=off)

── TOP 20 por score ajustado ──
                                 par_linhas                    regiao  volume  dist_med  score  score_ajustado  delta_rank
         040 INTERBAIRROS IV <-> 876 SAVÓIA Regional Santa Felicidade      54       2.1  100.0            63.9           0
           170 BRACATINGA <-> 171 PRIMAVERA           Regional Matriz      47       2.4   87.0            56.9           0
               701 FAZENDINHA <-> 703 CAIUÁ           Regional Portão      42       1.9   77.8            49.5           0
     170 BRACATINGA <-> 901 STA. FELICIDADE Regional Santa Felicidade      24       5.9   44.4            41.0           1
             166 V. NORI <-> 170 BRACATINGA Regional Santa Felicidade      34       1.9   63.0            40.6          -1
              170 BRACATINGA <-> 876 SAVÓIA Regional Santa Felicidade      23       5.8   42.6            39.5           0
       280 NSA.SRA.DE NAZARÉ <-> 876 SAVÓIA Regional Santa Felicidade      12       7.6   22.2            32.3          23
        609 MAD. CIC <-> 679 MAD. TATUQUARA           Regional Portão       8       8.6   14.8            30.7          66
 060 INTERBAIRROS VI <-> 685 RIO BONITO/CIC        Regional Tatuquara      11       7.2   20.4            29.9          29
         166 V. NORI <-> 168 RAPOSO TAVARES Regional Santa Felicidade      16       4.8   29.6            28.7           4
  338 CENTENÁRIO/HAUER <-> 468 JD. CENTAURO           Regional Cajuru      10       6.9   18.5            28.1          38
                372 TARUMÃ <-> 777 V. VELHA           Regional Matriz      11       6.2   20.4            27.1          26
060 INTERBAIRROS VI <-> 619 STA. RITA / CIC              Regional Cic       5       8.0    9.3            25.6         165
             468 JD. CENTAURO <-> 703 CAIUÁ           Regional Cajuru       5       8.0    9.3            25.5         164
       468 JD. CENTAURO <-> 474 JD. ITIBERÊ           Regional Cajuru       6       7.4   11.1            25.0         115
   160 J.MERCES/GUANABA <-> 475 CANAL BELÉM           Regional Portão       9       6.1   16.7            24.7          43
      380 DETRAN/V.MACHADO <-> 870 SÃO BRAZ Regional Santa Felicidade       5       7.7    9.3            24.6         161
             387 PALOTINOS <-> 777 V. VELHA           Regional Portão       8       6.4   14.8            24.3          56
          468 JD. CENTAURO <-> 777 V. VELHA           Regional Matriz       7       6.8   13.0            24.2          76
         170 BRACATINGA <-> 205 BARREIRINHA Regional Santa Felicidade       9       5.9   16.7            24.0          39
In [6]:
# Scatter: score original (volume) vs. score ajustado
cores = merged["regiao"].map(CORES_REGIONAL).fillna("#95a5a6")
fig, ax = plt.subplots(figsize=(9, 8))
ax.scatter(merged["score"], merged["score_ajustado"], c=cores,
           s=merged["cartoes"].fillna(1)*4, alpha=0.75, edgecolors="gray", linewidth=0.4)
lim = max(merged["score"].max(), merged["score_ajustado"].max()) + 3
ax.plot([0, lim], [0, lim], "k--", alpha=0.3, linewidth=1, label="sem mudança")
for _, row in merged[merged["delta_rank"].abs() >= 5].head(12).iterrows():
    ax.annotate(row["par_linhas"][:30], (row["score"], row["score_ajustado"]),
                fontsize=6, alpha=0.85, xytext=(4, -3), textcoords="offset points")
from matplotlib.lines import Line2D
handles = [Line2D([0],[0], marker="o", color="w", markerfacecolor=c, markersize=8, label=r)
           for r, c in CORES_REGIONAL.items() if r in merged["regiao"].values]
ax.legend(handles=handles, title="Região", fontsize=7, bbox_to_anchor=(1.01, 1), loc="upper left")
ax.set_xlabel("Score original (volume puro)")
ax.set_ylabel("Score ajustado (volume + distância" + (" + IBGE" if USE_IBGE else "") + ")")
ax.set_title("Mudança de prioridade com score multidimensional")
plt.tight_layout(); plt.savefig("scatter_score.png", dpi=150, bbox_inches="tight"); plt.show()
print("Salvo: scatter_score.png")
No description has been provided for this image
Salvo: scatter_score.png
In [7]:
# Bump chart: variação de rank (TOP 20)
top20 = merged[merged["rank_orig"] <= 20].copy()
fig, ax = plt.subplots(figsize=(8, 9))
for _, row in top20.iterrows():
    cor = CORES_REGIONAL.get(row["regiao"], "#95a5a6")
    ax.plot([0, 1], [row["rank_orig"], row["rank_novo"]], color=cor, linewidth=1.5, alpha=0.75)
    ax.scatter([0], [row["rank_orig"]], color=cor, s=60, zorder=5)
    ax.scatter([1], [row["rank_novo"]], color=cor, s=60, zorder=5)
    ax.text(-0.03, row["rank_orig"], row["par_linhas"][:28], ha="right", va="center", fontsize=6)
    ax.text(1.03, row["rank_novo"], row["par_linhas"][:28], ha="left", va="center", fontsize=6)
ax.set_xlim(-0.5, 1.5); ax.set_ylim(0.5, 20.5); ax.invert_yaxis()
ax.set_xticks([0, 1])
ax.set_xticklabels(["Score original\n(volume)", "Score ajustado\n(vol + dist" + ("+IBGE" if USE_IBGE else "") + ")"])
ax.set_yticks([])
ax.spines[["top","right","left","bottom"]].set_visible(False)
ax.set_title("Variação de ranking com score multidimensional (TOP 20)", fontsize=11)
plt.tight_layout(); plt.savefig("bump_chart_score.png", dpi=150, bbox_inches="tight"); plt.show()
print("Salvo: bump_chart_score.png")
/tmp/ipykernel_2625478/140039341.py:17: UserWarning: Tight layout not applied. The bottom and top margins cannot be made large enough to accommodate all Axes decorations.
  plt.tight_layout(); plt.savefig("bump_chart_score.png", dpi=150, bbox_inches="tight"); plt.show()
No description has been provided for this image
Salvo: bump_chart_score.png

4. Mapas¶

In [8]:
# Mapa das integrações PROPOSTAS (top 40, tamanho = volume), cor = região
prop_geo = prop.dropna(subset=["lat_m","lon_m"]).head(40)
mp = novo_mapa(location=[-25.45,-49.29], zoom_start=12)
vmax = prop_geo["volume"].max()
for _, r in prop_geo.iterrows():
    folium.CircleMarker([r["lat_m"], r["lon_m"]], radius=5+15*r["volume"]/vmax,
        color=CORES_REGIONAL.get(r["regiao"], "#555"), fill=True, fill_opacity=0.6,
        popup=folium.Popup(f"<b>{r['par_linhas']}</b><br>região: {r['regiao']}<br>volume/mês: {r['volume']} · cartões: {r['cartoes']}", max_width=300)
    ).add_to(mp)
mp.save("mapa_proposta_integracoes.html")
print("salvo: mapa_proposta_integracoes.html (top 40 propostas)")
mp
salvo: mapa_proposta_integracoes.html (top 40 propostas)
Out[8]:
Make this Notebook Trusted to load map: File -> Trust Notebook
In [9]:
# TOP 40 pares sem integração (origem→destino, espessura = demanda)
sem_coords = sem.dropna(subset=["LATITUDE_ORIGEM","LONGITUDE_ORIGEM","LATITUDE_DESTINO","LONGITUDE_DESTINO"])
top_od = (sem_coords.groupby(["CODLINHA_ORIGEM","NOMELINHA_ORIGEM","CODLINHA_DESTINO","NOMELINHA_DESTINO"])
   .agg(qtd=("NUMEROCARTAO","count"),
        lat_o=("LATITUDE_ORIGEM","median"), lon_o=("LONGITUDE_ORIGEM","median"),
        lat_d=("LATITUDE_DESTINO","median"), lon_d=("LONGITUDE_DESTINO","median"))
   .reset_index().sort_values("qtd", ascending=False).head(40))
mp = novo_mapa(location=[-25.45,-49.29], zoom_start=12)
qmax = top_od["qtd"].max()
for _, r in top_od.iterrows():
    folium.PolyLine([[r.lat_o,r.lon_o],[r.lat_d,r.lon_d]], color="#c0392b", weight=2+8*r.qtd/qmax, opacity=0.6,
        popup=f"{r.CODLINHA_ORIGEM} {r.NOMELINHA_ORIGEM} → {r.CODLINHA_DESTINO} {r.NOMELINHA_DESTINO}<br>{r.qtd} baldeações").add_to(mp)
    folium.CircleMarker([r.lat_o,r.lon_o], radius=4, color="#2980b9", fill=True).add_to(mp)
    folium.CircleMarker([r.lat_d,r.lon_d], radius=4, color="#c0392b", fill=True).add_to(mp)
mp.save("mapa_transbordos_sem_integracao.html")
print("salvo: mapa_transbordos_sem_integracao.html")
mp
salvo: mapa_transbordos_sem_integracao.html
Out[9]:
Make this Notebook Trusted to load map: File -> Trust Notebook
In [10]:
# Heatmap das origens/destinos sem integração
hm = novo_mapa(location=[-25.45,-49.29], zoom_start=12)
HeatMap(sem_coords[["LATITUDE_ORIGEM","LONGITUDE_ORIGEM"]].values.tolist(), name="Origens", radius=15,
        gradient={"0.4":"yellow","0.65":"orange","1":"red"}).add_to(hm)
HeatMap(sem_coords[["LATITUDE_DESTINO","LONGITUDE_DESTINO"]].values.tolist(), name="Destinos", radius=15,
        gradient={"0.4":"cyan","0.65":"blue","1":"navy"}).add_to(hm)
folium.LayerControl().add_to(hm)
hm.save("mapa_heatmap_sem_integracao.html")
print("salvo: mapa_heatmap_sem_integracao.html")
hm
salvo: mapa_heatmap_sem_integracao.html
Out[10]:
Make this Notebook Trusted to load map: File -> Trust Notebook