dashgusbr 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dashgusbr/__init__.py +39 -0
- dashgusbr/_theme.py +113 -0
- dashgusbr/analytics.py +363 -0
- dashgusbr/client.py +183 -0
- dashgusbr/config.py +40 -0
- dashgusbr/data.py +93 -0
- dashgusbr/schema.py +125 -0
- dashgusbr/viz.py +342 -0
- dashgusbr-0.1.0.dist-info/METADATA +179 -0
- dashgusbr-0.1.0.dist-info/RECORD +12 -0
- dashgusbr-0.1.0.dist-info/WHEEL +4 -0
- dashgusbr-0.1.0.dist-info/licenses/LICENSE +21 -0
dashgusbr/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""dashgusbr — consumo e visualização do histórico do Campeonato Brasileiro.
|
|
2
|
+
|
|
3
|
+
Camada de consumo da arquitetura serverless do projeto Infra-Brasileirao:
|
|
4
|
+
lê a OBT (One Big Table) publicada pelo pipeline ETL (CSV no GitHub, com
|
|
5
|
+
fallback para Google Sheets) e oferece análises em Pandas e gráficos Plotly.
|
|
6
|
+
|
|
7
|
+
Uso rápido::
|
|
8
|
+
|
|
9
|
+
from dashgusbr import Brasileirao
|
|
10
|
+
|
|
11
|
+
br = Brasileirao()
|
|
12
|
+
br.tabela(2023) # DataFrame da classificação
|
|
13
|
+
br.plot_confronto("Flamengo", "Palmeiras").show()
|
|
14
|
+
|
|
15
|
+
Uso avançado (camadas puras)::
|
|
16
|
+
|
|
17
|
+
from dashgusbr import analytics, data, viz
|
|
18
|
+
|
|
19
|
+
df = data.carregar_dados()
|
|
20
|
+
tab = analytics.classificacao(df, ano=2023)
|
|
21
|
+
fig = viz.classificacao(tab)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from . import analytics, config, data, schema, viz
|
|
25
|
+
from .client import Brasileirao
|
|
26
|
+
from .data import carregar_dados
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Brasileirao",
|
|
32
|
+
"carregar_dados",
|
|
33
|
+
"analytics",
|
|
34
|
+
"config",
|
|
35
|
+
"data",
|
|
36
|
+
"schema",
|
|
37
|
+
"viz",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
dashgusbr/_theme.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Tema visual da dashgusbr (template Plotly + paleta).
|
|
2
|
+
|
|
3
|
+
Paleta validada para daltonismo (deutan/protan/tritan) em modo claro:
|
|
4
|
+
ordem fixa dos slots categóricos — nunca ciclar nem reordenar, a ordem é o
|
|
5
|
+
mecanismo de segurança para visão de cores. Sequencial = um matiz (azul),
|
|
6
|
+
claro→escuro. Cinza neutro para categorias "sem lado" (empates).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import plotly.graph_objects as go
|
|
12
|
+
import plotly.io as pio
|
|
13
|
+
|
|
14
|
+
# Slots categóricos, em ordem fixa (identidade de séries)
|
|
15
|
+
CORES_CATEGORICAS = [
|
|
16
|
+
"#2a78d6", # 1 azul
|
|
17
|
+
"#008300", # 2 verde
|
|
18
|
+
"#e87ba4", # 3 magenta
|
|
19
|
+
"#eda100", # 4 amarelo
|
|
20
|
+
"#1baf7a", # 5 aqua
|
|
21
|
+
"#eb6834", # 6 laranja
|
|
22
|
+
"#4a3aa7", # 7 violeta
|
|
23
|
+
"#e34948", # 8 vermelho
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
AZUL = CORES_CATEGORICAS[0]
|
|
27
|
+
VERDE = CORES_CATEGORICAS[1]
|
|
28
|
+
|
|
29
|
+
# Cinza neutro para marcas sem identidade de série (ex.: empates)
|
|
30
|
+
CINZA_NEUTRO = "#898781"
|
|
31
|
+
|
|
32
|
+
# Rampa sequencial (magnitude): azul claro→escuro
|
|
33
|
+
RAMPA_SEQUENCIAL = [
|
|
34
|
+
"#cde2fb",
|
|
35
|
+
"#9ec5f4",
|
|
36
|
+
"#6da7ec",
|
|
37
|
+
"#3987e5",
|
|
38
|
+
"#256abf",
|
|
39
|
+
"#184f95",
|
|
40
|
+
"#0d366b",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Superfície e tinta (chrome do gráfico)
|
|
44
|
+
SUPERFICIE = "#fcfcfb"
|
|
45
|
+
TINTA = "#0b0b0b"
|
|
46
|
+
TINTA_SECUNDARIA = "#52514e"
|
|
47
|
+
TINTA_MUTED = "#898781"
|
|
48
|
+
GRADE = "#e1e0d9"
|
|
49
|
+
EIXO = "#c3c2b7"
|
|
50
|
+
|
|
51
|
+
FONTE = 'system-ui, -apple-system, "Segoe UI", sans-serif'
|
|
52
|
+
|
|
53
|
+
TEMA = "dashgusbr"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def escala_sequencial() -> list:
|
|
57
|
+
"""Rampa sequencial no formato de colorscale do Plotly (0..1)."""
|
|
58
|
+
n = len(RAMPA_SEQUENCIAL) - 1
|
|
59
|
+
return [[i / n, cor] for i, cor in enumerate(RAMPA_SEQUENCIAL)]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def registrar_tema() -> None:
|
|
63
|
+
"""Registra (idempotente) o template ``dashgusbr`` no Plotly.
|
|
64
|
+
|
|
65
|
+
Não altera o template default global do usuário: cada figura da
|
|
66
|
+
biblioteca pede ``template="dashgusbr"`` explicitamente.
|
|
67
|
+
"""
|
|
68
|
+
template = go.layout.Template(
|
|
69
|
+
layout=go.Layout(
|
|
70
|
+
paper_bgcolor=SUPERFICIE,
|
|
71
|
+
plot_bgcolor=SUPERFICIE,
|
|
72
|
+
colorway=CORES_CATEGORICAS,
|
|
73
|
+
font=dict(family=FONTE, color=TINTA, size=13),
|
|
74
|
+
title=dict(font=dict(size=16, color=TINTA), x=0, xanchor="left"),
|
|
75
|
+
margin=dict(l=64, r=32, t=64, b=48),
|
|
76
|
+
xaxis=dict(
|
|
77
|
+
gridcolor=GRADE,
|
|
78
|
+
linecolor=EIXO,
|
|
79
|
+
zerolinecolor=EIXO,
|
|
80
|
+
ticks="outside",
|
|
81
|
+
tickcolor=EIXO,
|
|
82
|
+
title=dict(font=dict(color=TINTA_SECUNDARIA)),
|
|
83
|
+
tickfont=dict(color=TINTA_MUTED, size=12),
|
|
84
|
+
),
|
|
85
|
+
yaxis=dict(
|
|
86
|
+
gridcolor=GRADE,
|
|
87
|
+
linecolor=EIXO,
|
|
88
|
+
zerolinecolor=EIXO,
|
|
89
|
+
ticks="outside",
|
|
90
|
+
tickcolor=EIXO,
|
|
91
|
+
title=dict(font=dict(color=TINTA_SECUNDARIA)),
|
|
92
|
+
tickfont=dict(color=TINTA_MUTED, size=12),
|
|
93
|
+
),
|
|
94
|
+
legend=dict(
|
|
95
|
+
orientation="h",
|
|
96
|
+
yanchor="bottom",
|
|
97
|
+
y=1.02,
|
|
98
|
+
xanchor="left",
|
|
99
|
+
x=0,
|
|
100
|
+
font=dict(color=TINTA_SECUNDARIA, size=12),
|
|
101
|
+
),
|
|
102
|
+
hoverlabel=dict(
|
|
103
|
+
bgcolor="#ffffff",
|
|
104
|
+
bordercolor=GRADE,
|
|
105
|
+
font=dict(family=FONTE, color=TINTA, size=12),
|
|
106
|
+
),
|
|
107
|
+
hovermode="closest",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
pio.templates[TEMA] = template
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
registrar_tema()
|
dashgusbr/analytics.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Agregações analíticas sobre a OBT do Brasileirão.
|
|
2
|
+
|
|
3
|
+
Funções puras: recebem o DataFrame no schema canônico e devolvem DataFrames
|
|
4
|
+
(ou dicts) prontos para consumo ou visualização. Nenhuma função acessa rede
|
|
5
|
+
ou estado global — a carga é responsabilidade de :mod:`dashgusbr.data`.
|
|
6
|
+
|
|
7
|
+
Nota sobre pontuação: as colunas ``pontos_mandante``/``pontos_visitante`` já
|
|
8
|
+
vêm calculadas pelo ETL respeitando a regra da época (vitória valia 2 pontos
|
|
9
|
+
até 1994 e 3 pontos a partir de 1995). Por isso as agregações SOMAM esses
|
|
10
|
+
pontos em vez de recalcular 3-1-0, e o aproveitamento é normalizado pelo
|
|
11
|
+
valor da vitória vigente em cada temporada.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import difflib
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
from .schema import TIPO_FASE_PONTOS_CORRIDOS
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Helpers internos
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validar_ano(df: pd.DataFrame, ano: int) -> None:
|
|
29
|
+
anos = df["ano_campeonato"].dropna().unique()
|
|
30
|
+
if ano not in anos:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Temporada {ano} não encontrada. Dados disponíveis de "
|
|
33
|
+
f"{int(anos.min())} a {int(anos.max())}."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _validar_time(df: pd.DataFrame, time: str) -> None:
|
|
38
|
+
times = pd.unique(pd.concat([df["mandante"], df["visitante"]]).dropna())
|
|
39
|
+
if time not in times:
|
|
40
|
+
sugestoes = difflib.get_close_matches(time, list(times), n=3)
|
|
41
|
+
dica = f" Você quis dizer: {', '.join(sugestoes)}?" if sugestoes else ""
|
|
42
|
+
raise ValueError(f"Time {time!r} não encontrado na base.{dica}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _pontos_corridos(df: pd.DataFrame) -> pd.DataFrame:
|
|
46
|
+
"""Mantém apenas jogos que contam para a tabela (fase de pontos corridos)."""
|
|
47
|
+
return df[df["tipo_fase"] == TIPO_FASE_PONTOS_CORRIDOS]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _com_placar(df: pd.DataFrame) -> pd.DataFrame:
|
|
51
|
+
return df.dropna(subset=["gols_mandante", "gols_visitante"])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _formato_longo(df: pd.DataFrame) -> pd.DataFrame:
|
|
55
|
+
"""Uma linha por (partida, time): a partida vista da perspectiva de cada lado."""
|
|
56
|
+
mandante = pd.DataFrame(
|
|
57
|
+
{
|
|
58
|
+
"id_partida": df["id_partida"],
|
|
59
|
+
"ano_campeonato": df["ano_campeonato"],
|
|
60
|
+
"data": df["data"],
|
|
61
|
+
"time": df["mandante"],
|
|
62
|
+
"adversario": df["visitante"],
|
|
63
|
+
"local": "mandante",
|
|
64
|
+
"gols_pro": df["gols_mandante"],
|
|
65
|
+
"gols_contra": df["gols_visitante"],
|
|
66
|
+
"resultado": df["resultado_mandante"],
|
|
67
|
+
"pontos": df["pontos_mandante"],
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
visitante = pd.DataFrame(
|
|
71
|
+
{
|
|
72
|
+
"id_partida": df["id_partida"],
|
|
73
|
+
"ano_campeonato": df["ano_campeonato"],
|
|
74
|
+
"data": df["data"],
|
|
75
|
+
"time": df["visitante"],
|
|
76
|
+
"adversario": df["mandante"],
|
|
77
|
+
"local": "visitante",
|
|
78
|
+
"gols_pro": df["gols_visitante"],
|
|
79
|
+
"gols_contra": df["gols_mandante"],
|
|
80
|
+
"resultado": df["resultado_visitante"],
|
|
81
|
+
"pontos": df["pontos_visitante"],
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
return pd.concat([mandante, visitante], ignore_index=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _valor_vitoria(df_temporada: pd.DataFrame) -> int:
|
|
88
|
+
"""Quantos pontos valia a vitória na temporada (2 até 1994, 3 depois)."""
|
|
89
|
+
pontos = pd.concat(
|
|
90
|
+
[df_temporada["pontos_mandante"], df_temporada["pontos_visitante"]]
|
|
91
|
+
).dropna()
|
|
92
|
+
if pontos.empty:
|
|
93
|
+
return 3
|
|
94
|
+
return int(pontos.max())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Classificação
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def classificacao(df: pd.DataFrame, ano: int) -> pd.DataFrame:
|
|
103
|
+
"""Tabela de classificação da fase de pontos corridos de uma temporada.
|
|
104
|
+
|
|
105
|
+
Colunas: posicao, time, pontos, jogos, vitorias, empates, derrotas,
|
|
106
|
+
gols_pro, gols_contra, saldo, aproveitamento (%). Desempate no critério
|
|
107
|
+
CBF: pontos, vitórias, saldo de gols, gols pró.
|
|
108
|
+
"""
|
|
109
|
+
_validar_ano(df, ano)
|
|
110
|
+
temporada = _com_placar(_pontos_corridos(df[df["ano_campeonato"] == ano]))
|
|
111
|
+
longo = _formato_longo(temporada)
|
|
112
|
+
|
|
113
|
+
tabela = (
|
|
114
|
+
longo.groupby("time")
|
|
115
|
+
.agg(
|
|
116
|
+
pontos=("pontos", "sum"),
|
|
117
|
+
jogos=("id_partida", "count"),
|
|
118
|
+
vitorias=("resultado", lambda r: int((r == "V").sum())),
|
|
119
|
+
empates=("resultado", lambda r: int((r == "E").sum())),
|
|
120
|
+
derrotas=("resultado", lambda r: int((r == "D").sum())),
|
|
121
|
+
gols_pro=("gols_pro", "sum"),
|
|
122
|
+
gols_contra=("gols_contra", "sum"),
|
|
123
|
+
)
|
|
124
|
+
.reset_index()
|
|
125
|
+
)
|
|
126
|
+
tabela["saldo"] = tabela["gols_pro"] - tabela["gols_contra"]
|
|
127
|
+
|
|
128
|
+
valor_vitoria = _valor_vitoria(temporada)
|
|
129
|
+
tabela["aproveitamento"] = (
|
|
130
|
+
100 * tabela["pontos"] / (tabela["jogos"] * valor_vitoria)
|
|
131
|
+
).astype(float).round(1)
|
|
132
|
+
|
|
133
|
+
tabela = tabela.sort_values(
|
|
134
|
+
by=["pontos", "vitorias", "saldo", "gols_pro", "time"],
|
|
135
|
+
ascending=[False, False, False, False, True],
|
|
136
|
+
ignore_index=True,
|
|
137
|
+
)
|
|
138
|
+
tabela.insert(0, "posicao", tabela.index + 1)
|
|
139
|
+
return tabela
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Evolução e histórico de um time
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def evolucao_pontos(df: pd.DataFrame, time: str, ano: int) -> pd.DataFrame:
|
|
148
|
+
"""Pontuação acumulada de um time, jogo a jogo, dentro de uma temporada.
|
|
149
|
+
|
|
150
|
+
A OBT não publica número de rodada, então a linha do tempo é ordenada
|
|
151
|
+
pela data da partida (coluna ``jogo`` = 1º, 2º, 3º... jogo do time).
|
|
152
|
+
"""
|
|
153
|
+
_validar_ano(df, ano)
|
|
154
|
+
_validar_time(df, time)
|
|
155
|
+
temporada = _com_placar(_pontos_corridos(df[df["ano_campeonato"] == ano]))
|
|
156
|
+
longo = _formato_longo(temporada)
|
|
157
|
+
jogos = (
|
|
158
|
+
longo[longo["time"] == time]
|
|
159
|
+
.sort_values(["data", "id_partida"])
|
|
160
|
+
.reset_index(drop=True)
|
|
161
|
+
)
|
|
162
|
+
if jogos.empty:
|
|
163
|
+
raise ValueError(f"{time!r} não disputou pontos corridos em {ano}.")
|
|
164
|
+
jogos.insert(0, "jogo", jogos.index + 1)
|
|
165
|
+
jogos["pontos_acumulados"] = jogos["pontos"].cumsum().astype("Int64")
|
|
166
|
+
return jogos[
|
|
167
|
+
[
|
|
168
|
+
"jogo",
|
|
169
|
+
"data",
|
|
170
|
+
"time",
|
|
171
|
+
"adversario",
|
|
172
|
+
"local",
|
|
173
|
+
"gols_pro",
|
|
174
|
+
"gols_contra",
|
|
175
|
+
"resultado",
|
|
176
|
+
"pontos",
|
|
177
|
+
"pontos_acumulados",
|
|
178
|
+
]
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def historico_time(df: pd.DataFrame, time: str) -> pd.DataFrame:
|
|
183
|
+
"""Desempenho de um time temporada a temporada (posição, pontos, aproveitamento).
|
|
184
|
+
|
|
185
|
+
O aproveitamento é comparável entre eras (normalizado pelo valor da
|
|
186
|
+
vitória de cada temporada); a posição vem da classificação completa
|
|
187
|
+
da fase de pontos corridos de cada ano.
|
|
188
|
+
"""
|
|
189
|
+
_validar_time(df, time)
|
|
190
|
+
pontos_corridos = _pontos_corridos(df)
|
|
191
|
+
anos = sorted(
|
|
192
|
+
pontos_corridos[
|
|
193
|
+
(pontos_corridos["mandante"] == time)
|
|
194
|
+
| (pontos_corridos["visitante"] == time)
|
|
195
|
+
]["ano_campeonato"].dropna().unique()
|
|
196
|
+
)
|
|
197
|
+
linhas = []
|
|
198
|
+
for ano in anos:
|
|
199
|
+
tabela = classificacao(df, int(ano))
|
|
200
|
+
linha = tabela[tabela["time"] == time]
|
|
201
|
+
if linha.empty:
|
|
202
|
+
continue
|
|
203
|
+
registro = linha.iloc[0].to_dict()
|
|
204
|
+
registro["ano_campeonato"] = int(ano)
|
|
205
|
+
linhas.append(registro)
|
|
206
|
+
if not linhas:
|
|
207
|
+
raise ValueError(f"{time!r} nunca disputou fases de pontos corridos na base.")
|
|
208
|
+
historico = pd.DataFrame(linhas)
|
|
209
|
+
colunas = ["ano_campeonato"] + [c for c in historico.columns if c != "ano_campeonato"]
|
|
210
|
+
return historico[colunas].reset_index(drop=True)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# Confronto direto
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def confronto(df: pd.DataFrame, time_a: str, time_b: str) -> dict:
|
|
219
|
+
"""Histórico do confronto direto entre dois times (todas as fases).
|
|
220
|
+
|
|
221
|
+
Retorna um dict com o resumo (jogos, vitórias de cada lado, empates,
|
|
222
|
+
gols) e o DataFrame ``partidas`` com todos os jogos, do mais antigo ao
|
|
223
|
+
mais recente.
|
|
224
|
+
"""
|
|
225
|
+
_validar_time(df, time_a)
|
|
226
|
+
_validar_time(df, time_b)
|
|
227
|
+
if time_a == time_b:
|
|
228
|
+
raise ValueError("Informe dois times diferentes para o confronto.")
|
|
229
|
+
|
|
230
|
+
partidas = _com_placar(
|
|
231
|
+
df[
|
|
232
|
+
((df["mandante"] == time_a) & (df["visitante"] == time_b))
|
|
233
|
+
| ((df["mandante"] == time_b) & (df["visitante"] == time_a))
|
|
234
|
+
]
|
|
235
|
+
).sort_values(["data", "id_partida"]).reset_index(drop=True)
|
|
236
|
+
|
|
237
|
+
a_mandante = partidas["mandante"] == time_a
|
|
238
|
+
vitorias_a = (
|
|
239
|
+
(a_mandante & (partidas["resultado_mandante"] == "V"))
|
|
240
|
+
| (~a_mandante & (partidas["resultado_visitante"] == "V"))
|
|
241
|
+
).sum()
|
|
242
|
+
vitorias_b = (
|
|
243
|
+
(~a_mandante & (partidas["resultado_mandante"] == "V"))
|
|
244
|
+
| (a_mandante & (partidas["resultado_visitante"] == "V"))
|
|
245
|
+
).sum()
|
|
246
|
+
empates = (partidas["resultado_mandante"] == "E").sum()
|
|
247
|
+
gols_a = (
|
|
248
|
+
partidas.loc[a_mandante, "gols_mandante"].sum()
|
|
249
|
+
+ partidas.loc[~a_mandante, "gols_visitante"].sum()
|
|
250
|
+
)
|
|
251
|
+
gols_b = (
|
|
252
|
+
partidas.loc[~a_mandante, "gols_mandante"].sum()
|
|
253
|
+
+ partidas.loc[a_mandante, "gols_visitante"].sum()
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
"time_a": time_a,
|
|
258
|
+
"time_b": time_b,
|
|
259
|
+
"jogos": int(len(partidas)),
|
|
260
|
+
"vitorias_a": int(vitorias_a),
|
|
261
|
+
"empates": int(empates),
|
|
262
|
+
"vitorias_b": int(vitorias_b),
|
|
263
|
+
"gols_a": int(gols_a),
|
|
264
|
+
"gols_b": int(gols_b),
|
|
265
|
+
"partidas": partidas,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# Estatísticas agregadas do campeonato
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def estatisticas_temporada(df: pd.DataFrame) -> pd.DataFrame:
|
|
275
|
+
"""Indicadores por temporada: jogos, gols, média de gols e mando de campo.
|
|
276
|
+
|
|
277
|
+
``pct_vitorias_mandante``/``pct_empates``/``pct_vitorias_visitante`` medem
|
|
278
|
+
o peso do fator casa ao longo da história (somam 100% por temporada).
|
|
279
|
+
"""
|
|
280
|
+
jogos = _com_placar(_pontos_corridos(df)).copy()
|
|
281
|
+
jogos["_gols_partida"] = jogos["gols_mandante"] + jogos["gols_visitante"]
|
|
282
|
+
stats = (
|
|
283
|
+
jogos.groupby("ano_campeonato")
|
|
284
|
+
.agg(
|
|
285
|
+
jogos=("id_partida", "count"),
|
|
286
|
+
gols=("_gols_partida", "sum"),
|
|
287
|
+
pct_vitorias_mandante=(
|
|
288
|
+
"resultado_mandante",
|
|
289
|
+
lambda r: round(100 * (r == "V").mean(), 1),
|
|
290
|
+
),
|
|
291
|
+
pct_empates=(
|
|
292
|
+
"resultado_mandante",
|
|
293
|
+
lambda r: round(100 * (r == "E").mean(), 1),
|
|
294
|
+
),
|
|
295
|
+
pct_vitorias_visitante=(
|
|
296
|
+
"resultado_mandante",
|
|
297
|
+
lambda r: round(100 * (r == "D").mean(), 1),
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
.reset_index()
|
|
301
|
+
)
|
|
302
|
+
stats["media_gols"] = (stats["gols"] / stats["jogos"]).astype(float).round(2)
|
|
303
|
+
return stats[
|
|
304
|
+
[
|
|
305
|
+
"ano_campeonato",
|
|
306
|
+
"jogos",
|
|
307
|
+
"gols",
|
|
308
|
+
"media_gols",
|
|
309
|
+
"pct_vitorias_mandante",
|
|
310
|
+
"pct_empates",
|
|
311
|
+
"pct_vitorias_visitante",
|
|
312
|
+
]
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def distribuicao_placares(
|
|
317
|
+
df: pd.DataFrame, ano: Optional[int] = None, max_gols: int = 6
|
|
318
|
+
) -> pd.DataFrame:
|
|
319
|
+
"""Matriz de frequência de placares (gols do mandante × gols do visitante).
|
|
320
|
+
|
|
321
|
+
Placares com mais de ``max_gols`` de um dos lados são agrupados no último
|
|
322
|
+
bin para a matriz não explodir por causa de goleadas raras. Índice =
|
|
323
|
+
gols do mandante, colunas = gols do visitante, valores = nº de jogos.
|
|
324
|
+
"""
|
|
325
|
+
jogos = _com_placar(df if ano is None else df[df["ano_campeonato"] == ano])
|
|
326
|
+
if ano is not None:
|
|
327
|
+
_validar_ano(df, ano)
|
|
328
|
+
gm = jogos["gols_mandante"].clip(upper=max_gols).astype(int)
|
|
329
|
+
gv = jogos["gols_visitante"].clip(upper=max_gols).astype(int)
|
|
330
|
+
matriz = pd.crosstab(gm, gv)
|
|
331
|
+
eixo = range(0, max_gols + 1)
|
|
332
|
+
matriz = matriz.reindex(index=eixo, columns=eixo, fill_value=0)
|
|
333
|
+
matriz.index.name = "gols_mandante"
|
|
334
|
+
matriz.columns.name = "gols_visitante"
|
|
335
|
+
return matriz
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def maiores_goleadas(df: pd.DataFrame, n: int = 10) -> pd.DataFrame:
|
|
339
|
+
"""As ``n`` maiores goleadas da história (maior diferença de gols)."""
|
|
340
|
+
jogos = _com_placar(df).copy()
|
|
341
|
+
jogos["diferenca"] = (jogos["gols_mandante"] - jogos["gols_visitante"]).abs()
|
|
342
|
+
jogos["_gols_partida"] = jogos["gols_mandante"] + jogos["gols_visitante"]
|
|
343
|
+
jogos["placar"] = (
|
|
344
|
+
jogos["gols_mandante"].astype(int).astype(str)
|
|
345
|
+
+ " x "
|
|
346
|
+
+ jogos["gols_visitante"].astype(int).astype(str)
|
|
347
|
+
)
|
|
348
|
+
return (
|
|
349
|
+
jogos.sort_values(
|
|
350
|
+
["diferenca", "_gols_partida", "data"], ascending=[False, False, True]
|
|
351
|
+
)
|
|
352
|
+
.head(n)[
|
|
353
|
+
[
|
|
354
|
+
"ano_campeonato",
|
|
355
|
+
"data",
|
|
356
|
+
"mandante",
|
|
357
|
+
"placar",
|
|
358
|
+
"visitante",
|
|
359
|
+
"diferenca",
|
|
360
|
+
]
|
|
361
|
+
]
|
|
362
|
+
.reset_index(drop=True)
|
|
363
|
+
)
|
dashgusbr/client.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Fachada pública da dashgusbr: a classe :class:`Brasileirao`.
|
|
2
|
+
|
|
3
|
+
Orquestra as camadas puras (``data`` → ``analytics`` → ``viz``) com um
|
|
4
|
+
DataFrame cacheado por instância. Usuários avançados podem importar as
|
|
5
|
+
camadas diretamente (``from dashgusbr import analytics, viz``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Iterable, Optional, Union
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
import plotly.graph_objects as go
|
|
14
|
+
|
|
15
|
+
from . import analytics, data, viz
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Brasileirao:
|
|
19
|
+
"""Ponto de entrada da biblioteca.
|
|
20
|
+
|
|
21
|
+
Carrega a OBT sob demanda (no primeiro uso, não no construtor) e a mantém
|
|
22
|
+
em memória; todos os métodos de análise e plot operam sobre essa cópia.
|
|
23
|
+
|
|
24
|
+
Examples
|
|
25
|
+
--------
|
|
26
|
+
>>> from dashgusbr import Brasileirao
|
|
27
|
+
>>> br = Brasileirao()
|
|
28
|
+
>>> br.tabela(2023).head()
|
|
29
|
+
>>> br.plot_confronto("Flamengo", "Palmeiras").show()
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
fonte: str = "auto",
|
|
35
|
+
github_url: Optional[str] = None,
|
|
36
|
+
sheets_url: Optional[str] = None,
|
|
37
|
+
cache: bool = True,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._fonte = fonte
|
|
40
|
+
self._github_url = github_url
|
|
41
|
+
self._sheets_url = sheets_url
|
|
42
|
+
self._cache = cache
|
|
43
|
+
self._df: Optional[pd.DataFrame] = None
|
|
44
|
+
|
|
45
|
+
# -- dados -------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def df(self) -> pd.DataFrame:
|
|
49
|
+
"""A OBT completa (carga preguiçosa; não modifique in-place)."""
|
|
50
|
+
if self._df is None:
|
|
51
|
+
self._df = data.carregar_dados(
|
|
52
|
+
fonte=self._fonte,
|
|
53
|
+
github_url=self._github_url,
|
|
54
|
+
sheets_url=self._sheets_url,
|
|
55
|
+
cache=self._cache,
|
|
56
|
+
)
|
|
57
|
+
return self._df
|
|
58
|
+
|
|
59
|
+
def recarregar(self) -> "Brasileirao":
|
|
60
|
+
"""Força novo download da fonte, ignorando os caches."""
|
|
61
|
+
self._df = data.carregar_dados(
|
|
62
|
+
fonte=self._fonte,
|
|
63
|
+
github_url=self._github_url,
|
|
64
|
+
sheets_url=self._sheets_url,
|
|
65
|
+
cache=self._cache,
|
|
66
|
+
forcar_download=True,
|
|
67
|
+
)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def partidas(
|
|
71
|
+
self, ano: Optional[int] = None, time: Optional[str] = None
|
|
72
|
+
) -> pd.DataFrame:
|
|
73
|
+
"""Partidas da base, opcionalmente filtradas por temporada e/ou time."""
|
|
74
|
+
partidas = self.df
|
|
75
|
+
if ano is not None:
|
|
76
|
+
partidas = partidas[partidas["ano_campeonato"] == ano]
|
|
77
|
+
if time is not None:
|
|
78
|
+
partidas = partidas[
|
|
79
|
+
(partidas["mandante"] == time) | (partidas["visitante"] == time)
|
|
80
|
+
]
|
|
81
|
+
return partidas.reset_index(drop=True).copy()
|
|
82
|
+
|
|
83
|
+
def anos(self) -> "list[int]":
|
|
84
|
+
"""Temporadas disponíveis na base, em ordem crescente."""
|
|
85
|
+
return sorted(int(a) for a in self.df["ano_campeonato"].dropna().unique())
|
|
86
|
+
|
|
87
|
+
def times(self, ano: Optional[int] = None) -> "list[str]":
|
|
88
|
+
"""Times presentes na base (ou apenas em uma temporada), em ordem alfabética."""
|
|
89
|
+
partidas = self.partidas(ano=ano)
|
|
90
|
+
return sorted(
|
|
91
|
+
pd.unique(pd.concat([partidas["mandante"], partidas["visitante"]]).dropna())
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# -- classificação -----------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def tabela(self, ano: int) -> pd.DataFrame:
|
|
97
|
+
"""Classificação da fase de pontos corridos da temporada."""
|
|
98
|
+
return analytics.classificacao(self.df, ano)
|
|
99
|
+
|
|
100
|
+
def plot_tabela(self, ano: int) -> go.Figure:
|
|
101
|
+
"""Gráfico de barras da classificação da temporada."""
|
|
102
|
+
return viz.classificacao(
|
|
103
|
+
self.tabela(ano), titulo=f"Brasileirão {ano} — Classificação"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# -- evolução e histórico ----------------------------------------------
|
|
107
|
+
|
|
108
|
+
def evolucao(self, time: str, ano: int) -> pd.DataFrame:
|
|
109
|
+
"""Pontos acumulados do time, jogo a jogo, na temporada."""
|
|
110
|
+
return analytics.evolucao_pontos(self.df, time, ano)
|
|
111
|
+
|
|
112
|
+
def plot_evolucao(
|
|
113
|
+
self, times: Union[str, Iterable[str]], ano: int
|
|
114
|
+
) -> go.Figure:
|
|
115
|
+
"""Linha(s) de pontos acumulados de um ou mais times na temporada."""
|
|
116
|
+
if isinstance(times, str):
|
|
117
|
+
times = [times]
|
|
118
|
+
evolucoes = pd.concat(
|
|
119
|
+
[analytics.evolucao_pontos(self.df, t, ano) for t in times],
|
|
120
|
+
ignore_index=True,
|
|
121
|
+
)
|
|
122
|
+
return viz.evolucao(
|
|
123
|
+
evolucoes, titulo=f"Brasileirão {ano} — Evolução de pontos"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def historico(self, time: str) -> pd.DataFrame:
|
|
127
|
+
"""Desempenho do time temporada a temporada (posição, pontos, aproveitamento)."""
|
|
128
|
+
return analytics.historico_time(self.df, time)
|
|
129
|
+
|
|
130
|
+
def plot_historico(
|
|
131
|
+
self, time: str, metrica: str = "aproveitamento"
|
|
132
|
+
) -> go.Figure:
|
|
133
|
+
"""Linha do desempenho histórico do time (aproveitamento por padrão)."""
|
|
134
|
+
return viz.historico(self.historico(time), metrica=metrica)
|
|
135
|
+
|
|
136
|
+
# -- confronto direto ----------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def confronto(self, time_a: str, time_b: str) -> dict:
|
|
139
|
+
"""Resumo do confronto direto (inclui o DataFrame ``partidas``)."""
|
|
140
|
+
return analytics.confronto(self.df, time_a, time_b)
|
|
141
|
+
|
|
142
|
+
def plot_confronto(self, time_a: str, time_b: str) -> go.Figure:
|
|
143
|
+
"""Barras de vitórias/empates do confronto direto."""
|
|
144
|
+
return viz.confronto(self.confronto(time_a, time_b))
|
|
145
|
+
|
|
146
|
+
# -- estatísticas do campeonato ------------------------------------------
|
|
147
|
+
|
|
148
|
+
def estatisticas(self) -> pd.DataFrame:
|
|
149
|
+
"""Indicadores por temporada: jogos, gols, média de gols, fator casa."""
|
|
150
|
+
return analytics.estatisticas_temporada(self.df)
|
|
151
|
+
|
|
152
|
+
def plot_gols_por_temporada(self) -> go.Figure:
|
|
153
|
+
"""Linha da média de gols por jogo em cada temporada."""
|
|
154
|
+
return viz.gols_por_temporada(self.estatisticas())
|
|
155
|
+
|
|
156
|
+
def plot_mandante_visitante(self) -> go.Figure:
|
|
157
|
+
"""Linhas do fator casa (% vitórias mandante/empates/visitante)."""
|
|
158
|
+
return viz.mandante_visitante(self.estatisticas())
|
|
159
|
+
|
|
160
|
+
def placares(self, ano: Optional[int] = None, max_gols: int = 6) -> pd.DataFrame:
|
|
161
|
+
"""Matriz de frequência de placares (mandante × visitante)."""
|
|
162
|
+
return analytics.distribuicao_placares(self.df, ano=ano, max_gols=max_gols)
|
|
163
|
+
|
|
164
|
+
def plot_placares(
|
|
165
|
+
self, ano: Optional[int] = None, max_gols: int = 6
|
|
166
|
+
) -> go.Figure:
|
|
167
|
+
"""Heatmap da distribuição de placares."""
|
|
168
|
+
titulo = (
|
|
169
|
+
f"Brasileirão {ano} — Distribuição de placares"
|
|
170
|
+
if ano is not None
|
|
171
|
+
else "Distribuição de placares (1971–hoje)"
|
|
172
|
+
)
|
|
173
|
+
return viz.distribuicao_placares(
|
|
174
|
+
self.placares(ano=ano, max_gols=max_gols), titulo=titulo
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def goleadas(self, n: int = 10) -> pd.DataFrame:
|
|
178
|
+
"""As ``n`` maiores goleadas da história do campeonato."""
|
|
179
|
+
return analytics.maiores_goleadas(self.df, n=n)
|
|
180
|
+
|
|
181
|
+
def __repr__(self) -> str:
|
|
182
|
+
estado = "não carregado" if self._df is None else f"{len(self._df)} partidas"
|
|
183
|
+
return f"Brasileirao(fonte={self._fonte!r}, dados: {estado})"
|