| import json |
| from datetime import date, datetime, timedelta |
| from zoneinfo import ZoneInfo |
| from pathlib import Path |
| import gradio as gr |
| import pandas as pd |
| import numpy as np |
|
|
| |
| HAVE_PYBASEBALL = True |
| try: |
| from pybaseball import statcast_pitcher |
| except Exception: |
| HAVE_PYBASEBALL = False |
| statcast_pitcher = None |
|
|
| |
| try: |
| import requests |
| def http_get_json(url, params=None, timeout=10): |
| r = requests.get(url, params=params, timeout=timeout, headers={"User-Agent": "zone-live-mlb/1.0"}) |
| r.raise_for_status(); return r.json() |
| except Exception: |
| from urllib.parse import urlencode |
| from urllib.request import urlopen, Request |
| def http_get_json(url, params=None, timeout=10): |
| if params: url = f"{url}?{urlencode(params)}" |
| req = Request(url, headers={"User-Agent": "zone-live-mlb/1.0"}) |
| with urlopen(req, timeout=timeout) as resp: return json.load(resp) |
|
|
| API_SCHEDULE = "https://statsapi.mlb.com/api/v1/schedule" |
| API_BOXSCORE = "https://statsapi.mlb.com/api/v1/game/{gamePk}/boxscore" |
| API_LIVEFEED = "https://statsapi.mlb.com/api/v1.1/game/{gamePk}/feed/live" |
| API_PBP = "https://statsapi.mlb.com/api/v1/game/{gamePk}/playByPlay" |
|
|
| IN_PROGRESS_STATES = {"In Progress","Mid Inning","End Inning","Warmup","Game Delayed","Delayed Start","Manager Challenge","Manager Review","Pitching Change"} |
| EXCLUDE_STATES = {"Final","Game Over","Completed Early","Postponed","Cancelled","Suspended"} |
|
|
| |
|
|
| def _team_label(team_side: dict) -> str: |
| t = (team_side or {}).get("team") or {} |
| return t.get("name") or t.get("teamName") or t.get("clubName") or t.get("shortName") or t.get("abbreviation") or "UNKNOWN" |
|
|
| def _candidate_dates(): |
| eastern_today = datetime.now(ZoneInfo("America/New_York")).date(); utc_today = date.today() |
| return sorted({d.isoformat() for d in {eastern_today, utc_today, eastern_today - timedelta(days=1), utc_today - timedelta(days=1)}}) |
|
|
| _DEF_FEET_CAP = 3.0 |
|
|
| def _to_inches(val): |
| if val is None: return None |
| try: v = float(val) |
| except Exception: return None |
| return v * 12.0 if abs(v) <= _DEF_FEET_CAP else v |
|
|
| def _get_live_boxscore(game_pk: int): |
| feed = http_get_json(API_LIVEFEED.format(gamePk=int(game_pk)), {"language":"en"}) |
| live = (feed.get("liveData") or {}); box = (live.get("boxscore") or {}); plays = (live.get("plays") or {}).get("allPlays", []) or [] |
| return box, plays |
|
|
| def _get_pbp(game_pk: int): |
| try: |
| data = http_get_json(API_PBP.format(gamePk=int(game_pk)), {"language": "en"}); return (data or {}).get("allPlays", []) or [] |
| except Exception: return [] |
|
|
| |
|
|
| def get_games(inprogress_only: bool, include_finals_today: bool): |
| games, eastern_today_iso = {}, datetime.now(ZoneInfo("America/New_York")).date().isoformat() |
| for d in _candidate_dates(): |
| try: data = http_get_json(API_SCHEDULE, {"sportId": 1, "date": d}) |
| except Exception: continue |
| for day in data.get("dates", []): |
| day_date = day.get("date") |
| for g in (day.get("games") or []): |
| status = (g.get("status") or {}).get("detailedState") or "" |
| if inprogress_only and status not in IN_PROGRESS_STATES: continue |
| if not inprogress_only: |
| is_final_like = status in EXCLUDE_STATES |
| if is_final_like and not (include_finals_today and day_date == eastern_today_iso and status in {"Final","Game Over","Completed Early"}): |
| continue |
| game_pk = g.get("gamePk") |
| if not game_pk: continue |
| home_team = _team_label((g.get("teams") or {}).get("home")) |
| away_team = _team_label((g.get("teams") or {}).get("away")) |
| label = f"{away_team} @ {home_team} | {status} | game_pk={game_pk}" |
| games[int(game_pk)] = {"game_pk": int(game_pk), "label": label} |
| return list(games.values()) |
|
|
| |
|
|
| def _swing_whiff_flags_from_text(desc: str, is_in_play_flag: bool, type_code: str): |
| d = (desc or "").lower(); is_in_play = bool(is_in_play_flag) or (type_code == "X") |
| is_foul = "foul" in d; is_foul_tip = "foul tip" in d; is_swinging_strike = "swinging strike" in d; is_missed_bunt = "missed bunt" in d |
| swing = is_in_play or is_foul or is_foul_tip or is_swinging_strike or is_missed_bunt; whiff = is_swinging_strike or is_missed_bunt |
| return swing, whiff, is_in_play |
|
|
| |
| PITCH_NAME_MAP = {"FF":"Four-seam Fastball","FA":"Fastball","FT":"Two-seam Fastball","SI":"Sinker","FC":"Cutter","SL":"Slider","CH":"Changeup","CU":"Curveball","KC":"Knuckle Curve","KN":"Knuckleball","FS":"Splitter","ST":"Sweeper","SC":"Screwball","EP":"Eephus","FO":"Forkball","GY":"Gyroball"} |
| CODE_TO_NAME = {"FF":"Four-seam Fastball","FA":"Fastball","FT":"Two-seam Fastball","SI":"Sinker","FC":"Cutter","SL":"Slider","ST":"Sweeper","CH":"Changeup","CU":"Curveball","KC":"Knuckle Curve","FS":"Splitter"} |
| NAME_TO_CODE = {"four-seam fastball":"FF","four seam fastball":"FF","4-seam fastball":"FF","fastball":"FA","two-seam fastball":"FT","2-seam fastball":"FT","two seam fastball":"FT","sinker":"SI","cutter":"FC","slider":"SL","sweeper":"ST","changeup":"CH","change-up":"CH","curveball":"CU","knuckle curve":"KC","knuckle-curve":"KC","splitter":"FS"} |
|
|
| def _canon_pitch_code(x: str) -> str: |
| if x is None: return "UNK" |
| s = str(x).strip(); u = s.upper() |
| return u if u in CODE_TO_NAME else NAME_TO_CODE.get(s.lower(), s) |
|
|
| def _friendly_pitch(code: str) -> str: return CODE_TO_NAME.get(str(code).upper(), str(code)) |
|
|
| def _pitch_label(code_or_name: str) -> str: |
| if not code_or_name: return "UNK" |
| s = str(code_or_name).strip(); key = s.upper(); return PITCH_NAME_MAP.get(key, s if len(s) > 3 else s.title()) |
|
|
| |
|
|
| def _style_pos_neg(val, invert=False): |
| try: |
| if pd.isna(val): return "" |
| v = float(val) |
| except Exception: return "" |
| pos_color, neg_color = ("red","green") if invert else ("green","red") |
| color = pos_color if v>0 else (neg_color if v<0 else ""); bold = "font-weight:600;" if color else ""; color_css = f"color:{color};" if color else "" |
| return f'<span style="{bold}{color_css}">{v:.2f}</span>' |
|
|
| def _style_compare_live_vs_season(live, season): |
| if pd.isna(live): return "" |
| try: l = float(live) |
| except Exception: return str(live) |
| s = None if pd.isna(season) else float(season) |
| if s is None: return f"{l:.2f}" |
| if l > s: return f'<span style="font-weight:600;color:green;">{l:.2f}</span>' |
| if l < s: return f'<span style="font-weight:600;color:red;">{l:.2f}</span>' |
| return f"{l:.2f}" |
|
|
| def _table_html(df, highlight_specs=None, caption=None, compare_cols=None): |
| df = df.copy(); highlight_specs = highlight_specs or [] |
| headers = ''.join(f'<th style="text-align:right;padding:6px 10px;white-space:nowrap;">{c}</th>' if c!="Pitch type" else f'<th style="text-align:left;padding:6px 10px;white-space:nowrap;">{c}</th>' for c in df.columns) |
| rows_html = [] |
| for _, row in df.iterrows(): |
| cells = [] |
| for col in df.columns: |
| val = row[col] |
| if col == "Pitch type": cells.append(f'<td style="text-align:left;padding:4px 10px;">{val}</td>'); continue |
| if compare_cols and col == compare_cols[0]: |
| html = _style_compare_live_vs_season(val, row.get(compare_cols[1])); cells.append(f'<td style="text-align:right;padding:4px 10px;">{html}</td>'); continue |
| spec = next((inv for c, inv in highlight_specs if c == col), None) |
| is_num = False |
| try: |
| if pd.isna(val): is_num = True |
| else: _ = float(val); is_num = True |
| except Exception: is_num = False |
| if spec is not None: |
| html = _style_pos_neg(val, invert=spec); cells.append(f'<td style="text-align:right;padding:4px 10px;">{html}</td>') |
| else: |
| txt = "" if pd.isna(val) else (f"{float(val):.2f}" if is_num else str(val)); cells.append(f'<td style="text-align:right;padding:4px 10px;">{txt}</td>') |
| rows_html.append(f"<tr>{''.join(cells)}</tr>") |
| caption_html = f'<caption style="caption-side:top;text-align:left;margin-bottom:8px;font-weight:600;">{caption}</caption>' if caption else '' |
| return f'''<table style="border-collapse:collapse;border:1px solid #ddd;border-radius:8px;overflow:hidden;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu;">{caption_html}<thead style="background:#f6f8fa;border-bottom:1px solid #ddd;"><tr>{headers}</tr></thead><tbody>{''.join(rows_html)}</tbody></table>''' |
|
|
| |
|
|
| def _extract_pitch_type(ev): |
| det = ev.get("details") or {}; code = (det.get("type") or {}).get("code"); name = det.get("type") or {} |
| return code or name or (det.get("description") or "") |
|
|
| def _extract_spin_break(ev): |
| pdict = ev.get("pitchData") or {}; spin = pdict.get("breaks", {}).get("spinRate") or pdict.get("spinRate") |
| coords = pdict.get("coordinates", {}) |
| hmov = _to_inches(coords.get("pfxX")); vmov = _to_inches(coords.get("pfxZ")) |
| try: hmov = float(hmov) if hmov is not None else None |
| except Exception: hmov = None |
| try: vmov = float(vmov) if vmov is not None else None |
| except Exception: vmov = None |
| return spin, hmov, vmov |
|
|
| |
|
|
| def live_per_pitch_and_totals(game_pk: int, pitcher_id: int): |
| try: feed = http_get_json(API_LIVEFEED.format(gamePk=int(game_pk)), {"language": "en"}) |
| except Exception: feed = {} |
| plays = (((feed.get("liveData") or {}).get("plays") or {}).get("allPlays")) or [] |
| if not plays: plays = _get_pbp(game_pk) |
| rows, exit_velos, bip_count, total_pitches, strike_like_count = [], [], 0, 0, 0 |
| for play in plays: |
| play_pitcher = (((play.get("matchup") or {}).get("pitcher") or {}).get("id")) |
| for ev in (play.get("playEvents") or []): |
| if not ev.get("isPitch"): continue |
| ev_pitcher = (((ev.get("matchup") or {}).get("pitcher") or {}).get("id")); pid = ev_pitcher or play_pitcher |
| if pid != int(pitcher_id): continue |
| pdict = ev.get("pitchData") or {}; spd = pdict.get("startSpeed"); |
| if spd is None: continue |
| det = ev.get("details") or {}; desc = det.get("description") or (det.get("call") or {}).get("description") or ""; type_code = (det.get("type") or {}).get("code") |
| swing, whiff, is_in_play = _swing_whiff_flags_from_text(desc, det.get("isInPlay"), type_code) |
| is_ball = (type_code == "B"); is_strike_like = (type_code == "S") or (not is_ball and (is_in_play or "called strike" in desc.lower() or "swinging strike" in desc.lower() or "foul" in desc.lower() or "foul tip" in desc.lower() or "missed bunt" in desc.lower())) |
| total_pitches += 1; strike_like_count += 1 if is_strike_like else 0 |
| ptype = _canon_pitch_code(_extract_pitch_type(ev)); spin, hmov, vmov = _extract_spin_break(ev) |
| ev_hit = ev.get("hitData") or {}; ev_ev = ev_hit.get("launchSpeed") |
| if ev_ev is not None: |
| try: exit_velos.append(float(ev_ev)) |
| except Exception: pass |
| if is_in_play: bip_count += 1 |
| rows.append({"pitch_type": ptype, "speed": float(spd), "spin": spin, "hmov": hmov, "vmov": vmov, "swing": 1 if swing else 0, "whiff": 1 if whiff else 0}) |
| if not rows: |
| return pd.DataFrame(columns=["pitch_type","avg_speed","avg_spin","avg_hmov","avg_vmov","swings","whiffs","whiff_pct","num_pitches"]), pd.DataFrame(columns=["avg_exit_velo","swings","whiffs","whiff_pct","bip","pitches","strike_pct"]) |
| df = pd.DataFrame(rows) |
| per_pitch = (df.groupby("pitch_type").agg(avg_speed=("speed","mean"), avg_spin=("spin","mean"), avg_hmov=("hmov","mean"), avg_vmov=("vmov","mean"), swings=("swing","sum"), whiffs=("whiff","sum"), num_pitches=("speed","size")).reset_index()) |
| per_pitch["pitch_type"] = per_pitch["pitch_type"].astype(str).str.upper() |
| per_pitch["whiff_pct"] = (100.0 * per_pitch["whiffs"] / per_pitch["swings"].where(per_pitch["swings"] > 0, pd.NA)).round(2) |
| for c in ["avg_speed","avg_spin","avg_hmov","avg_vmov"]: per_pitch[c] = per_pitch[c].round(2) |
| total_swings = int(df["swing"].sum()); total_whiffs = int(df["whiff"].sum()) |
| totals = pd.DataFrame([{ "avg_exit_velo": (round(sum(exit_velos) / len(exit_velos), 2) if exit_velos else None), "swings": total_swings, "whiffs": total_whiffs, "whiff_pct": (round(100.0 * total_whiffs / total_swings, 2) if total_swings > 0 else None), "bip": int(bip_count), "pitches": int(total_pitches), "strike_pct": (round(100.0 * strike_like_count / total_pitches, 2) if total_pitches > 0 else None)}]) |
| return per_pitch.sort_values(["num_pitches","pitch_type"], ascending=[False, True]).reset_index(drop=True), totals |
|
|
| |
|
|
| def season_year_now_eastern(): return datetime.now(ZoneInfo("America/New_York")).year |
|
|
| def season_per_pitch_and_totals(pitcher_id: int, season_year: int): |
| if not HAVE_PYBASEBALL: return pd.DataFrame(), pd.DataFrame() |
| try: |
| start, end = f"{season_year}-03-01", date.today().isoformat(); df = statcast_pitcher(start_dt=start, end_dt=end, player_id=pitcher_id) |
| if df is None or df.empty: return pd.DataFrame(), pd.DataFrame() |
| except Exception: return pd.DataFrame(), pd.DataFrame() |
| df = df.dropna(subset=["pitch_type","release_speed"]).copy(); df["pitch_type"] = df["pitch_type"].astype(str).map(_canon_pitch_code).str.upper() |
| def _safe_to_inches(x): |
| try: return _to_inches(float(x)) |
| except Exception: return None |
| df["pfx_x_in"] = df["pfx_x"].apply(_safe_to_inches) if "pfx_x" in df.columns else np.nan |
| df["pfx_z_in"] = df["pfx_z"].apply(_safe_to_inches) if "pfx_z" in df.columns else np.nan |
| mph_to_fps, default_extension_ft = 1.4666667, 5.5 |
| t_plate = pd.to_numeric(df.get("plate_time", pd.Series(dtype=float)), errors="coerce") |
| need_est = t_plate.isna() |
| if need_est.any(): |
| speed_fps = pd.to_numeric(df["release_speed"], errors="coerce") * mph_to_fps; speed_fps = speed_fps.replace([0, np.inf, -np.inf], np.nan) |
| ext_ft = pd.to_numeric(df.get("release_extension", default_extension_ft), errors="coerce").fillna(default_extension_ft) |
| dist_ft = 60.5 - ext_ft; t_plate = t_plate.where(~need_est, dist_ft / speed_fps) |
| G_IN_S2 = 386.089; grav_drop_in = 0.5 * G_IN_S2 * (t_plate ** 2) |
| pfx_z_in_num = pd.to_numeric(df["pfx_z_in"], errors="coerce"); df["vmov_with_grav_in"] = pfx_z_in_num - grav_drop_in |
| def _flags(row): |
| desc = str(row.get("description") or row.get("des") or "").lower(); type_code = str(row.get("type") or "") |
| is_in_play = "in play" in desc or type_code == "X"; is_foul = "foul" in desc; is_foul_tip = "foul tip" in desc |
| is_swinging_strike = "swinging_strike" in desc or "swinging strike" in desc; is_missed_bunt = "missed_bunt" in desc or "missed bunt" in desc |
| swing = is_in_play or is_foul or is_foul_tip or is_swinging_strike or is_missed_bunt; whiff = is_swinging_strike or is_missed_bunt |
| is_ball = (type_code == "B"); is_strike_like = (type_code == "S") or (not is_ball and (is_in_play or "called strike" in desc or "swinging strike" in desc or "foul" in desc or "foul tip" in desc or "missed bunt" in desc)) |
| return pd.Series({"swing": 1 if swing else 0, "whiff": 1 if whiff else 0, "strike_like": 1 if is_strike_like else 0}) |
| df = pd.concat([df, df.apply(_flags, axis=1)], axis=1) |
| per_pitch = (df.groupby("pitch_type").agg(avg_speed=("release_speed","mean"), avg_spin=("release_spin_rate","mean"), avg_hmov=("pfx_x_in","mean"), avg_vmov=("vmov_with_grav_in","mean"), swings=("swing","sum"), whiffs=("whiff","sum"), num_pitches=("release_speed","size")).reset_index()) |
| per_pitch["avg_hmov"] = -per_pitch["avg_hmov"]; per_pitch["whiff_pct"] = (100.0 * per_pitch["whiffs"] / per_pitch["swings"].where(per_pitch["swings"] > 0, pd.NA)).round(2) |
| for c in ["avg_speed","avg_spin","avg_hmov","avg_vmov"]: per_pitch[c] = per_pitch[c].round(2) |
| per_pitch = per_pitch.sort_values(["num_pitches","pitch_type"], ascending=[False, True]).reset_index(drop=True) |
| total_swings, total_whiffs = int(df["swing"].sum()), int(df["whiff"].sum()) |
| avg_exit_velo = round(pd.to_numeric(df.get("launch_speed", pd.Series(dtype=float)), errors="coerce").dropna().astype(float).mean(), 2) if "launch_speed" in df.columns else None |
| total_pitches = int(len(df)); strike_like = int(df["strike_like"].sum()) if "strike_like" in df.columns else 0 |
| totals = pd.DataFrame([{ "avg_exit_velo": avg_exit_velo, "swings": total_swings, "whiffs": total_whiffs, "whiff_pct": (round(100.0 * total_whiffs / total_swings, 2) if total_swings>0 else None), "pitches": total_pitches, "strike_pct": (round(100.0 * strike_like / total_pitches, 2) if total_pitches>0 else None)}]) |
| return per_pitch, totals |
|
|
| |
|
|
| def compare_live_vs_season(live_per, live_totals, season_per, season_totals): |
| if season_per is None or season_per.empty: |
| m = live_per.copy().rename(columns={"avg_speed":"avg_speed_live","avg_spin":"avg_spin_live","avg_hmov":"avg_hmov_live","avg_vmov":"avg_vmov_live","num_pitches":"num_pitches_live"}) |
| for c in ["avg_speed_season","avg_spin_season","avg_hmov_season","avg_vmov_season","num_pitches_season"]: m[c] = pd.NA |
| else: |
| sp = season_per.rename(columns={"num_pitches":"num_pitches_season"}) |
| m = pd.merge(live_per, sp[["pitch_type","avg_speed","avg_spin","avg_hmov","avg_vmov","num_pitches_season"]], on="pitch_type", how="outer", suffixes=("_live","_season")) |
| if "num_pitches_live" not in m.columns and "num_pitches" in live_per.columns: |
| m = pd.merge(m, live_per[["pitch_type","num_pitches"]].rename(columns={"num_pitches":"num_pitches_live"}), on="pitch_type", how="left") |
| for base in ["avg_speed","avg_spin","avg_hmov","avg_vmov"]: |
| m[f"{base}_diff"] = pd.to_numeric(m.get(f"{base}_live"), errors="coerce") - pd.to_numeric(m.get(f"{base}_season"), errors="coerce") |
| table1 = m[["pitch_type","avg_speed_live","avg_speed_season","avg_spin_live","avg_spin_season","avg_hmov_live","avg_hmov_season","avg_vmov_live","avg_vmov_season"]].copy().rename(columns={"pitch_type":"Pitch type","avg_speed_live":"avg speed live","avg_speed_season":"avg speed season","avg_spin_live":"avg spin live","avg_spin_season":"avg spin season","avg_hmov_live":"avg hmov live","avg_hmov_season":"avg hmov season","avg_vmov_live":"avg vmov live","avg_vmov_season":"avg vmov season"}).sort_values(["avg speed live","Pitch type"], ascending=[False, True]).reset_index(drop=True) |
| order_codes = table1["Pitch type"].tolist(); t2 = m[["pitch_type","avg_speed_diff","avg_spin_diff","avg_hmov_diff","avg_vmov_diff","num_pitches_live","num_pitches_season"]].copy().rename(columns={"pitch_type":"Pitch type","avg_speed_diff":"avg speed diff","avg_spin_diff":"avg spin diff","avg_hmov_diff":"avg hmov diff","avg_vmov_diff":"avg vmov diff","num_pitches_live":"num pitches","num_pitches_season":"num pitches (year)"}).set_index("Pitch type") |
| in_order = [p for p in order_codes if p in t2.index]; rest = [p for p in t2.index if p not in in_order]; table2 = pd.concat([t2.loc[in_order], t2.loc[rest]]).reset_index() |
| totals_live = live_totals.iloc[0] if not live_totals.empty else pd.Series(dtype="float"); totals_season = season_totals.iloc[0] if not season_totals.empty else pd.Series(dtype="float") |
| table3 = pd.DataFrame([{ "total pitches live": totals_live.get("pitches", pd.NA), "strike% live": totals_live.get("strike_pct", pd.NA), "strike% season": totals_season.get("strike_pct", pd.NA), "balls in play live": totals_live.get("bip", pd.NA), "swings live": totals_live.get("swings", pd.NA)}]) |
| avg_ev_live, avg_ev_season = totals_live.get("avg_exit_velo", pd.NA), totals_season.get("avg_exit_velo", pd.NA) |
| wh_live, wh_season = totals_live.get("whiff_pct", pd.NA), totals_season.get("whiff_pct", pd.NA) |
| ev_diff = ((pd.to_numeric(pd.Series([avg_ev_live])) - pd.to_numeric(pd.Series([avg_ev_season]))).round(2).iloc[0] if pd.notna(avg_ev_live) and pd.notna(avg_ev_season) else pd.NA) |
| wh_diff = ((pd.to_numeric(pd.Series([wh_live])) - pd.to_numeric(pd.Series([wh_season]))).round(2).iloc[0] if pd.notna(wh_live) and pd.notna(wh_season) else pd.NA) |
| table4 = pd.DataFrame([{ "avg exit velo live": avg_ev_live, "avg exit velo season": avg_ev_season, "exit velo diff": ev_diff, "whiff percent live": wh_live, "whiff percent season": wh_season, "whiff percent diff": wh_diff }]) |
| table1["Pitch type"] = table1["Pitch type"].apply(_friendly_pitch); table2["Pitch type"] = table2["Pitch type"].apply(_friendly_pitch) |
| for df_ in [table1, table2, table3, table4]: |
| for col in df_.columns: |
| if pd.api.types.is_numeric_dtype(df_[col]): df_[col] = pd.to_numeric(df_[col], errors="coerce").round(2) |
| return table1.reset_index(drop=True), table2.reset_index(drop=True), table3.reset_index(drop=True), table4.reset_index(drop=True) |
|
|
| |
| try: |
| |
| from savantfunctions import load_metrics, list_feature_columns, compute_correlations, METRICS |
| HAVE_SAVANT = True |
| except Exception: |
| HAVE_SAVANT = False |
| load_metrics = list_feature_columns = compute_correlations = None |
| METRICS = ("whiff_pct","strike_pct","chase_pct","exit_velo","fb_velo") |
| |
| def _pick_first(paths): |
| for p in paths: |
| if Path(p).exists(): |
| return p |
| return None |
|
|
| PERFORMANCE_PATH = _pick_first([ |
| "data_f3_metrics.csv", "data/data_f3_metrics.csv", "outputs/data_f3_metrics.csv", |
| "data_f3_metrics.parquet", "data/data_f3_metrics.parquet", "outputs/data_f3_metrics.parquet", |
| ]) |
|
|
| def compute_savant_tables(pitcher_name: str, top_n: int = 25, k: int = 25, ci: float = 0.90): |
| """ |
| Build three small tables of top correlations (|r|) vs `game_woba` for |
| the feature bands: thru1, thru2, thru3. Uses savantfunctions helpers. |
| """ |
| if not HAVE_SAVANT: |
| return (pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), |
| "savantfunctions.py not importable. Ensure it's in repo root.") |
| if PERFORMANCE_PATH is None: |
| return (pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), |
| "Could not locate data_f3_metrics.* in repo root, data/, or outputs/.") |
|
|
| try: |
| df = load_metrics(PERFORMANCE_PATH) |
|
|
| def _top_corr_df(band: str) -> pd.DataFrame: |
| feats = list_feature_columns(time_bands=[band], metrics=METRICS) |
| pairs = compute_correlations(df, features=feats, target="game_woba") |
| rows = [{"metric": m, "weighted_r": (np.nan if pd.isna(r) else float(r))} |
| for m, r in pairs if not pd.isna(r)] |
| out = pd.DataFrame(rows) |
| return out.head(top_n) if not out.empty else pd.DataFrame(columns=["metric","weighted_r"]) |
|
|
| t1 = _top_corr_df("thru1") |
| t12 = _top_corr_df("thru2") |
| t13 = _top_corr_df("thru3") |
|
|
| diag = (f"Top |r| vs game_woba from data_f3_metrics for bands thru1/thru2/thru3. " |
| f"Rows: {len(df):,}. Showing up to {top_n} metrics per band.") |
| return t1, t12, t13, diag |
|
|
| except Exception as e: |
| return pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), ( |
| f"savantfunctions error: {e}\n" |
| "Check that columns like 'thru1_whiff_pct' exist in data_f3_metrics." |
| ) |
|
|
| |
|
|
| def refresh_games(inprogress_only: bool, include_finals_today: bool): |
| games = get_games(inprogress_only=inprogress_only, include_finals_today=include_finals_today) |
| if not games: |
| scope = "in-progress" if inprogress_only else ("active/pregame + finals(today)" if include_finals_today else "active/pregame") |
| return (gr.update(choices=[], value=None), gr.update(choices=[], value=None), {}, f"No {scope} MLB games found across Eastern/UTC (today/yesterday).") |
| labels = [g["label"] for g in games]; mapping = {g["label"]: g["game_pk"] for g in games} |
| return (gr.update(choices=labels, value=None), gr.update(choices=[], value=None), mapping, f"Found {len(labels)} game(s). Pick a game, then a pitcher.") |
|
|
| def on_game_change(game_label, mapping, include_not_appeared): |
| if not (game_label and mapping): return gr.update(choices=[], value=None), "No game selected." |
| game_pk = mapping[game_label] |
| pitchers = get_all_roster_pitchers(game_pk) if include_not_appeared else get_pitchers_in_game(game_pk) |
| if not pitchers: |
| return gr.update(choices=[], value=None), f"Selected game_pk={game_pk}. No pitchers found {'(roster)' if include_not_appeared else '(appeared)'}." |
| labels = [f"{name} (id={pid})" for pid, name in pitchers] |
| return gr.update(choices=labels, value=None), f"Selected game_pk={game_pk}. Choose a pitcher." |
|
|
| def on_pitcher_change(game_label, pitcher_label, mapping): |
| if not (game_label and pitcher_label and mapping): |
| return (pd.DataFrame(), "", "", "", "", pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), "Make sure a game and pitcher are selected.") |
| game_pk = mapping[game_label] |
| try: pitcher_id = int(pitcher_label.split("id=")[-1].strip(")")) |
| except Exception: return (pd.DataFrame(), "", "", "", "", pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), "Could not parse pitcher id.") |
| live_per, live_totals = live_per_pitch_and_totals(game_pk, pitcher_id) |
| season_per = season_totals = pd.DataFrame(); season_year = season_year_now_eastern() |
| if HAVE_PYBASEBALL: season_per, season_totals = season_per_pitch_and_totals(pitcher_id, season_year) |
| table1_abs, table2_diff, table3_counts, table4_totals = compare_live_vs_season(live_per, live_totals, season_per, season_totals) |
| t2_html = _table_html(table2_diff, highlight_specs=[("avg speed diff", False)], caption="(2) Per-pitch: Deltas (Live − Season) + Volume") |
| t3_html = _table_html(table3_counts, compare_cols=("strike% live", "strike% season"), caption="(3) Live Totals: Pitches, Strike% (live vs season), BIP, Swings") |
| t4_html = _table_html(table4_totals, highlight_specs=[("exit velo diff", True), ("whiff percent diff", False)], caption="(4) Totals: Exit Velo & Whiff%") |
| name = pitcher_label.split(" (id=")[0] |
| pred1, pred2, pred3, diag = compute_savant_tables(name) |
| note = (f"Live vs {season_year} season metrics for {name}. Deltas = live − season. BIP Live = balls with isInPlay=true." if HAVE_PYBASEBALL and not (season_per.empty and season_totals.empty) else f"Live metrics for {name}{'' if HAVE_PYBASEBALL else ' (install `pybaseball` to enable season averages)'}. Deltas only shown when season data is available.") |
| print("LIVE codes:", sorted(live_per.get("pitch_type", pd.Series(dtype=str)).astype(str).unique().tolist() if not live_per.empty else [])) |
| print("SEASON codes:", sorted(season_per.get("pitch_type", pd.Series(dtype=str)).astype(str).unique().tolist() if not season_per.empty else [])) |
| return table1_abs, t2_html, t3_html, t4_html, note, pred1, pred2, pred3, diag |
|
|
| |
|
|
| def _is_bad_result(t1_df, note_text): |
| if isinstance(note_text, str) and "Make sure a game and pitcher are selected." in note_text: return True |
| try: |
| if hasattr(t1_df, "empty") and t1_df.empty: return True |
| except Exception: pass |
| return False |
|
|
| def safe_on_pitcher_tick(game_label, pitcher_label, mapping, last_state): |
| last_state = last_state or {} |
| last = {k: last_state.get(k, pd.DataFrame() if k in {"t1","p1","p2","p3"} else ("" if k in {"t2","t3","t4","note","diag"} else None)) for k in ["t1","t2","t3","t4","note","p1","p2","p3","diag"]} |
| t1, h2, h3, h4, note, p1, p2, p3, diag = on_pitcher_change(game_label, pitcher_label, mapping) |
| if _is_bad_result(t1, note): |
| return last["t1"], last["t2"], last["t3"], last["t4"], last["note"], last["p1"], last["p2"], last["p3"], last["diag"], last |
| new_state = {"t1": t1, "t2": h2, "t3": h3, "t4": h4, "note": note, "p1": p1, "p2": p2, "p3": p3, "diag": diag} |
| return t1, h2, h3, h4, note, p1, p2, p3, diag, new_state |
|
|
|
|
| def _set_timer(active: bool, secs: float): |
| try: s = int(secs) |
| except Exception: s = 15 |
| s = max(5, min(300, s)); return gr.update(value=float(s), active=bool(active)) |
|
|
| |
|
|
| def get_pitchers_in_game(game_pk: int): |
| try: box, plays = _get_live_boxscore(game_pk) |
| except Exception: return [] |
| pitchers = set(); teams = (box.get("teams") or {}) |
| for side in ("home","away"): |
| team = teams.get(side) or {}; ids = team.get("pitchers") or []; players = team.get("players") or {} |
| for pid in ids: |
| info = players.get(f"ID{pid}", {}); name = ((info.get("person") or {}).get("fullName")) or None |
| if name: pitchers.add((int(pid), name)) |
| if not pitchers and plays: |
| for p in plays: |
| pid = (((p.get("matchup") or {}).get("pitcher") or {}).get("id")); |
| if not pid: continue |
| name = None |
| for side in ("home","away"): |
| players = ((teams.get(side) or {}).get("players") or {}); info = players.get(f"ID{pid}", {}); cand = ((info.get("person") or {}).get("fullName")) |
| if cand: name = cand; break |
| if name: pitchers.add((int(pid), name)) |
| return sorted(pitchers, key=lambda x: x[1]) |
|
|
| def get_all_roster_pitchers(game_pk: int): |
| try: box, _ = _get_live_boxscore(game_pk) |
| except Exception: return [] |
| pitchers = set(); teams = (box.get("teams") or {}) |
| for side in ("home","away"): |
| team = teams.get(side) or {}; players = team.get("players") or {} |
| for _, info in players.items(): |
| person = info.get("person") or {}; pos = (info.get("position") or {}).get("abbreviation") or (person.get("primaryPosition") or {}).get("abbreviation") |
| if pos == "P": pid, name = person.get("id"), person.get("fullName"); |
| if pos == "P" and pid and name: pitchers.add((int(pid), name)) |
| return sorted(pitchers, key=lambda x: x[1]) |
|
|
| |
| with gr.Blocks(title="MLB Live Pitcher Dashboard — Zone Style + Savant Targets") as app: |
| gr.Markdown("# MLB Live Pitcher Dashboard — Zone Style\nLive vs season tables with optional auto-refresh, plus savantfunctions predictive metrics tabs.") |
| with gr.Row(): |
| inprog_only = gr.Checkbox(label="Only show in-progress games (hide pregame)", value=True) |
| include_finals_today = gr.Checkbox(label="Include finished games from today", value=False) |
| include_roster = gr.Checkbox(label="Include pitchers who haven't appeared", value=False) |
| refresh_btn = gr.Button("Refresh games", variant="primary"); status = gr.Markdown(visible=True) |
| game_map_state = gr.State({}) |
| game_dd = gr.Dropdown(label="Games (today/yesterday, Eastern & UTC)", choices=[], value=None) |
| pitcher_dd = gr.Dropdown(label="Pitcher", choices=[], value=None) |
| table1_abs_df = gr.Dataframe(label="(1) Per-pitch: Live & Season Averages") |
| table2_diff_ht = gr.HTML(label="(2) Per-pitch: Deltas (Live − Season) + Volume") |
| table3_cnt_ht = gr.HTML(label="(3) Live Totals") |
| table4_tot_ht = gr.HTML(label="(4) Totals: Exit Velo & Whiff%") |
| note = gr.Markdown(visible=True) |
| with gr.Tabs(): |
| with gr.TabItem("Top Predictors — End of 1st"): sav_t1_df = gr.Dataframe(interactive=False) |
| with gr.TabItem("Top Predictors — End of 2nd"): sav_t12_df = gr.Dataframe(interactive=False) |
| with gr.TabItem("Top Predictors — End of 3rd"): sav_t13_df = gr.Dataframe(interactive=False) |
| sav_diag = gr.Markdown(visible=True) |
| last_good = gr.State({"t1": pd.DataFrame(), "t2": "", "t3": "", "t4": "", "note": "", "p1": pd.DataFrame(), "p2": pd.DataFrame(), "p3": pd.DataFrame(), "diag": ""}) |
| with gr.Row(): |
| auto_refresh = gr.Checkbox(label="Auto-refresh", value=True) |
| refresh_secs = gr.Slider(minimum=5, maximum=120, value=15, step=1, label="Interval (seconds)") |
| timer = gr.Timer(value=15.0, active=True) |
| timer.tick(fn=safe_on_pitcher_tick, inputs=[game_dd, pitcher_dd, game_map_state, last_good], outputs=[table1_abs_df, table2_diff_ht, table3_cnt_ht, table4_tot_ht, note, sav_t1_df, sav_t12_df, sav_t13_df, sav_diag, last_good]) |
| auto_refresh.change(_set_timer, [auto_refresh, refresh_secs], [timer]) |
| refresh_secs.change(_set_timer, [auto_refresh, refresh_secs], [timer]) |
| refresh_btn.click(fn=refresh_games, inputs=[inprog_only, include_finals_today], outputs=[game_dd, pitcher_dd, game_map_state, status]) |
| game_dd.change(fn=on_game_change, inputs=[game_dd, game_map_state, include_roster], outputs=[pitcher_dd, status]) |
| include_roster.change(fn=on_game_change, inputs=[game_dd, game_map_state, include_roster], outputs=[pitcher_dd, status]) |
| pitcher_dd.change(fn=on_pitcher_change, inputs=[game_dd, pitcher_dd, game_map_state], outputs=[table1_abs_df, table2_diff_ht, table3_cnt_ht, table4_tot_ht, note, sav_t1_df, sav_t12_df, sav_t13_df, sav_diag]) |
|
|
| if __name__ == "__main__": |
| app.launch() |
|
|