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
# Optional pybaseball
HAVE_PYBASEBALL = True
try:
from pybaseball import statcast_pitcher
except Exception:
HAVE_PYBASEBALL = False
statcast_pitcher = None
# HTTP helper
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"}
# ===== Helpers =====
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 []
# ===== Live data =====
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())
# swing/whiff flags
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 mapping/canonicalization
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())
# Styling & HTML tables
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'{v:.2f}'
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'{l:.2f}'
if l < s: return f'{l:.2f}'
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'
{c} | ' if c!="Pitch type" else f'{c} | ' 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'{val} | '); continue
if compare_cols and col == compare_cols[0]:
html = _style_compare_live_vs_season(val, row.get(compare_cols[1])); cells.append(f'{html} | '); 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'{html} | ')
else:
txt = "" if pd.isna(val) else (f"{float(val):.2f}" if is_num else str(val)); cells.append(f'{txt} | ')
rows_html.append(f"{''.join(cells)}
")
caption_html = f'{caption}' if caption else ''
return f'''{caption_html}{headers}
{''.join(rows_html)}
'''
# ===== Pitch data extraction =====
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
# ===== Live per-pitch + totals =====
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
# ===== Season aggregates =====
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
# ===== Compare (four tables) =====
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)
# ===== Savant predictive metrics (savantfunctions) =====
try:
# Use functions that actually exist in savantfunctions.py
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") # sorted by |r|
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") # “End of 1st”
t12 = _top_corr_df("thru2") # “End of 2nd”
t13 = _top_corr_df("thru3") # “End of 3rd”
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."
)
# ===== UI callbacks =====
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
# Persist last good results
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))
# ===== Pitcher lists (appeared vs roster) =====
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])
# ===== Gradio App =====
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()