Spaces:
Running
Running
Título legible en la sección de predicción (fondo oscuro) + liga dinámica en reporte de bloques
592fb15 | """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) | |