pablogrois Claude Fable 5 commited on
Commit
bf4531b
·
1 Parent(s): 281bc09

Tablas bloque bajo: diferencia (±x) vs media de liga-temporada + selector de temporada

Browse files

- Artefacto con columna temporada; medias ponderadas por intentos por liga-temporada
- Cada métrica muestra (±x) vs esa media (pv coloreado); columna 'media liga' retirada
- Selector de temporada junto a liga y equipo

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/racing_reports/api/routes_lowblock.py CHANGED
@@ -24,8 +24,8 @@ def data(league: str = "__all__") -> dict:
24
 
25
 
26
  @router.get("/team", dependencies=[Depends(require_auth)])
27
- def team(league: str, equipo: str) -> dict:
28
  try:
29
- return lowblock_data.team_tables(league, equipo)
30
  except ValueError as exc:
31
  raise HTTPException(status_code=404, detail=str(exc))
 
24
 
25
 
26
  @router.get("/team", dependencies=[Depends(require_auth)])
27
+ def team(league: str, equipo: str, season: str | None = None) -> dict:
28
  try:
29
+ return lowblock_data.team_tables(league, equipo, season)
30
  except ValueError as exc:
31
  raise HTTPException(status_code=404, detail=str(exc))
src/racing_reports/lowblock_data.py CHANGED
@@ -55,8 +55,11 @@ def _df() -> pd.DataFrame:
55
 
56
  def options() -> dict:
57
  df = _df()
 
 
