Spaces:
Running
Running
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);
|