RRC / src /racing_reports /reports /common.py
pablogrois's picture
Deploy MVP: API JSON + SPA + bundle/cache de artifacts CORE
e58615a
Raw
History Blame Contribute Delete
7.41 kB
from __future__ import annotations
import base64
from pathlib import Path
import numpy as np
import pandas as pd
from racing_reports.utils import slugify
DARK_BG = "#0b1721"
CARD_BG = "#111821"
TEXT = "#f5f5f5"
MUTED = "#9ca3af"
GREEN = "#006b3f"
LIGHT_GREEN = "#00a86b"
AMBER = "#d69a2d"
RED = "#d94b4b"
BLUE = "#5B8DB8"
SHOT_EVENTS = {"Goal", "MissedShots", "SavedShot", "ShotOnPost", "ChanceMissed"}
RECOVERY_EVENTS = {"BallRecovery", "Interception", "Tackle", "BlockedPass", "Aerial"}
ZONE_RECTS = {
"ext izq 3/4": (68, 83, 79, 100),
"int izq 3/4": (68, 83, 63, 79),
"centro 3/4": (68, 83, 37, 63),
"int der 3/4": (68, 83, 21, 37),
"ext der 3/4": (68, 83, 0, 21),
"ext izq area": (83, 100, 79, 100),
"int izq area": (83, 100, 63, 79),
"centro area": (83, 100, 37, 63),
"int der area": (83, 100, 21, 37),
"ext der area": (83, 100, 0, 21),
}
ZONE_ORDER = list(ZONE_RECTS)
def output_match_dir(output_root: Path, league: str, season: str, home: str, away: str, match_id: str) -> Path:
return output_root / slugify(league) / str(season) / f"{slugify(home)}_vs_{slugify(away)}_{match_id}"
def read_preprocessed(path: Path) -> pd.DataFrame:
return pd.read_csv(path, low_memory=False, dtype={"matchId": str, "teamId": str})
def match_events(df: pd.DataFrame, match_id: str) -> pd.DataFrame:
out = df[df["matchId"].astype(str) == str(match_id)].copy()
if out.empty:
raise ValueError(f"No hay eventos para matchId={match_id}")
return out
def html_page(title: str, subtitle: str, body: str) -> str:
return f"""<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{title}</title>
<style>
:root {{
--bg: {DARK_BG};
--card: {CARD_BG};
--text: {TEXT};
--muted: {MUTED};
--green: {GREEN};
--light-green: {LIGHT_GREEN};
--border: rgba(255,255,255,.12);
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
background:
radial-gradient(circle at top left, rgba(0,107,63,.30), transparent 34rem),
linear-gradient(180deg, #0b1721 0%, #0d141c 100%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}}
.container {{ max-width: 1180px; margin: 0 auto; padding: 30px 22px 46px; }}
header {{
border: 1px solid var(--border);
border-radius: 20px;
padding: 26px 28px;
background: linear-gradient(120deg, rgba(0,107,63,.92), rgba(13,20,28,.92));
margin-bottom: 24px;
}}
.badge {{ display:inline-block; padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,.13); color: var(--text); font-weight: 700; font-size: 13px; margin-bottom: 12px; }}
h1 {{ margin: 0 0 8px; font-size: 34px; letter-spacing: -.03em; }}
h2 {{ margin: 0; color: rgba(245,245,245,.78); font-size: 16px; font-weight: 500; line-height: 1.45; }}
h3 {{ font-size: 20px; margin: 0 0 10px; }}
.grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }}
.card {{ background: rgba(17,24,33,.92); border: 1px solid var(--border); border-radius: 18px; padding: 18px; box-shadow: 0 18px 60px rgba(0,0,0,.18); }}
.wide {{ grid-column: 1 / -1; }}
.muted {{ color: var(--muted); line-height: 1.55; }}
.kpis {{ display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }}
.kpi {{ padding: 14px; background: rgba(255,255,255,.04); border-radius: 14px; border: 1px solid rgba(255,255,255,.08); }}
.kpi span {{ color: var(--muted); display:block; font-size: 12px; margin-bottom: 6px; }}
.kpi strong {{ font-size: 24px; }}
img {{ max-width: 100%; border-radius: 12px; display: block; }}
table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
th, td {{ padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,.08); text-align: left; }}
th {{ color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .05em; }}
@media (max-width: 860px) {{ .grid, .kpis {{ grid-template-columns: 1fr; }} h1 {{ font-size: 28px; }} }}
</style>
</head>
<body>
<div class="container">
<header>
<div class="badge">Racing Reports</div>
<h1>{title}</h1>
<h2>{subtitle}</h2>
</header>
{body}
</div>
</body>
</html>"""
def add_attack_zone(df: pd.DataFrame) -> pd.DataFrame:
out = df.copy()
out["attack_zone"] = pd.Series(index=out.index, dtype="object")
x = pd.to_numeric(out.get("x"), errors="coerce")
y = pd.to_numeric(out.get("y"), errors="coerce")
for zone, (x0, x1, y0, y1) in ZONE_RECTS.items():
mask = x.ge(x0) & x.lt(x1) & y.ge(y0) & y.lt(y1)
out.loc[mask, "attack_zone"] = zone
return out
def team_kpis(events: pd.DataFrame, team: str) -> dict[str, float]:
t = events[events["TeamName"].astype(str) == team].copy()
if t.empty:
return {"events": 0, "shots": 0, "xg": 0.0, "pv": 0.0, "goals": 0.0}
xg_source = t["xG"] if "xG" in t.columns else pd.Series(0, index=t.index)
pv_source = t["pvAdded"] if "pvAdded" in t.columns else pd.Series(0, index=t.index)
goal_source = t["goal_int"] if "goal_int" in t.columns else pd.Series(0, index=t.index)
return {
"events": float(len(t)),
"shots": float(t["event_name"].isin(SHOT_EVENTS).sum()) if "event_name" in t.columns else 0.0,
"xg": float(pd.to_numeric(xg_source, errors="coerce").fillna(0).sum()),
"pv": float(pd.to_numeric(pv_source, errors="coerce").fillna(0).sum()),
"goals": float(pd.to_numeric(goal_source, errors="coerce").fillna(0).sum()),
}
def season_team_events(df: pd.DataFrame, team: str, before_date: str | None = None) -> pd.DataFrame:
out = df[df["TeamName"].astype(str) == team].copy()
if before_date and "fecha" in out.columns:
dates = parse_event_dates(out["fecha"])
out = out[dates < pd.to_datetime(before_date, errors="coerce", utc=True)]
return out
def parse_event_dates(series: pd.Series) -> pd.Series:
cleaned = series.astype(str).str.replace("Z", "", regex=False).str[:10]
return pd.to_datetime(cleaned, errors="coerce", utc=True, format="%Y-%m-%d")
def save_figure(fig, out_dir: Path, name: str, *, svg: bool = True, dpi: int = 160) -> tuple[Path, Path | None]:
"""Persiste una figura matplotlib como PNG (y opcionalmente SVG).
Devuelve los paths absolutos. NO cierra la figura: el caller decide
(algunos flujos también la serializan a base64 después).
"""
figs_dir = out_dir / "figures"
figs_dir.mkdir(parents=True, exist_ok=True)
png_path = figs_dir / f"{name}.png"
fig.savefig(png_path, dpi=dpi, bbox_inches="tight", facecolor=fig.get_facecolor())
svg_path: Path | None = None
if svg:
svg_path = figs_dir / f"{name}.svg"
fig.savefig(svg_path, format="svg", bbox_inches="tight", facecolor=fig.get_facecolor())
return png_path, svg_path
def save_table(df: pd.DataFrame, out_dir: Path, name: str) -> Path:
tables_dir = out_dir / "tables"
tables_dir.mkdir(parents=True, exist_ok=True)
csv_path = tables_dir / f"{name}.csv"
df.to_csv(csv_path, index=False)
return csv_path
def png_to_data_uri(path: Path) -> str:
return "data:image/png;base64," + base64.b64encode(path.read_bytes()).decode("ascii")