pablogrois Claude Opus 4.8 commited on
Commit
021b03d
·
1 Parent(s): 1bdff3c

Descargar jugadas (CSV) en Ejes y Recursos — Segunda (resto de ligas pendiente)

Browse files

- Nueva tabla per-secuencia vendor/data/plays/<liga>/<temp>/plays.parquet: partido,
local/visitante, equipo/rival, fecha, resultado final del partido, min:seg inicio/fin,
cantidad de pases, si tuvo tiro, xG acumulado, tipo de jugada (origen → desenlace, ej
"Corner → Gol"), bloque y flags de recurso. Se arma con scripts/build_plays_table.py.
- Endpoints /api/plays/ejes (todas las secuencias del equipo) y /api/plays/recursos
(filtra por lado/recurso/bloques) que devuelven CSV. plays_data.py + routes_plays.py.
- Botones "Descargar jugadas (CSV)" en Ejes (sección 4) y Recursos (sección 5). El 6 no
lleva (es a nivel partido). Por ahora solo Segunda tiene datos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

src/racing_reports/api/routes_plays.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Endpoints de descarga de jugadas (CSV) para Ejes y Recursos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from fastapi.responses import Response
7
+
8
+ from racing_reports import plays_data
9
+ from racing_reports.api.deps import require_auth
10
+
11
+ router = APIRouter(prefix="/api/plays")
12
+
13
+
14
+ def _csv_response(text: str, filename: str) -> Response:
15
+ return Response(content=text, media_type="text/csv; charset=utf-8",
16
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'})
17
+
18
+
19
+ def _slug(s: str) -> str:
20
+ return "".join(c if c.isalnum() else "_" for c in s).strip("_")
21
+
22
+
23
+ @router.get("/available", dependencies=[Depends(require_auth)])
24
+ def available(league: str, season: str) -> dict:
25
+ return {"available": plays_data.available(league, season)}
26
+
27
+
28
+ @router.get("/ejes", dependencies=[Depends(require_auth)])
29
+ def ejes(league: str, season: str, team: str) -> Response:
30
+ try:
31
+ csv = plays_data.ejes_csv(league, season, team)
32
+ except FileNotFoundError as exc:
33
+ raise HTTPException(status_code=404, detail=str(exc))
34
+ return _csv_response(csv, f"jugadas_{_slug(team)}_{_slug(season)}.csv")
35
+
36
+
37
+ @router.get("/recursos", dependencies=[Depends(require_auth)])
38
+ def recursos(league: str, season: str, team: str, lado: str = "of",
39
+ recurso: str = "todos", bloques: str | None = None) -> Response:
40
+ try:
41
+ csv = plays_data.recursos_csv(league, season, team, lado, recurso, bloques)
42
+ except FileNotFoundError as exc:
43
+ raise HTTPException(status_code=404, detail=str(exc))
44
+ return _csv_response(csv, f"jugadas_{_slug(team)}_{recurso}_{_slug(season)}.csv")
src/racing_reports/plays_data.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Descarga de "jugadas" (secuencias) en CSV para los gráficos de Ejes y Recursos.
2
+
3
+ Lee ``vendor/data/plays/<slug>/<season>/plays.parquet`` (1 fila por secuencia con partido,
4
+ local/visitante, resultado, tiempos, pases, tiro, xG acumulado, tipo de jugada, bloque y
5
+ flags de recurso). Filtra por equipo (y por recurso/bloque en Recursos) y arma el CSV.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ from functools import lru_cache
11
+
12
+ import pandas as pd
13
+
14
+ from racing_reports import vendor_env
15
+
16
+ # columnas visibles del CSV (orden y nombres para el usuario)
17
+ CSV_COLS = ["matchId", "local", "visitante", "equipo", "rival", "fecha", "resultado_final",
18
+ "min_inicio", "seg_inicio", "min_fin", "seg_fin", "n_pases", "tiro",
19
+ "xg_acumulado", "tipo_jugada"]
20
+ RENAME = {"matchId": "partido"}
21
+
22
+
23
+ def _slug(text: str) -> str:
24
+ return "".join(c.lower() if c.isalnum() else "-" for c in text).strip("-")
25
+
26
+
27
+ @lru_cache(maxsize=8)
28
+ def _df(league: str, season: str) -> pd.DataFrame:
29
+ p = vendor_env.DATA_DIR / "plays" / _slug(league) / str(season) / "plays.parquet"
30
+ if not p.exists():
31
+ raise FileNotFoundError(f"Sin jugadas para {league} {season}")
32
+ return pd.read_parquet(p)
33
+
34
+
35
+ def available(league: str, season: str) -> bool:
36
+ p = vendor_env.DATA_DIR / "plays" / _slug(league) / str(season) / "plays.parquet"
37
+ return p.exists()
38
+
39
+
40
+ def _to_csv(df: pd.DataFrame) -> str:
41
+ out = df[CSV_COLS].rename(columns=RENAME)
42
+ buf = io.StringIO()
43
+ out.to_csv(buf, index=False)
44
+ return buf.getvalue()
45
+
46
+
47
+ def ejes_csv(league: str, season: str, team: str) -> str:
48
+ """Todas las secuencias de ataque del equipo (para el gráfico de Ejes)."""
49
+ df = _df(league, season)
50
+ df = df[df["equipo"].astype(str) == team].sort_values(["fecha", "matchId", "min_inicio"])
51
+ return _to_csv(df)
52
+
53
+
54
+ def recursos_csv(league: str, season: str, team: str, lado: str = "of",
55
+ recurso: str = "todos", bloques: str | None = None) -> str:
56
+ """Secuencias del recurso/bloque elegidos. lado 'of' = ataques del equipo;
57
+ 'def' = lo que concede (ataques del rival contra el equipo)."""
58
+ df = _df(league, season)
59
+ key = "equipo" if lado != "def" else "rival"
60
+ df = df[df[key].astype(str) == team]
61
+ sel = [b.strip() for b in (bloques or "").split(",") if b.strip() in ("bajo", "medio", "alto")]
62
+ if sel:
63
+ df = df[df["bloque"].isin(sel)]
64
+ col = f"rec_{recurso}"
65
+ if col in df.columns:
66
+ df = df[df[col] == 1]
67
+ return _to_csv(df.sort_values(["fecha", "matchId", "min_inicio"]))
src/racing_reports/web/app.py CHANGED
@@ -27,6 +27,7 @@ try: # feature en desarrollo (archivos pueden no estar en el deploy)
27
  except Exception: # noqa: BLE001
28
  api_goalkicks_router = None
29
  from racing_reports.api.routes_lowblock import router as api_lowblock_router
 
30
  from racing_reports.config import DEFAULT_SETTINGS
31
  from racing_reports.datastore import DataStore
32
  from racing_reports.match_index import MatchIndex
@@ -57,6 +58,7 @@ app.include_router(api_ejes_router)
57
  if api_goalkicks_router is not None:
58
  app.include_router(api_goalkicks_router)
59
  app.include_router(api_lowblock_router)
 
60
 
61
  # Estado en memoria (single container en HF Space). Re-export para retrocompat.
62
  Job = jobs_mod.Job
 
27
  except Exception: # noqa: BLE001
28
  api_goalkicks_router = None
29
  from racing_reports.api.routes_lowblock import router as api_lowblock_router
30
+ from racing_reports.api.routes_plays import router as api_plays_router
31
  from racing_reports.config import DEFAULT_SETTINGS
32
  from racing_reports.datastore import DataStore
33
  from racing_reports.match_index import MatchIndex
 
58
  if api_goalkicks_router is not None:
59
  app.include_router(api_goalkicks_router)
60
  app.include_router(api_lowblock_router)
61
+ app.include_router(api_plays_router)
62
 
63
  # Estado en memoria (single container en HF Space). Re-export para retrocompat.
64
  Job = jobs_mod.Job
src/racing_reports/web/templates/home.html CHANGED
@@ -673,6 +673,17 @@
673
  <svg id="ejsvg" width="100%" height="440" viewBox="0 0 980 440"></svg>
674
  <div id="ejtip" style="position:absolute;display:none;pointer-events:none;background:#1f2937;border:1px solid #4b5563;border-radius:8px;padding:6px 10px;font-size:12px;max-width:230px;z-index:10"></div>
675
  </div>
 
 
 
 
 
 
 
 
 
 
 
676
  </section>
677
 
678
  <!-- ---- Recursos bloque bajo ---- -->
@@ -812,6 +823,18 @@
812
  </div>
813
  <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>
814
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
815
  <p class="text-xs text-red-300" x-show="lbtError" x-text="lbtError"></p>
816
  <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 de esa liga-temporada (métricas por jugada ponderadas por intentos; conteos con media simple). En la tabla defensiva los colores se invierten: conceder más que la liga es rojo. 'Todos los recursos' = unión de los recursos de construcción (sin tiro de fuera del área): cada acción cuenta una sola vez aunque cumpla varios recursos, y los goles/tiros son de jugadas con al menos un recurso — por eso NO coincide con la suma de las filas (que doble-cuentan jugadas con recursos combinados). 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>
817
  <template x-for="lado in ['of','def']" :key="lado">
@@ -1185,6 +1208,8 @@
1185
  ejAll: [], ejAllKeys: [], ejPool: [], ejPoolLigaIn:'', ejPoolTeamIn:'', ejEscala:'liga',
1186
  ejBub: '',
1187
  mlLeagues: [], ejPartidos: {},
 
 
1188
  // Variables (scatter libre)
1189
  vsLoaded:false, vsLeagueList:[], vsSeasons:{}, vsLeague:'__all__', vsSeason:'__all__',
1190
  vsColLabel:{}, vsTypeCols:{}, vsTypeList:[], vsNeg:{},
@@ -1517,6 +1542,18 @@
1517
  await this.$nextTick(); this.lbDraw();
1518
  } catch(e){ this.lbError = 'Error: '+e; }
