S.T.A.R.T / app.py
JakeR3's picture
Update app.py
8dcbfa5 verified
Raw
History Blame Contribute Delete
33.4 kB
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'<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>'''
# ===== 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()