58
  return {
59
  "leagues": sorted(df["liga"].unique().tolist()),
 
60
  "recursos": RECURSO_LABELS,
61
  "metricas": METRICA_LABELS,
62
  "lados": {"of": "Ofensivo", "def": "Defensivo"},
@@ -67,36 +70,43 @@ def scatter_rows(league: str) -> list[dict]:
67
  df = _df()
68
  if league and league != "__all__":
69
  df = df[df["liga"] == league]
70
- cols = ["liga", "equipo", "lado", "recurso", "n_intentos", "pct_exito", "pv_jugada",
71
  "pv_jugada_exitosa", "evento_clave",
72
  "tiros_jugada", "xg_acum", "xg_jugada", "xg_jugada_exitosa", "goles", "pct_goles"]
73
  out = df[cols].where(pd.notna(df[cols]), None)
74
  return out.to_dict("records")
75
 
76
 
77
- def team_tables(league: str, equipo: str) -> dict:
 
 
 
 
78
  df = _df()
79
  liga_df = df[df["liga"] == league]
 
 
80
  if liga_df.empty:
81
- raise ValueError(f"Liga sin datos: {league}")
82
- # medias de la liga ponderadas por intentos (para la referencia "vs liga")
83
  out = {}
84
  for lado in ("of", "def"):
85
  sub = liga_df[liga_df["lado"] == lado]
86
- medias = {}
 
87
  for rec, g in sub.groupby("recurso"):
88
  w = g["n_intentos"].clip(lower=1)
89
- pvw = (g["pv_jugada"] * w).sum() / w.sum()
90
- medias[rec] = {
91
- "pv_jugada": (round(float(pvw), 2) if pd.notna(pvw) else None),
92
- "pct_exito": (lambda v: round(float(v), 1) if pd.notna(v) else None)(
93
- (g["pct_exito"] * w).sum() / w.sum()),
94
- "xg_jugada": round(float((g["xg_jugada"].fillna(0) * w).sum() / w.sum()), 4),
95
- }
96
  rows = sub[sub["equipo"] == equipo].sort_values("pv_jugada", ascending=False)
97
- out[lado] = {
98
- "rows": rows.where(pd.notna(rows), None).to_dict("records"),
99
- "liga_media": medias,
100
- }
101
- return {"league": league, "equipo": equipo,
 
 
 
 
102
  "recursos": RECURSO_LABELS, **out}
 
55
 
56
  def options() -> dict:
57
  df = _df()
58
+ seasons = {lg: sorted(g["temporada"].astype(str).unique(), reverse=True)
59
+ for lg, g in df.groupby("liga")}
60
  return {
61
  "leagues": sorted(df["liga"].unique().tolist()),
62
+ "seasons": seasons,
63
  "recursos": RECURSO_LABELS,
64
  "metricas": METRICA_LABELS,
65
  "lados": {"of": "Ofensivo", "def": "Defensivo"},
 
70
  df = _df()
71
  if league and league != "__all__":
72
  df = df[df["liga"] == league]
73
+ cols = ["liga", "temporada", "equipo", "lado", "recurso", "n_intentos", "pct_exito", "pv_jugada",
74
  "pv_jugada_exitosa", "evento_clave",
75
  "tiros_jugada", "xg_acum", "xg_jugada", "xg_jugada_exitosa", "goles", "pct_goles"]
76
  out = df[cols].where(pd.notna(df[cols]), None)
77
  return out.to_dict("records")
78
 
79
 
80
+ DIF_METRICS = {"pct_exito": 1, "pv_jugada": 2, "pv_jugada_exitosa": 2,
81
+ "tiros_jugada": 3, "xg_jugada": 4, "pct_goles": 2}
82
+
83
+
84
+ def team_tables(league: str, equipo: str, season: str | None = None) -> dict:
85
  df = _df()
86
  liga_df = df[df["liga"] == league]
87
+ if season:
88
+ liga_df = liga_df[liga_df["temporada"].astype(str) == str(season)]
89
  if liga_df.empty:
90
+ raise ValueError(f"Liga/temporada sin datos: {league} {season or ''}")
 
91
  out = {}
92
  for lado in ("of", "def"):
93
  sub = liga_df[liga_df["lado"] == lado]
94
+ # media de la liga ponderada por intentos, por recurso y métrica
95
+ medias: dict[str, dict] = {}
96
  for rec, g in sub.groupby("recurso"):
97
  w = g["n_intentos"].clip(lower=1)
98
+ medias[rec] = {}
99
+ for m, nd in DIF_METRICS.items():
100
+ v = (g[m] * w).sum() / w.sum()
101
+ medias[rec][m] = round(float(v), nd) if pd.notna(v) else None
 
 
 
102
  rows = sub[sub["equipo"] == equipo].sort_values("pv_jugada", ascending=False)
103
+ recs = rows.where(pd.notna(rows), None).to_dict("records")
104
+ for r in recs:
105
+ mu = medias.get(r["recurso"], {})
106
+ r["dif"] = {}
107
+ for m, nd in DIF_METRICS.items():
108
+ if r.get(m) is not None and mu.get(m) is not None:
109
+ r["dif"][m] = round(float(r[m]) - mu[m], nd)
110
+ out[lado] = {"rows": recs, "liga_media": medias}
111
+ return {"league": league, "season": season, "equipo": equipo,
112
  "recursos": RECURSO_LABELS, **out}
src/racing_reports/web/templates/home.html CHANGED
@@ -573,6 +573,12 @@
573
  <template x-for="l in lbLeagues" :key="l"><option :value="l" x-text="l"></option></template>
574
  </select>
575
  </div>
 
 
 
 
 
 
576
  <div>
577
  <label class="block text-xs text-gray-400 mb-1">Equipo</label>
578
  <select x-model="lbtEquipo" class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm">
@@ -582,7 +588,7 @@
582
  <button @click="lbtRun()" class="bg-racing-500 hover:bg-racing-600 text-racing-900 font-semibold px-4 py-2 rounded-lg text-sm">Generar tablas</button>
583
  </div>
584
  <p class="text-xs text-red-300" x-show="lbtError" x-text="lbtError"></p>
585
- <p class="text-xs text-gray-500 mb-2" x-show="lbtResult">Peligro/jugada = pvAdded medio por intento (‰): un valor negativo significa que en promedio esos intentos PIERDEN valor de posesión (el costo de los fallos supera lo que generan los aciertos). Evento clave = nº de ataques en los que ese recurso fue la acción que MÁS peligro agregó. Rompe-líneas no muestra % éxito (el proveedor solo marca los completados). El tiro desde fuera del área no muestra pv ni evento clave: el tiro REALIZA el peligro en vez de agregarlo (el gol tiene pvAdded nulo y el errado negativo) — usar % éxito (= % gol), xG y goles.</p>
586
  <template x-for="lado in ['of','def']" :key="lado">
587
  <div x-show="lbtResult" class="mb-4">
588
  <div class="text-xs uppercase tracking-wide mb-1" :class="lado==='of' ? 'text-racing-200' : 'text-yellow-200'"
@@ -590,7 +596,7 @@
590
  <table class="w-full text-xs">
591
  <thead><tr class="text-gray-400 text-left">
592
  <th class="py-1">Recurso</th><th class="text-right">Intentos</th><th class="text-right">% éxito</th>
593
- <th class="text-right">Peligro/jugada (‰)</th><th class="text-right">media liga</th>
594
  <th class="text-right">Peligro/jug. exitosa</th><th class="text-right">Evento clave</th>
595
  <th class="text-right">Tiros/jugada</th><th class="text-right">xG/jugada</th><th class="text-right">Goles</th><th class="text-right">% goles</th>
596
  </tr></thead>
@@ -599,15 +605,14 @@
599
  <tr class="border-t border-ink-600">
600
  <td class="py-1" x-text="lbRecursos[r.recurso]||r.recurso"></td>
601
  <td class="text-right" x-text="r.n_intentos"></td>
602
- <td class="text-right" x-text="r.pct_exito!=null ? r.pct_exito+'%' : '—'"></td>
603
- <td class="text-right font-semibold" x-text="r.pv_jugada!=null ? r.pv_jugada : '—'"></td>
604
- <td class="text-right text-gray-400" x-text="((lbtResult[lado].liga_media[r.recurso]||{}).pv_jugada != null) ? lbtResult[lado].liga_media[r.recurso].pv_jugada : ''"></td>
605
- <td class="text-right" x-text="r.pv_jugada_exitosa ?? '—'"></td>
606
  <td class="text-right" x-text="r.evento_clave ?? '—'"></td>
607
- <td class="text-right" x-text="r.tiros_jugada ?? '—'"></td>
608
- <td class="text-right" x-text="r.xg_jugada ?? '—'"></td>
609
  <td class="text-right" x-text="r.goles ?? '—'"></td>
610
- <td class="text-right" x-text="r.pct_goles!=null ? r.pct_goles+'%' : '—'"></td>
611
  </tr>
612
  </template>
613
  </tbody>
@@ -738,6 +743,7 @@
738
  lbPinned: {},
739
  lbtLeague: '', lbtEquipo: '', lbtEquipos: [], lbtResult: null, lbtError: '',
740
  lbBuscar: '', lbEquiposPlot: [],
 
741
 
742
  async init() { await this.loadLeagues(); await this.loadSeasons(false); await this.loadTeams(); this._refreshHealth(); },
743
  async _getJSON(u){ const r=await fetch(u); if(!r.ok) throw new Error((await r.json().catch(()=>({detail:r.statusText}))).detail); return r.json(); },
@@ -966,6 +972,7 @@
966
  try {
967
  const o = await this._getJSON('/api/lowblock/options');
968
  this.lbLeagues = o.leagues||[]; this.lbRecursos = o.recursos||{}; this.lbMetricas = o.metricas||{};
 
969
  this.lbLoaded = true;
970
  await this.$nextTick();
971
  this.lbX = {...this.lbX}; this.lbY = {...this.lbY};
@@ -1053,6 +1060,8 @@
1053
  },
1054
  async lbtLoadTeams(){
1055
  if (!this.lbtLeague){ this.lbtEquipos=[]; return; }
 
 
1056
  try {
1057
  const d = await this._getJSON(`/api/lowblock/data?league=${encodeURIComponent(this.lbtLeague)}`);
1058
  this.lbtEquipos = [...new Set((d.rows||[]).map(r=>r.equipo))].sort();
@@ -1060,11 +1069,17 @@
1060
  this.lbtEquipo = this.lbtEquipos.includes('Racing de Santander') ? 'Racing de Santander' : (this.lbtEquipos[0]||'');
1061
  } catch(e){ this.lbtEquipos=[]; }
1062
  },
 
 
 
 
 
 
1063
  async lbtRun(){
1064
  this.lbtError=''; this.lbtResult=null;
1065
  if (!this.lbtLeague || !this.lbtEquipo){ this.lbtError='Elegí liga y equipo.'; return; }
1066
  try {
1067
- this.lbtResult = await this._getJSON(`/api/lowblock/team?league=${encodeURIComponent(this.lbtLeague)}&equipo=${encodeURIComponent(this.lbtEquipo)}`);
1068
  } catch(e){ this.lbtError = 'Error: '+e; }
1069
  },
1070
  async ejRunPost(){
 
573
  <template x-for="l in lbLeagues" :key="l"><option :value="l" x-text="l"></option></template>
574
  </select>
575
  </div>
576
+ <div>
577
+ <label class="block text-xs text-gray-400 mb-1">Temporada</label>
578
+ <select x-model="lbtSeason" class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm">
579
+ <template x-for="s in (lbSeasons[lbtLeague]||[])" :key="s"><option :value="s" x-text="s"></option></template>
580
+ </select>
581
+ </div>
582
  <div>
583
  <label class="block text-xs text-gray-400 mb-1">Equipo</label>
584
  <select x-model="lbtEquipo" class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm">
 
588
  <button @click="lbtRun()" class="bg-racing-500 hover:bg-racing-600 text-racing-900 font-semibold px-4 py-2 rounded-lg text-sm">Generar tablas</button>
589
  </div>
590
  <p class="text-xs text-red-300" x-show="lbtError" x-text="lbtError"></p>
591
+ <p class="text-xs text-gray-500 mb-2" x-show="lbtResult">Peligro/jugada = pvAdded medio por intento (‰): un valor negativo significa que en promedio esos intentos PIERDEN valor de posesión (el costo de los fallos supera lo que generan los aciertos). Evento clave = nº de ataques en los que ese recurso fue la acción que MÁS peligro agregó. Entre paréntesis: diferencia vs la media (ponderada por intentos) de esa liga-temporada. Rompe-líneas no muestra % éxito (el proveedor solo marca los completados). El tiro desde fuera del área no muestra pv ni evento clave: el tiro REALIZA el peligro en vez de agregarlo (el gol tiene pvAdded nulo y el errado negativo) — usar % éxito (= % gol), xG y goles.</p>
592
  <template x-for="lado in ['of','def']" :key="lado">
593
  <div x-show="lbtResult" class="mb-4">
594
  <div class="text-xs uppercase tracking-wide mb-1" :class="lado==='of' ? 'text-racing-200' : 'text-yellow-200'"
 
596
  <table class="w-full text-xs">
597
  <thead><tr class="text-gray-400 text-left">
598
  <th class="py-1">Recurso</th><th class="text-right">Intentos</th><th class="text-right">% éxito</th>
599
+ <th class="text-right">Peligro/jugada (‰)</th>
600
  <th class="text-right">Peligro/jug. exitosa</th><th class="text-right">Evento clave</th>
601
  <th class="text-right">Tiros/jugada</th><th class="text-right">xG/jugada</th><th class="text-right">Goles</th><th class="text-right">% goles</th>
602
  </tr></thead>
 
605
  <tr class="border-t border-ink-600">
606
  <td class="py-1" x-text="lbRecursos[r.recurso]||r.recurso"></td>
607
  <td class="text-right" x-text="r.n_intentos"></td>
608
+ <td class="text-right"><span x-text="r.pct_exito!=null ? r.pct_exito+'%' : '—'"></span><span class="text-gray-500" x-text="lbDif(r,'pct_exito')"></span></td>
609
+ <td class="text-right font-semibold"><span x-text="r.pv_jugada!=null ? r.pv_jugada : '—'"></span><span class="font-normal" :class="((r.dif||{}).pv_jugada||0)>=0 ? 'text-racing-200' : 'text-red-300'" x-text="lbDif(r,'pv_jugada')"></span></td>
610
+ <td class="text-right"><span x-text="r.pv_jugada_exitosa ?? '—'"></span><span class="text-gray-500" x-text="lbDif(r,'pv_jugada_exitosa')"></span></td>
 
611
  <td class="text-right" x-text="r.evento_clave ?? '—'"></td>
612
+ <td class="text-right"><span x-text="r.tiros_jugada ?? '—'"></span><span class="text-gray-500" x-text="lbDif(r,'tiros_jugada')"></span></td>
613
+ <td class="text-right"><span x-text="r.xg_jugada ?? '—'"></span><span class="text-gray-500" x-text="lbDif(r,'xg_jugada')"></span></td>
614
  <td class="text-right" x-text="r.goles ?? '—'"></td>
615
+ <td class="text-right"><span x-text="r.pct_goles!=null ? r.pct_goles+'%' : '—'"></span><span class="text-gray-500" x-text="lbDif(r,'pct_goles')"></span></td>
616
  </tr>
617
  </template>
618
  </tbody>
 
743
  lbPinned: {},
744
  lbtLeague: '', lbtEquipo: '', lbtEquipos: [], lbtResult: null, lbtError: '',
745
  lbBuscar: '', lbEquiposPlot: [],
746
+ lbSeasons: {}, lbtSeason: '',
747
 
748
  async init() { await this.loadLeagues(); await this.loadSeasons(false); await this.loadTeams(); this._refreshHealth(); },
749
  async _getJSON(u){ const r=await fetch(u); if(!r.ok) throw new Error((await r.json().catch(()=>({detail:r.statusText}))).detail); return r.json(); },
 
972
  try {
973
  const o = await this._getJSON('/api/lowblock/options');
974
  this.lbLeagues = o.leagues||[]; this.lbRecursos = o.recursos||{}; this.lbMetricas = o.metricas||{};
975
+ this.lbSeasons = o.seasons||{};
976
  this.lbLoaded = true;
977
  await this.$nextTick();
978
  this.lbX = {...this.lbX}; this.lbY = {...this.lbY};
 
1060
  },
1061
  async lbtLoadTeams(){
1062
  if (!this.lbtLeague){ this.lbtEquipos=[]; return; }
1063
+ const ss = this.lbSeasons[this.lbtLeague]||[];
1064
+ if (!ss.includes(this.lbtSeason)) this.lbtSeason = ss[0]||'';
1065
  try {
1066
  const d = await this._getJSON(`/api/lowblock/data?league=${encodeURIComponent(this.lbtLeague)}`);
1067
  this.lbtEquipos = [...new Set((d.rows||[]).map(r=>r.equipo))].sort();
 
1069
  this.lbtEquipo = this.lbtEquipos.includes('Racing de Santander') ? 'Racing de Santander' : (this.lbtEquipos[0]||'');
1070
  } catch(e){ this.lbtEquipos=[]; }
1071
  },
1072
+ lbDif(r, m){
1073
+ const d = (r.dif||{})[m];
1074
+ if (d==null) return '';
1075
+ const s = (d>0?'+':'') + d;
1076
+ return ` (${s})`;
1077
+ },
1078
  async lbtRun(){
1079
  this.lbtError=''; this.lbtResult=null;
1080
  if (!this.lbtLeague || !this.lbtEquipo){ this.lbtError='Elegí liga y equipo.'; return; }
1081
  try {
1082
+ this.lbtResult = await this._getJSON(`/api/lowblock/team?league=${encodeURIComponent(this.lbtLeague)}&equipo=${encodeURIComponent(this.lbtEquipo)}&season=${encodeURIComponent(this.lbtSeason)}`);
1083
  } catch(e){ this.lbtError = 'Error: '+e; }
1084
  },
1085
  async ejRunPost(){
vendor/data/lowblock/team_resources.parquet CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:0bd47081b7527ee6fb55e23cfdb4e9b281002e1a2a927f587e749bbc68ae886d
3
- size 236523
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2ab8ad8a165c43d5790dea6fc45afa5afeb733c140f2074f0157aa376eee1049
3
+ size 237224