nesticot's picture
Update app.py
0c77ee6 verified
import polars as pl
import numpy as np
import pandas as pd
import api_scraper
scrape = api_scraper.MLB_Scrape()
from functions import df_update
from functions import pitch_summary_functions
update = df_update.df_update()
import requests
import joblib
from matplotlib.gridspec import GridSpec
from shiny import App, reactive, ui, render
from shiny.ui import h2, tags
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
from functions.pitch_summary_functions import *
from functions.df_update import *
from shiny import App, reactive, ui, render
from shiny.ui import h2, tags
from functions.heat_map_functions import *
colour_palette = ['#FFB000','#648FFF','#785EF0',
'#DC267F','#FE6100','#3D1EB2','#894D80','#16AA02','#B5592B','#A3C1ED']
year_list = [2017,2018,2019,2020,2021,2022,2023,2024,2025]
level_dict = {'1':'MLB',
'11':'AAA',
'12':'AA',
'13':'A+',
'14':'A',
'17':'AFL',
'22':'College',
'21':'Prospects',
'51':'International' }
function_dict={
'velocity_kdes':'Velocity Distributions',
'break_plot':'Pitch Movement',
'tj_stuff_roling':'Rolling tjStuff+ by Pitch',
'tj_stuff_roling_game':'Rolling tjStuff+ by Game',
'location_plot_lhb':'Locations vs LHB',
'location_plot_rhb':'Locations vs RHB',
}
split_dict = {'all':'All',
'left':'LHH',
'right':'RHH'}
split_dict_hand = {'all':['L','R'],
'left':['L'],
'right':['R']}
type_dict = {'R':'Regular Season',
'S':'Spring',
'P':'Playoffs' }
format_dict = {
'pitch_percent': '{:.1%}',
'pitches': '{:.0f}',
'heart_zone_percent': '{:.1%}',
'shadow_zone_percent': '{:.1%}',
'chase_zone_percent': '{:.1%}',
'waste_zone_percent': '{:.1%}',
'csw_percent': '{:.1%}',
'whiff_rate': '{:.1%}',
'zone_whiff_percent': '{:.1%}',
'chase_percent': '{:.1%}',
'bip': '{:.0f}',
'xwoba_percent_contact': '{:.3f}'
}
format_dict = {
'pitch_percent': '{:.1%}',
'pitches': '{:.0f}',
'heart_zone_percent': '{:.1%}',
'shadow_zone_percent': '{:.1%}',
'chase_zone_percent': '{:.1%}',
'waste_zone_percent': '{:.1%}',
'csw_percent': '{:.1%}',
'whiff_rate': '{:.1%}',
'zone_whiff_percent': '{:.1%}',
'chase_percent': '{:.1%}',
'bip': '{:.0f}',
'xwoba_percent_contact': '{:.3f}'
}
label_translation_dict = {
'pitch_percent': 'Pitch%',
'pitches': 'Pitches',
'heart_zone_percent': 'Heart%',
'shadow_zone_percent': 'Shado%',
'chase_zone_percent': 'Chas%',
'waste_zone_percent': 'Waste%',
'csw_percent': 'CSW%',
'whiff_rate': 'Whiff%',
'zone_whiff_percent': 'Z-Whiff%',
'chase_percent': 'O-Swing%',
'bip': 'BBE',
'xwoba_percent_contact': 'xwOBACON'
}
cmap_sum22 = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#648FFF','#FFB000',])
cmap_sum = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#648FFF','#FFFFFF','#FFB000',])
cmap_sum2 = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#FFFFFF','#FFB000','#FE6100'])
cmap_sum_r = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#FFB000','#FFFFFF','#648FFF',])
import requests
import os
CAMPAIGN_ID = os.getenv("CAMPAIGN_ID")
ACCESS_TOKEN = os.getenv("ACCESS_TOKEN")
BACKUP_PW = os.getenv("BACKUP_PW")
ADMIN_PW = os.getenv("ADMIN_PW")
url = f"https://www.patreon.com/api/oauth2/v2/campaigns/{CAMPAIGN_ID}/members"
headers = {
"Authorization": f"Bearer {ACCESS_TOKEN}"
}
# Simple parameters, requesting the member's email and currently entitled tiers
params = {
"fields[member]": "full_name,email", # Request the member's email
"include": "currently_entitled_tiers", # Include the currently entitled tiers
"page[size]": 10000 # Fetch up to 1000 patrons per request
}
response = requests.get(url, headers=headers, params=params)
VALID_PASSWORDS = []
if response.status_code == 200:
data = response.json()
for patron in data['data']:
try:
tiers = patron['relationships']['currently_entitled_tiers']['data']
if any(tier['id'] == '9078921' for tier in tiers):
full_name = patron['attributes']['email']
VALID_PASSWORDS.append(full_name)
except KeyError:
continue
VALID_PASSWORDS.append(BACKUP_PW)
VALID_PASSWORDS.append(ADMIN_PW)
# VALID_PASSWORDS.append('')
from shiny import App, reactive, ui, render
from shiny.ui import h2, tags
# Define the login UI
login_ui = ui.page_fluid(
ui.card(
ui.h2([
"TJStats Pitching Heat Maps App ",
ui.tags.a("(@TJStats)", href="https://twitter.com/TJStats", target="_blank")
]),
ui.p(
"This App is available to Superstar Patrons. Please enter your Patreon email address in the box below. If you're having trouble, please refer to the ",
ui.tags.a("Patreon post", href="https://www.patreon.com/posts/117909954", target="_blank"),
"."
),
ui.input_password("password", "Enter Patreon Email (or Password from Link):", width="25%"),
ui.tags.input(
type="checkbox",
id="authenticated",
value=False,
disabled=True
),
ui.input_action_button("login", "Login", class_="btn-primary"),
ui.output_text("login_message"),
)
)
main_ui = ui.page_sidebar(
ui.sidebar(
# Row for selecting season and level
ui.row(
ui.column(4, ui.input_select('year_input', 'Select Season', year_list, selected=2025)),
ui.column(4, ui.input_select('level_input', 'Select Level', level_dict)),
ui.column(4, ui.input_select('type_input', 'Select Type', type_dict,selected='R'))
),
# Row for the action button to get player list
ui.row(ui.input_action_button("player_button", "Get Player List", class_="btn-primary")),
# Row for selecting the player
ui.row(ui.column(12, ui.output_ui('player_select_ui', 'Select Player'))),
ui.row(ui.input_action_button("get_pitches", "Get Pitch Types", class_="btn-secondary")),
# Rows for selecting plots and split options
ui.row(ui.column(12, ui.output_ui('pitch_type_ui', 'Select Pitch Type'))),
ui.row(ui.column(6, ui.input_select('plot_type', 'Select Plot', ['Pitch%','Whiff%','xwOBACON'])),
ui.column(6, ui.input_switch('scatter_switch', 'Show Pitches', value=False))),
ui.row(ui.column(12, ui.output_ui('date_id', 'Select Date'))),
# Row for the action button to generate plot
ui.row(ui.input_action_button("generate_plot", "Generate Plot", class_="btn-primary")),
width="400px" # Added this parameter to control sidebar width
),
# Main content (former panel_main content)
ui.navset_tab(
# Tab for game summary plot
ui.nav("Pitching Summary",
ui.output_text("status"),
ui.output_plot('plot', width='1440px', height=f'{900/1600*1440}px')
),
)
)
# Combined UI with conditional panel
app_ui = ui.page_fluid(
ui.tags.head(
ui.tags.script(src="script.js")
),
ui.panel_conditional(
"!input.authenticated",
login_ui
),
ui.panel_conditional(
"input.authenticated",
main_ui
)
)
def server(input, output, session):
@reactive.Effect
@reactive.event(input.login)
def check_password():
if input.password() in VALID_PASSWORDS:
ui.update_checkbox("authenticated", value=True)
ui.update_text("login_message", value="")
else:
ui.update_text("login_message", value="Invalid password!")
ui.update_text("password", value="")
@output
@render.text
def login_message():
return ""
# Instead of using @reactive.calc with @reactive.event
cached_data_value = reactive.value(None) # Initialize with None
@reactive.calc
@reactive.event(input.date_id,input.pitcher_id)
def cached_data():
if not hasattr(input, 'pitcher_id') or input.pitcher_id() is None or not hasattr(input, 'date_id') or input.date_id() is None:
return # Exit early if required inputs aren't ready
year_input = int(input.year_input())
sport_id = int(input.level_input())
player_input = int(input.pitcher_id())
start_date = str(input.date_id()[0])
end_date = str(input.date_id()[1])
# Simulate an expensive data operation
game_list = scrape.get_player_games_list(sport_id = sport_id,
season = year_input,
player_id = player_input,
start_date = start_date,
end_date = end_date,
game_type = [input.type_input()])
data_list = scrape.get_data(game_list_input = game_list[:])
df = (update.update(scrape.get_data_df(data_list = data_list).filter(
(pl.col("pitcher_id") == player_input)&
(pl.col("is_pitch") == True)
))).with_columns(
pl.col('pitch_type').count().over('pitch_type').alias('pitch_count')
)
return df
@render.ui
@reactive.event(input.player_button, input.year_input, input.level_input, input.type_input,ignore_none=False)
def player_select_ui():
# Get the list of pitchers for the selected level and season
df_pitcher_info = scrape.get_players(sport_id=int(input.level_input()), season=int(input.year_input()), game_type = [input.type_input()]).filter(
pl.col("position").is_in(['P','TWP'])).sort("name")
# Create a dictionary of pitcher IDs and names
pitcher_dict = dict(zip(df_pitcher_info['player_id'], df_pitcher_info['name']))
# Return a select input for choosing a pitcher
return ui.input_select("pitcher_id", "Select Pitcher", pitcher_dict, selectize=True)
is_loading = reactive.value(False)
data_result = reactive.value(None)
@reactive.effect
@reactive.event(input.get_pitches)
def load_data():
is_loading.set(True)
data_result.set(None) # Clear any previous data
try:
# This will fetch the data
result = cached_data()
data_result.set(result)
except Exception as e:
# Handle any errors
print(f"Error loading data: {e}")
finally:
is_loading.set(False)
@output
@render.ui
def pitch_type_ui():
# Make sure to add dependencies on both values
input.get_pitches()
loading = is_loading()
data = data_result()
# If loading, show spinner
if loading:
return ui.div(
ui.span("Loading pitch types... ", class_="me-2"),
ui.tags.div(class_="spinner-border spinner-border-sm text-primary"),
style="padding: 10px; background-color: #f8f9fa; border-radius: 5px;"
)
# If data is loaded, show dropdown
elif data is not None:
df = data
df = df.clone() if hasattr(df, 'clone') else df.copy()
pitch_dict = dict(zip(df['pitch_type'], df['pitch_description']))
return ui.input_select(
"pitch_type_input",
"Select Pitch Type",
pitch_dict,
selectize=True
)
# Initial state or after reset
else:
return ui.div(
ui.p("Click 'Get Pitch Types' to load the dropdown.", class_="text-muted"),
style="text-align: center; padding: 10px;"
) # Empty div with instructions
@render.ui
@reactive.event(input.player_button, input.year_input, input.level_input, input.type_input,ignore_none=False)
def date_id():
# Create a date range input for selecting the date range within the selected year
return ui.input_date_range("date_id", "Select Date Range",
start=f"{int(input.year_input())}-01-01",
end=f"{int(input.year_input())}-12-31",
min=f"{int(input.year_input())}-01-01",
max=f"{int(input.year_input())}-12-31")
@output
@render.text
def status():
# Only show status when generating
if input.generate == 0:
return ""
return ""
@output
@render.plot
@reactive.event(input.generate_plot, ignore_none=False)
def plot():
# Show progress/loading notification
with ui.Progress(min=0, max=1) as p:
p.set(message="Generating plot", detail="This may take a while...")
p.set(0.3, "Gathering data...")
year_input = int(input.year_input())
sport_id = int(input.level_input())
player_input = int(input.pitcher_id())
start_date = str(input.date_id()[0])
end_date = str(input.date_id()[1])
scatter_bool = input.scatter_switch()
print(year_input, sport_id, player_input, start_date, end_date)
df = cached_data()
df = df.clone()
pitch_input = input.pitch_type_input()
df_plot = pitch_heat_map(pitch_input, df)
pivot_table_l = pitch_prop(df=df_plot, hand = 'L')
pivot_table_r = pitch_prop(df=df_plot, hand = 'R')
table_left = df_update().update_summary_select(df=df_plot.filter(pl.col('batter_hand') == 'L'), selection=['pitcher_hand'])
table_left = table_left.with_columns(
(pl.col('pitches')/len(df.filter(pl.col('batter_hand') == 'L'))).alias('pitch_percent')
)
table_right = df_update().update_summary_select(df=df_plot.filter(pl.col('batter_hand') == 'R'), selection=['pitcher_hand'])
table_right = table_right.with_columns(
(pl.col('pitches')/len(df.filter(pl.col('batter_hand') == 'R'))).alias('pitch_percent')
)
try:
normalize = mcolors.Normalize(vmin=table_left['pitch_percent']*0.5,
vmax=table_left['pitch_percent']*1.5) # Define the range of values
df_colour_left = pd.DataFrame(data=[[get_color(x,normalize,cmap_sum2) for x in pivot_table_l[0]],
[get_color(x,normalize,cmap_sum2) for x in pivot_table_l[1]],
[get_color(x,normalize,cmap_sum2) for x in pivot_table_l[2]]])
df_colour_left[0] = '#ffffff'
except ValueError:
normalize = mcolors.Normalize(vmin=0,
vmax=1) # Define the range of values
df_colour_left = pd.DataFrame(data=[['#ffffff','#ffffff','#ffffff','#ffffff'],
['#ffffff','#ffffff','#ffffff','#ffffff'],
['#ffffff','#ffffff','#ffffff','#ffffff']])
try:
normalize = mcolors.Normalize(vmin=table_right['pitch_percent']*0.5,
vmax=table_right['pitch_percent']*1.5) # Define the range of values
df_colour_right = pd.DataFrame(data=[[get_color(x,normalize,cmap_sum2) for x in pivot_table_r[0]],
[get_color(x,normalize,cmap_sum2) for x in pivot_table_r[1]],
[get_color(x,normalize,cmap_sum2) for x in pivot_table_r[2]]])
df_colour_right[0] = '#ffffff'
except ValueError:
normalize = mcolors.Normalize(vmin=0,
vmax=1) # Define the range of values
df_colour_right = pd.DataFrame(data=[['#ffffff','#ffffff','#ffffff','#ffffff'],
['#ffffff','#ffffff','#ffffff','#ffffff'],
['#ffffff','#ffffff','#ffffff','#ffffff']])
table_left = table_left.select(
'pitch_percent',
'pitches',
'heart_zone_percent',
'shadow_zone_percent',
'chase_zone_percent',
'waste_zone_percent',
'csw_percent',
'whiff_rate',
'zone_whiff_percent',
'chase_percent',
'bip',
'xwoba_percent_contact').to_pandas().T
table_right = table_right.select(
'pitch_percent',
'pitches',
'heart_zone_percent',
'shadow_zone_percent',
'chase_zone_percent',
'waste_zone_percent',
'csw_percent',
'whiff_rate',
'zone_whiff_percent',
'chase_percent',
'bip',
'xwoba_percent_contact').to_pandas().T
table_right = table_right.replace({'nan%':'—'})
table_right = table_right.replace({'nan':'—'})
p.set(0.6, "Creating plot...")
import matplotlib.pyplot as plt
fig = plt.figure(figsize=(16, 9))
fig.set_facecolor('white')
sns.set_theme(style="whitegrid", palette=colour_palette)
gs = GridSpec(3, 5, height_ratios=[2,9,1],width_ratios=[1,9,1,9,1])
gs.update(hspace=0.2, wspace=0.3)
# Add subplots to the grid
ax_header = fig.add_subplot(gs[0, :])
ax_left = fig.add_subplot(gs[1, 1])
ax_right = fig.add_subplot(gs[1, 3])
axfooter = fig.add_subplot(gs[-1, :])
if input.plot_type() == 'Pitch%':
heat_map_plot(df=df_plot,
ax=ax_left,
cmap=cmap_sum2,
hand='L',
scatter=scatter_bool)
heat_map_plot(df=df_plot,
ax=ax_right,
cmap=cmap_sum2,
hand='R',
scatter=scatter_bool)
if input.plot_type() == 'Whiff%':
heat_map_plot_hex_whiff(df=df_plot,
ax=ax_left,
cmap=cmap_sum,
hand='L',
scatter=scatter_bool)
heat_map_plot_hex_whiff(df=df_plot,
ax=ax_right,
cmap=cmap_sum,
hand='R',
scatter=scatter_bool)
if input.plot_type() == 'xwOBACON':
print(df_plot.filter((pl.col('launch_speed')>0)).select(['batter_name','launch_speed','launch_angle','woba_pred_contact']))
heat_map_plot_hex_damage(df=df_plot,
ax=ax_left,
cmap=cmap_sum,
hand='L',
scatter=scatter_bool)
heat_map_plot_hex_damage(df=df_plot,
ax=ax_right,
cmap=cmap_sum,
hand='R',
scatter=scatter_bool)
# Load the image
img = mpimg.imread('images/left.png')
imagebox = OffsetImage(img, zoom=0.58) # adjust zoom as needed
ab = AnnotationBbox(imagebox, (1.25, -0.5), box_alignment=(0, 0), frameon=False)
ax_left.add_artist(ab)
# Load the image
img = mpimg.imread('images/right.png')
imagebox = OffsetImage(img, zoom=0.58) # adjust zoom as needed
# Create an AnnotationBbox
ab = AnnotationBbox(imagebox, (-1.25, -0.5), box_alignment=(1, 0), frameon=False)
ax_right.add_artist(ab)
table_plot(ax=ax_left,
table=table_left,
hand='L')
table_plot_pivot(ax=ax_left,
pivot_table=pivot_table_l,
df_colour=df_colour_left)
table_plot(ax=ax_right,
table=table_right,
hand='R')
table_plot_pivot(ax=ax_right,
pivot_table=pivot_table_r,
df_colour=df_colour_right)
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Normalize
# Create a ScalarMappable with the same colormap and normalization
if input.plot_type() == 'Pitch%':
sm = ScalarMappable(cmap=cmap_sum2, norm=Normalize(vmin=0, vmax=1))
cbar = fig.colorbar(sm, ax=axfooter, orientation='horizontal',aspect=100)
cbar.set_ticks([])
cbar.set_ticks([sm.norm.vmin, sm.norm.vmax])
cbar.ax.set_xticklabels(['Least', 'Most'])
cbar.ax.tick_params(labeltop=True, labelbottom=False, labelsize=14)
labels = cbar.ax.get_xticklabels()
labels[0].set_horizontalalignment('left')
labels[-1].set_horizontalalignment('right')
labels = cbar.ax.get_xticklabels()
cbar.ax.set_xticklabels(labels)
cbar.ax.tick_params(length=0)
if input.plot_type() == 'Whiff%':
sm = ScalarMappable(cmap=cmap_sum, norm=Normalize(vmin=0.15, vmax=0.35))
cbar = fig.colorbar(sm, ax=axfooter, orientation='horizontal',aspect=100)
cbar.set_ticks([])
cbar.set_ticks([sm.norm.vmin, sm.norm.vmax])
cbar.ax.set_xticklabels(['15%', '35%'])
cbar.ax.tick_params(labeltop=True, labelbottom=False, labelsize=14)
labels = cbar.ax.get_xticklabels()
labels[0].set_horizontalalignment('left')
labels[-1].set_horizontalalignment('right')
labels = cbar.ax.get_xticklabels()
cbar.ax.set_xticklabels(labels)
cbar.ax.tick_params(length=0)
if input.plot_type() == 'xwOBACON':
sm = ScalarMappable(cmap=cmap_sum_r, norm=Normalize(vmin=0.25, vmax=0.5))
cbar = fig.colorbar(sm, ax=axfooter, orientation='horizontal',aspect=100)
cbar.set_ticks([])
cbar.set_ticks([sm.norm.vmin, sm.norm.vmax])
cbar.ax.set_xticklabels(['.000', '.500'])
cbar.ax.tick_params(labeltop=True, labelbottom=False, labelsize=14)
labels = cbar.ax.get_xticklabels()
labels[0].set_horizontalalignment('left')
labels[-1].set_horizontalalignment('right')
labels = cbar.ax.get_xticklabels()
cbar.ax.set_xticklabels(labels)
cbar.ax.tick_params(length=0)
axfooter.text(x=0.02,y=1,s='By: Thomas Nestico\n @TJStats',fontname='Calibri',ha='left',fontsize=14,va='top')
axfooter.text(x=1-0.02,y=1,s='Data: MLB',ha='right',fontname='Calibri',fontsize=14,va='top')
axfooter.axis('off')
# Display the image on the axis
ax_header.set_xlim(-12,12)
ax_header.set_ylim(0, 2)
if input.plot_type() == 'Pitch%':
ax_header.text(x=0,y=2,s=f"{df_plot['pitcher_name'][0]} - {df_plot['pitcher_hand'][0]}HP\n{df_plot['pitch_description'][0]} Pitch Frequency",ha='center',fontsize=24,va='top')
if input.plot_type() == 'Whiff%':
ax_header.text(x=0,y=2,s=f"{df_plot['pitcher_name'][0]} - {df_plot['pitcher_hand'][0]}HP\n{df_plot['pitch_description'][0]} Whiff%",ha='center',fontsize=24,va='top')
if input.plot_type() == 'xwOBACON':
ax_header.text(x=0,y=2,s=f"{df_plot['pitcher_name'][0]} - {df_plot['pitcher_hand'][0]}HP\n{df_plot['pitch_description'][0]} xwOBACON",ha='center',fontsize=24,va='top')
ax_header.text(x=0,y=0.7,s=f"{year_input} {level_dict[str(sport_id)]} Season",ha='center',fontsize=16,va='top')
ax_header.text(x=0,y=0.3,s=f"{df_plot['game_date'][0]} to {df_plot['game_date'][-1]}",ha='center',fontsize=16,va='top',fontstyle='italic')
ax_header.axis('off')
import urllib
import urllib.request
import urllib.error
from urllib.error import HTTPError
plot_header(pitcher_id=player_input,
ax=ax_header,
df_team=scrape.get_teams(),
df_players=scrape.get_players(sport_id,year_input),
sport_id=sport_id,)
fig.subplots_adjust(left=0.03, right=0.97, top=0.97, bottom=0.03)
app = App(app_ui, server)