RRC / src /racing_reports /reports /matchup_render.py
pablogrois's picture
Título legible en la sección de predicción (fondo oscuro) + liga dinámica en reporte de bloques
592fb15
Raw
History Blame Contribute Delete
10.1 kB
"""Dibujo de la predicción de cruce sobre canchas:
- Ataque por pasillo: media cancha con 3 pasillos (Izq/Centro/Der), % y xT.
- Bloque defensivo: el 75% defensivo dividido en bajo (verde, cerca del arco propio),
medio (naranja) y alto (rojo), sombreado según el % previsto.
Devuelve figuras de matplotlib (una por cancha)."""
from __future__ import annotations
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from mplsoccer import VerticalPitch
PITCH = dict(pitch_type="opta", line_color="#cdd6e6", linewidth=1.2)
GREEN, ORANGE, RED = "#2bb673", "#f5912b", "#e0392b"
LANE_C = "#2E74D6"
TEXT = "#10243f"
def _pitch(ax, half=False):
p = VerticalPitch(half=half, pad_top=2, pitch_color="none", **PITCH)
p.draw(ax=ax)
return p
def attack_fig(side: dict, team: str) -> plt.Figure:
"""Media cancha de ataque con los 3 pasillos sombreados por % y rotulados con % y xT."""
fig, ax = plt.subplots(figsize=(4.2, 5.2))
fig.patch.set_facecolor("white")
p = _pitch(ax, half=True)
pas, xt = side["pasillo"], side["xt"]
mx = max(pas.values()) or 1.0
# x = ancho (0-100). Izquierda a la izquierda del espectador.
bounds = [(0, 33.33, "Der"), (33.33, 66.67, "Centro"), (66.67, 100, "Izq")]
for x0, x1, lane in bounds:
frac = pas[lane]
ax.fill_betweenx([50, 100], x0, x1, color=LANE_C, alpha=0.12 + 0.55 * frac / mx, zorder=0.5)
ax.plot([x0, x0], [50, 100], color="#9fb0c9", lw=1, ls="--", zorder=1)
ax.text((x0 + x1) / 2, 88, f"{frac*100:.0f}%", ha="center", va="center",
fontsize=17, fontweight="bold", color=TEXT, zorder=3)
ax.text((x0 + x1) / 2, 79, lane, ha="center", va="center", fontsize=10.5, color="#42546e", zorder=3)
ax.text((x0 + x1) / 2, 73, f"xT {xt[lane]:.2f}", ha="center", va="center",
fontsize=9, color="#5a6b85", zorder=3)
ax.set_title(f"{team}\nPor dónde ataca", fontsize=12.5, fontweight="bold", color=TEXT, pad=6)
fig.tight_layout()
return fig
def defense_fig(side: dict, team: str) -> plt.Figure:
"""Cancha con el 75% defensivo en 3 bandas: bajo (verde) cerca del arco propio,
medio (naranja) y alto (rojo); sombreado por % previsto."""
fig, ax = plt.subplots(figsize=(4.2, 5.2))
fig.patch.set_facecolor("white")
_pitch(ax, half=False)
blo = side["bloque"]
mx = max(blo.values()) or 1.0
# arco propio abajo (y=0). Bandas a lo largo: bajo [0-25], medio [25-50], alto [50-75].
bands = [(0, 25, "bajo", GREEN), (25, 50, "medio", ORANGE), (50, 75, "alto", RED)]
for y0, y1, name, col in bands:
frac = blo[name]
ax.fill_between([0, 100], y0, y1, color=col, alpha=0.18 + 0.6 * frac / mx, zorder=0.5)
ax.plot([0, 100], [y1, y1], color="#9fb0c9", lw=0.8, ls="--", zorder=1)
ax.text(50, (y0 + y1) / 2 + 3.5, f"{frac*100:.0f}%", ha="center", va="center",
fontsize=16, fontweight="bold", color=TEXT, zorder=3)
ax.text(50, (y0 + y1) / 2 - 4, f"Bloque {name}", ha="center", va="center",
fontsize=10.5, color="#33455f", zorder=3)
ax.annotate("arco propio", (50, 1.5), ha="center", va="bottom", fontsize=8.5,
color="#7a889e", zorder=3)
ax.set_title(f"{team}\nCómo defiende", fontsize=12.5, fontweight="bold", color=TEXT, pad=6)
fig.tight_layout()
return fig
def section_html(pred: dict, png_paths: dict) -> str:
"""Bloque HTML autocontenido (imágenes base64 + insight + tabla) para inyectar en
el HTML completo del reporte. ``png_paths`` = {'ah','dh','aa','da': Path}."""
import base64
from pathlib import Path
def img(key):
p = Path(png_paths[key])
b = base64.b64encode(p.read_bytes()).decode()
return f'<img src="data:image/png;base64,{b}" style="width:100%;display:block;border-radius:8px"/>'
ta, tb = pred["team_a"], pred["team_b"]
rows = ""
for side, team in ((pred["a"], ta), (pred["b"], tb)):
b, pa, xt = side["bloque"], side["pasillo"], side["xt"]
rows += ("<tr><td style='text-align:left;font-weight:600'>" + team + "</td>"
+ f"<td>{b['alto']*100:.0f}%</td><td>{b['medio']*100:.0f}%</td><td>{b['bajo']*100:.0f}%</td>"
+ f"<td>{pa['Izq']*100:.0f}%</td><td>{pa['Centro']*100:.0f}%</td><td>{pa['Der']*100:.0f}%</td>"
+ f"<td>{xt['Izq']:.2f}</td><td>{xt['Centro']:.2f}</td><td>{xt['Der']:.2f}</td></tr>")
th = ("<tr><th style='text-align:left'>Equipo</th><th>Bloq. alto</th><th>Bloq. medio</th>"
"<th>Bloq. bajo</th><th>Atq. Izq</th><th>Atq. Centro</th><th>Atq. Der</th>"
"<th>xT Izq</th><th>xT Centro</th><th>xT Der</th></tr>")
return f"""
<section style="margin:38px auto;max-width:1200px;font-family:system-ui,Segoe UI,Roboto,sans-serif">
<h2 style="font-size:1.5rem;color:#eef4ff;border-bottom:3px solid #2E74D6;padding-bottom:8px;margin-bottom:4px">
Predicción del modelo — cruce {ta} vs {tb}</h2>
<p style="color:#9fb0c9;margin:8px 0 18px">Bloque defensivo, pasillo de ataque y xT esperados por equipo (forma a la fecha).</p>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px">
<div style="background:#fff;border:1px solid #e4e9f2;border-radius:12px;padding:8px">{img('ah')}</div>
<div style="background:#fff;border:1px solid #e4e9f2;border-radius:12px;padding:8px">{img('dh')}</div>
<div style="background:#fff;border:1px solid #e4e9f2;border-radius:12px;padding:8px">{img('aa')}</div>
<div style="background:#fff;border:1px solid #e4e9f2;border-radius:12px;padding:8px">{img('da')}</div>
</div>
<div style="background:#eef4ff;border-left:4px solid #2E74D6;border-radius:10px;padding:14px 16px;margin-bottom:16px;color:#1b3050;line-height:1.55">
<b>Lectura del cruce.</b> {insight(pred)}</div>
<table style="border-collapse:collapse;width:100%;font-size:.9rem">
<thead style="background:#1c3050;color:#fff">{th}</thead>
<tbody style="text-align:center">{rows}</tbody>
</table>
</section>"""
def comparison_html(pred: dict, actual: dict, home: str, away: str) -> str:
"""Sección HTML 'Predicción del modelo vs. lo que pasó' para el post-partido.
``pred`` = salida de predict_matchup; ``actual`` = salida de actual_from_match
(keyed por TeamName)."""
def team_block(side_pred, team):
act = actual.get(team)
if act is None:
return f"<p style='color:#8a98ac'>Sin datos reales para {team}.</p>"
def rows(kind, keys, labels, pct=True, dec=0):
r = ""
for k, lab in zip(keys, labels):
p = side_pred[kind][k]; a = act[kind][k]
if pct:
pv, av = f"{p*100:.0f}%", f"{a*100:.0f}%"
d = (a - p) * 100; dv = f"{d:+.0f} pp"
else:
pv, av = f"{p:.2f}", f"{a:.2f}"
d = a - p; dv = f"{d:+.2f}"
col = "#2bb673" if abs(d) < (8 if pct else 0.08) else "#e0a83b"
r += (f"<tr><td style='text-align:left'>{lab}</td><td>{pv}</td><td>{av}</td>"
f"<td style='color:{col}'>{dv}</td></tr>")
return r
body = (rows("bloque", ["alto", "medio", "bajo"], ["Bloque alto", "Bloque medio", "Bloque bajo"])
+ rows("pasillo", ["Izq", "Centro", "Der"], ["Ataque Izq", "Ataque Centro", "Ataque Der"])
+ rows("xt", ["Izq", "Centro", "Der"], ["xT Izq", "xT Centro", "xT Der"], pct=False))
return f"""<div style="flex:1;min-width:300px">
<h3 style="color:#e7eef9;font-size:1.05rem;margin:0 0 8px">{team}</h3>
<table style="border-collapse:collapse;width:100%;font-size:.86rem">
<thead style="background:#1c3050;color:#fff"><tr>
<th style="text-align:left;padding:6px 10px">Métrica</th>
<th style="padding:6px 10px">Predicho</th><th style="padding:6px 10px">Real</th>
<th style="padding:6px 10px">Δ</th></tr></thead>
<tbody style="text-align:center">{body}</tbody></table></div>"""
a_html = team_block(pred["a"], home)
b_html = team_block(pred["b"], away)
return f"""
<section style="margin:38px auto;max-width:1200px;font-family:system-ui,Segoe UI,Roboto,sans-serif">
<h2 style="font-size:1.5rem;color:#ffe7e4;border-bottom:3px solid #e0392b;padding-bottom:8px;margin-bottom:4px">
Modelo vs. realidad</h2>
<p style="color:#9fb0c9;margin:8px 0 16px">Lo que el modelo esperaba del cruce contra lo que efectivamente pasó.
Δ en verde = acierto cercano; en ámbar = diferencia mayor. (Predicción con la forma a la fecha.)</p>
<div style="display:flex;gap:22px;flex-wrap:wrap">{a_html}{b_html}</div>
</section>"""
def insight(pred: dict) -> str:
"""Lectura breve del cruce a partir de las predicciones de A y B."""
a, b = pred["a"], pred["b"]
ta, tb = pred["team_a"], pred["team_b"]
lane_es = {"Izq": "izquierda", "Centro": "el centro", "Der": "derecha"}
blo_max = lambda s: max(s["bloque"], key=s["bloque"].get)
xt_max = lambda s: max(s["xt"], key=s["xt"].get)
out = []
# quién genera más peligro
xa, xb = sum(a["xt"].values()), sum(b["xt"].values())
mas, men = (ta, tb) if xa >= xb else (tb, ta)
out.append(f"El modelo espera más peligro total de <b>{mas}</b> ({max(xa,xb):.2f} vs {min(xa,xb):.2f} de xT).")
# pasillo principal de cada uno
out.append(f"<b>{ta}</b> apuntaría sobre todo por {lane_es[xt_max(a)]} "
f"(xT {a['xt'][xt_max(a)]:.2f}); <b>{tb}</b>, por {lane_es[xt_max(b)]} "
f"(xT {b['xt'][xt_max(b)]:.2f}).")
# bloque defensivo de cada uno
out.append(f"Defensivamente, <b>{ta}</b> se pararía mayormente en bloque {blo_max(a)} "
f"y <b>{tb}</b> en bloque {blo_max(b)}.")
# coincidencia de pasillo (ambos cargan el mismo lado)
if xt_max(a) == xt_max(b) and xt_max(a) != "Centro":
out.append(f"Ojo: ambos cargan el mismo sector ({lane_es[xt_max(a)]}), va a ser una zona disputada.")
return " ".join(out)