1519
  },
 
 
 
 
 
 
 
 
 
 
 
 
1520
  _catLeagues(name){ return this.catMap[name] || null; }, // array de ligas si es categoría, si no null
1521
  _addPoolLigas(inp, avail, pool){
1522
  const cats=this._catLeagues(inp);
 
673
  <svg id="ejsvg" width="100%" height="440" viewBox="0 0 980 440"></svg>
674
  <div id="ejtip" style="position:absolute;display:none;pointer-events:none;background:#1f2937;border:1px solid #4b5563;border-radius:8px;padding:6px 10px;font-size:12px;max-width:230px;z-index:10"></div>
675
  </div>
676
+ <div class="flex flex-wrap items-end gap-2 mt-3 pt-3 border-t border-ink-600">
677
+ <div>
678
+ <label class="block text-xs text-gray-400 mb-1">Descargar jugadas del equipo</label>
679
+ <select x-model="plEjTeam" class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm">
680
+ <template x-for="t in ((ejData&&ejData.teams)||[]).map(x=>x.equipo).sort()" :key="'plej'+t"><option :value="t" x-text="t"></option></template>
681
+ </select>
682
+ </div>
683
+ <button @click="plDownload('/api/plays/ejes?league='+encodeURIComponent(ejLeague)+'&season='+encodeURIComponent(ejSeason)+'&team='+encodeURIComponent(plEjTeam))"
684
+ class="bg-racing-500 hover:bg-racing-600 text-racing-900 font-semibold px-4 py-2 rounded-lg text-sm">↓ Descargar jugadas (CSV)</button>
685
+ <span class="text-xs text-gray-500" x-text="plMsg"></span>
686
+ </div>
687
  </section>
688
 
689
  <!-- ---- Recursos bloque bajo ---- -->
 
823
  </div>
824
  <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>
825
  </div>
826
+ <div class="flex flex-wrap items-end gap-2 mb-3">
827
+ <span class="text-xs text-gray-400 self-center">Descargar jugadas:</span>
828
+ <select x-model="plLado" class="bg-ink-800 border border-ink-600 rounded-lg px-2 py-1.5 text-sm">
829
+ <option value="of">Ofensivo</option><option value="def">Defensivo (concede)</option>
830
+ </select>
831
+ <select x-model="plRecurso" class="bg-ink-800 border border-ink-600 rounded-lg px-2 py-1.5 text-sm">
832
+ <template x-for="(lbl,k) in lbRecursos" :key="'plr'+k"><option :value="k" x-text="lbl"></option></template>
833
+ </select>
834
+ <button @click="plDownload('/api/plays/recursos?league='+encodeURIComponent(lbtLeague)+'&season='+encodeURIComponent(lbtSeason)+'&team='+encodeURIComponent(lbtEquipo)+'&lado='+plLado+'&recurso='+plRecurso+'&bloques='+encodeURIComponent(lbBloqSel.join(',')))"
835
+ class="bg-ink-600 hover:bg-ink-500 text-gray-100 font-semibold px-3 py-1.5 rounded-lg text-sm border border-ink-500">↓ CSV</button>
836
+ <span class="text-xs text-gray-500" x-text="plMsg"></span>
837
+ </div>
838
  <p class="text-xs text-red-300" x-show="lbtError" x-text="lbtError"></p>
839
  <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 de esa liga-temporada (métricas por jugada ponderadas por intentos; conteos con media simple). En la tabla defensiva los colores se invierten: conceder más que la liga es rojo. 'Todos los recursos' = unión de los recursos de construcción (sin tiro de fuera del área): cada acción cuenta una sola vez aunque cumpla varios recursos, y los goles/tiros son de jugadas con al menos un recurso — por eso NO coincide con la suma de las filas (que doble-cuentan jugadas con recursos combinados). 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>
840
  <template x-for="lado in ['of','def']" :key="lado">
 
1208
  ejAll: [], ejAllKeys: [], ejPool: [], ejPoolLigaIn:'', ejPoolTeamIn:'', ejEscala:'liga',
1209
  ejBub: '',
1210
  mlLeagues: [], ejPartidos: {},
1211
+ // Descargar jugadas (CSV)
1212
+ plEjTeam:'Racing de Santander', plLado:'of', plRecurso:'todos', plMsg:'',
1213
  // Variables (scatter libre)
1214
  vsLoaded:false, vsLeagueList:[], vsSeasons:{}, vsLeague:'__all__', vsSeason:'__all__',
1215
  vsColLabel:{}, vsTypeCols:{}, vsTypeList:[], vsNeg:{},
 
1542
  await this.$nextTick(); this.lbDraw();
1543
  } catch(e){ this.lbError = 'Error: '+e; }
1544
  },
1545
+ async plDownload(url){
1546
+ this.plMsg='Generando…';
1547
+ try{
1548
+ const r=await fetch(url);
1549
+ if(!r.ok){ const d=await r.json().catch(()=>({detail:r.statusText})); this.plMsg='No disponible: '+(d.detail||r.statusText); return; }
1550
+ const blob=await r.blob();
1551
+ const cd=r.headers.get('Content-Disposition')||'';
1552
+ const fn=(cd.match(/filename="?([^"]+)"?/)||[])[1]||'jugadas.csv';
1553
+ const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=fn; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
1554
+ this.plMsg='';
1555
+ }catch(e){ this.plMsg='Error: '+e; }
1556
+ },
1557
  _catLeagues(name){ return this.catMap[name] || null; }, // array de ligas si es categoría, si no null
1558
  _addPoolLigas(inp, avail, pool){
1559
  const cats=this._catLeagues(inp);