Spaces:
Sleeping
Sleeping
| # ============================================================ | |
| # Oldham Athletic Player Scouting App | |
| # | |
| # Hugging Face files needed: | |
| # app.py | |
| # OA_sheet_for_app.csv | |
| # all_players_enriched_multiseason.csv | |
| # requirements.txt | |
| # | |
| # requirements.txt should contain: | |
| # gradio==5.49.1 | |
| # pandas | |
| # numpy | |
| # plotly | |
| # fpdf | |
| # matplotlib | |
| # ============================================================ | |
| import os | |
| import re | |
| import tempfile | |
| import warnings | |
| import gradio as gr | |
| import numpy as np | |
| import pandas as pd | |
| import plotly.graph_objects as go | |
| import plotly.express as px | |
| from fpdf import FPDF | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| warnings.filterwarnings("ignore") | |
| CUSTOM_CSS = """ | |
| .gradio-container { | |
| max-width: 98% !important; | |
| margin-left: auto !important; | |
| margin-right: auto !important; | |
| } | |
| .dataframe-container { | |
| width: 100% !important; | |
| overflow-x: auto !important; | |
| } | |
| table { | |
| width: 100% !important; | |
| } | |
| th, td { | |
| white-space: nowrap !important; | |
| overflow: visible !important; | |
| text-overflow: clip !important; | |
| max-width: none !important; | |
| } | |
| .wrap .dataframe-container, | |
| .gradio-dataframe { | |
| overflow-x: auto !important; | |
| } | |
| .profile-card { | |
| border: 1px solid #e5e7eb; | |
| border-radius: 12px; | |
| padding: 18px; | |
| background: #ffffff; | |
| } | |
| """ | |
| # ============================================================ | |
| # LOAD DATA | |
| # ============================================================ | |
| MAIN_DATA_FILE = "OA_sheet_for_app.csv" | |
| MULTISEASON_DATA_FILE = "all_players_enriched_multiseason.csv" | |
| def clean_columns(data): | |
| data = data.copy() | |
| data.columns = ( | |
| data.columns | |
| .str.strip() | |
| .str.lower() | |
| .str.replace(" ", "_") | |
| .str.replace("-", "_") | |
| .str.replace("/", "_") | |
| .str.replace(".", "_", regex=False) | |
| ) | |
| return data | |
| def clean_player_key(x): | |
| if pd.isna(x): | |
| return "" | |
| return ( | |
| str(x) | |
| .strip() | |
| .lower() | |
| .replace(".", "") | |
| .replace(",", "") | |
| .replace("-", " ") | |
| .replace(" ", " ") | |
| ) | |
| df = pd.read_csv(MAIN_DATA_FILE, encoding="latin1", low_memory=False) | |
| df = clean_columns(df) | |
| if os.path.exists(MULTISEASON_DATA_FILE): | |
| multi_df = pd.read_csv(MULTISEASON_DATA_FILE, encoding="latin1", low_memory=False) | |
| multi_df = clean_columns(multi_df) | |
| else: | |
| multi_df = pd.DataFrame() | |
| if "player_name" in df.columns: | |
| df["_player_key"] = df["player_name"].apply(clean_player_key) | |
| multi_player_col = None | |
| if not multi_df.empty: | |
| for possible_col in ["player_name", "player", "name"]: | |
| if possible_col in multi_df.columns: | |
| multi_player_col = possible_col | |
| break | |
| if multi_player_col is not None: | |
| multi_df["_player_key"] = multi_df[multi_player_col].apply(clean_player_key) | |
| else: | |
| multi_df["_player_key"] = "" | |
| historical_suffixes_for_merge = ["_2122", "_2223", "_2324", "_2425"] | |
| historical_cols_for_merge = [ | |
| col for col in multi_df.columns | |
| if any(col.endswith(suffix) for suffix in historical_suffixes_for_merge) | |
| ] | |
| if historical_cols_for_merge and "_player_key" in multi_df.columns: | |
| multi_keep = multi_df[["_player_key"] + historical_cols_for_merge].drop_duplicates(subset=["_player_key"]) | |
| overlapping_hist_cols = [c for c in historical_cols_for_merge if c in df.columns] | |
| if overlapping_hist_cols: | |
| df = df.drop(columns=overlapping_hist_cols) | |
| df = df.merge( | |
| multi_keep, | |
| on="_player_key", | |
| how="left" | |
| ) | |
| # ============================================================ | |
| # COLUMN SETUP | |
| # ============================================================ | |
| PLAYER_COL = "player_name" | |
| TEAM_COL = "team_name" | |
| COMP_COL = "competition_name" | |
| SEASON_COL = "season_name" | |
| POSITION_COL = "primary_position" | |
| SECONDARY_POSITION_COL = "secondary_position" | |
| COUNTRY_COL = "country_id" | |
| AGE_COL = "age" | |
| HEIGHT_COL = "player_height" | |
| WEIGHT_COL = "player_weight" | |
| MINUTES_COL = "player_season_minutes" | |
| MARKET_VALUE_COL = "market_value_eur" | |
| CONTRACT_COL = "seasons_left_num" | |
| ATTAINABILITY_COL = "attainability" | |
| TARGET_SCORE_COL = "target_score" | |
| ARCHETYPE_COL = "best_position_archetype_name" | |
| ARCHETYPE_SCORE_COL = "best_position_archetype_score" | |
| CLUB_RANK_COL = "club_rank" | |
| MATCH_TOUGHNESS_COL = "match_toughness" | |
| WEIGHTED_MATCH_TOUGHNESS_COL = "wmatch_toughness" | |
| ELO_COL = "elo" | |
| COMPETITION_RANK_COL = "competition_rank" | |
| ATTR_COLS = [ | |
| "attr_shot_stopping", | |
| "attr_sweeping", | |
| "attr_ball_claiming", | |
| "attr_short_passing", | |
| "attr_long_passing", | |
| "attr_pressing", | |
| "attr_duels", | |
| "attr_aerial", | |
| "attr_possession_retention", | |
| "attr_blocking", | |
| "attr_progression", | |
| "attr_set_pieces", | |
| "attr_impact", | |
| "attr_discipline", | |
| "attr_dribbling", | |
| "attr_chance_creation", | |
| "attr_finishing", | |
| "attr_crossing", | |
| "attr_box_presence", | |
| "attr_holdup", | |
| ] | |
| CAT_COLS = [ | |
| "cat_defensive_ability", | |
| "cat_aerial_ability", | |
| "cat_finishing", | |
| "cat_chance_creation", | |
| "cat_dribbling", | |
| "cat_ball_progression", | |
| "cat_passing", | |
| ] | |
| POSITION_SCORE_COLS = [ | |
| "cb_score", | |
| "fb_score", | |
| "cmd_score", | |
| "cma_score", | |
| "wm_score", | |
| "cf_score", | |
| "st_score", | |
| "gk_score", | |
| ] | |
| ARCHETYPE_SCORE_COLS = [ | |
| "score_defensive_cb", | |
| "score_pressing_cb", | |
| "score_ballplaying_cb", | |
| "score_defensive_fb", | |
| "score_attacking_fb", | |
| "score_possession_fb", | |
| "score_poacher", | |
| "score_target_man", | |
| "score_false_nine", | |
| "score_complete_forward", | |
| "score_inside_forward", | |
| "score_traditional_winger", | |
| "score_playmaking_winger", | |
| "score_pressing_winger", | |
| "score_complete_winger", | |
| "score_defensive_midfielder", | |
| "score_deep_lying_playmaker", | |
| "score_box_to_box_midfielder", | |
| "score_advanced_playmaker", | |
| "score_wide_midfielder", | |
| "score_attacking_runner", | |
| "score_shot_stopper_gk", | |
| "score_sweeper_keeper_gk", | |
| "score_ball_playing_gk", | |
| ] | |
| KEY_METRICS = [ | |
| "player_season_minutes", | |
| "player_season_goals_90", | |
| "player_season_assists_90", | |
| "player_season_np_xg_90", | |
| "player_season_xa_90", | |
| "player_season_key_passes_90", | |
| "player_season_passing_ratio", | |
| "player_season_tackles_90", | |
| "player_season_interceptions_90", | |
| "player_season_tackles_and_interceptions_90", | |
| "player_season_aerial_wins_90", | |
| "player_season_aerial_ratio", | |
| "player_season_dribbles_90", | |
| "player_season_crosses_90", | |
| "player_season_long_balls_90", | |
| "player_season_xgchain_90", | |
| "player_season_xgbuildup_90", | |
| "player_season_obv_90", | |
| ] | |
| SEARCH_TABLE_COLS = [ | |
| PLAYER_COL, | |
| POSITION_COL, | |
| TEAM_COL, | |
| COMP_COL, | |
| AGE_COL, | |
| COUNTRY_COL, | |
| MINUTES_COL, | |
| MARKET_VALUE_COL, | |
| CONTRACT_COL, | |
| ARCHETYPE_COL, | |
| ARCHETYPE_SCORE_COL, | |
| TARGET_SCORE_COL, | |
| ATTAINABILITY_COL, | |
| ] + POSITION_SCORE_COLS + ATTR_COLS | |
| COMPARISON_COLS = [ | |
| PLAYER_COL, | |
| POSITION_COL, | |
| TEAM_COL, | |
| COMP_COL, | |
| AGE_COL, | |
| MARKET_VALUE_COL, | |
| CONTRACT_COL, | |
| ARCHETYPE_COL, | |
| ARCHETYPE_SCORE_COL, | |
| TARGET_SCORE_COL, | |
| ATTAINABILITY_COL, | |
| ] + POSITION_SCORE_COLS + ATTR_COLS | |
| SHORTLIST_COLS = [ | |
| PLAYER_COL, | |
| POSITION_COL, | |
| TEAM_COL, | |
| COMP_COL, | |
| AGE_COL, | |
| MINUTES_COL, | |
| MARKET_VALUE_COL, | |
| CONTRACT_COL, | |
| ARCHETYPE_COL, | |
| ARCHETYPE_SCORE_COL, | |
| TARGET_SCORE_COL, | |
| ATTAINABILITY_COL, | |
| ] + KEY_METRICS + ATTR_COLS + POSITION_SCORE_COLS + ARCHETYPE_SCORE_COLS | |
| RADAR_METRICS = ATTR_COLS | |
| PERCENTILE_METRICS = ATTR_COLS + [ | |
| TARGET_SCORE_COL, | |
| ATTAINABILITY_COL, | |
| ARCHETYPE_SCORE_COL, | |
| ] | |
| SIMILARITY_METRICS = ATTR_COLS + [ | |
| TARGET_SCORE_COL, | |
| ATTAINABILITY_COL, | |
| ARCHETYPE_SCORE_COL, | |
| ] | |
| # ============================================================ | |
| # HELPERS | |
| # ============================================================ | |
| def available_cols(cols): | |
| seen = set() | |
| out = [] | |
| for c in cols: | |
| if c in df.columns and c not in seen: | |
| out.append(c) | |
| seen.add(c) | |
| return out | |
| def pretty_label(col): | |
| custom = { | |
| "player_name": "Player", | |
| "team_name": "Club", | |
| "competition_name": "Competition", | |
| "season_name": "Season", | |
| "primary_position": "Primary Position", | |
| "secondary_position": "Secondary Position", | |
| "country_id": "Country", | |
| "player_height": "Height", | |
| "player_weight": "Weight", | |
| "player_season_minutes": "Minutes", | |
| "market_value_eur": "Market Value", | |
| "seasons_left_num": "Seasons Left", | |
| "attainability": "Attainability", | |
| "target_score": "Target Score", | |
| "best_position_archetype_name": "Best Archetype", | |
| "best_position_archetype_score": "Best Archetype Score", | |
| "cb_score": "CB Score", | |
| "fb_score": "FB Score", | |
| "cmd_score": "CMD Score", | |
| "cma_score": "CMA Score", | |
| "wm_score": "WM Score", | |
| "cf_score": "CF Score", | |
| "st_score": "ST Score", | |
| "gk_score": "GK Score", | |
| "club_rank": "Club Rank", | |
| "match_toughness": "Match Toughness", | |
| "elo": "Club ELO", | |
| "competition_rank": "Competition Rank", | |
| "fit_score": "Fit Score", | |
| } | |
| if col in custom: | |
| return custom[col] | |
| label = col | |
| label = label.replace("player_season_", "") | |
| label = label.replace("attr_", "") | |
| label = label.replace("cat_", "") | |
| label = label.replace("score_", "") | |
| label = label.replace("_90", " Per 90") | |
| label = label.replace("_", " ") | |
| label = label.title() | |
| label = label.replace("Np Xg", "NP xG") | |
| label = label.replace("Xa", "xA") | |
| label = label.replace("Xgchain", "xGChain") | |
| label = label.replace("Xgbuildup", "xGBuildup") | |
| label = label.replace("Obv", "OBV") | |
| label = label.replace("Gk", "GK") | |
| label = label.replace("Cb", "CB") | |
| label = label.replace("Fb", "FB") | |
| label = label.replace("Cmd", "CMD") | |
| label = label.replace("Cma", "CMA") | |
| label = label.replace("Wm", "WM") | |
| label = label.replace("Cf", "CF") | |
| label = label.replace("St", "ST") | |
| return label | |
| def format_money(x): | |
| try: | |
| if pd.isna(x) or str(x).strip() in ["", "-", "nan"]: | |
| return "Not listed" | |
| x = float(x) | |
| if x >= 1_000_000: | |
| return f"€{x / 1_000_000:.1f}M" | |
| if x >= 1_000: | |
| return f"€{x / 1_000:.0f}K" | |
| return f"€{x:.0f}" | |
| except Exception: | |
| return "Not listed" | |
| def clean_value(x): | |
| if pd.isna(x): | |
| return "N/A" | |
| if isinstance(x, (float, np.floating)): | |
| return round(float(x), 2) | |
| if isinstance(x, (int, np.integer)): | |
| return int(x) | |
| return x | |
| def pretty_df(data): | |
| out = data.copy() | |
| if MARKET_VALUE_COL in out.columns: | |
| out[MARKET_VALUE_COL] = out[MARKET_VALUE_COL].apply(format_money) | |
| numeric_cols = out.select_dtypes(include=np.number).columns | |
| out[numeric_cols] = out[numeric_cols].round(2) | |
| out = out.rename(columns={c: pretty_label(c) for c in out.columns}) | |
| return out | |
| def safe_pdf_text(x): | |
| text = str(x) | |
| text = text.replace("€", "EUR ") | |
| text = text.replace("–", "-") | |
| text = text.replace("—", "-") | |
| text = text.replace("’", "'") | |
| text = text.replace("“", '"') | |
| text = text.replace("”", '"') | |
| return text.encode("latin1", "replace").decode("latin1") | |
| def get_player_row(player): | |
| if not player or PLAYER_COL not in df.columns: | |
| return None | |
| rows = df[df[PLAYER_COL].astype(str) == str(player)] | |
| if rows.empty: | |
| return None | |
| return rows.iloc[0] | |
| def get_player_group(row): | |
| comp = row.get(COMP_COL, None) | |
| pos = row.get(POSITION_COL, None) | |
| group = df.copy() | |
| if COMP_COL in df.columns and POSITION_COL in df.columns and pd.notna(comp) and pd.notna(pos): | |
| group = group[(group[COMP_COL] == comp) & (group[POSITION_COL] == pos)] | |
| if group.empty: | |
| group = df.copy() | |
| return group | |
| def normalize_0_100(series): | |
| values = pd.to_numeric(series, errors="coerce") | |
| min_v = values.min() | |
| max_v = values.max() | |
| if pd.isna(min_v) or pd.isna(max_v) or max_v == min_v: | |
| return pd.Series(np.zeros(len(values)), index=series.index) | |
| return ((values - min_v) / (max_v - min_v)) * 100 | |
| def top_available_attr_cols(row=None, max_cols=8): | |
| cols = [] | |
| for col in available_cols(RADAR_METRICS): | |
| if row is None: | |
| cols.append(col) | |
| else: | |
| if pd.notna(row.get(col, np.nan)): | |
| cols.append(col) | |
| return cols[:max_cols] | |
| def selected_player_from_table(table, evt: gr.SelectData): | |
| try: | |
| if table is None: | |
| return gr.update() | |
| if isinstance(table, pd.DataFrame): | |
| table_df = table.copy() | |
| else: | |
| table_df = pd.DataFrame(table) | |
| if table_df.empty: | |
| return gr.update() | |
| row_index = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index | |
| if "Player" not in table_df.columns: | |
| return gr.update() | |
| player = table_df.iloc[row_index]["Player"] | |
| if pd.isna(player): | |
| return gr.update() | |
| return gr.update(value=str(player)) | |
| except Exception: | |
| return gr.update() | |
| def selected_player_status(player): | |
| if player: | |
| return f"Loaded **{player}** into the Player Profile tab." | |
| return "Click a player row to load them into the Player Profile tab." | |
| # ============================================================ | |
| # PERFORMANCE OVER TIME SETUP | |
| # ============================================================ | |
| HISTORICAL_SEASONS = { | |
| "2122": "2021-22", | |
| "2223": "2022-23", | |
| "2324": "2023-24", | |
| "2425": "2024-25", | |
| } | |
| CURRENT_MAIN_SEASON_LABEL = "2025-26" | |
| PERFORMANCE_TIME_METRICS = POSITION_SCORE_COLS | |
| def strip_player_season(metric): | |
| return metric.replace("player_season_", "") | |
| def historical_candidate_columns(base_metric, season_code): | |
| short_metric = strip_player_season(base_metric) | |
| candidates = [ | |
| f"{base_metric}_{season_code}", | |
| f"{short_metric}_{season_code}", | |
| ] | |
| if short_metric.endswith("_90"): | |
| no_90 = short_metric.replace("_90", "") | |
| candidates += [ | |
| f"{no_90}_90_{season_code}", | |
| f"{no_90}_per_90_{season_code}", | |
| f"{no_90}_p90_{season_code}", | |
| ] | |
| replacements = { | |
| "crosses": "cross", | |
| "goals": "goal", | |
| "assists": "assist", | |
| "dribbles": "dribble", | |
| "tackles": "tackle", | |
| "interceptions": "interception", | |
| "aerial_wins": "aerial_win", | |
| "key_passes": "key_pass", | |
| "long_balls": "long_ball", | |
| } | |
| for plural, singular in replacements.items(): | |
| if plural in short_metric: | |
| candidates.append(f"{short_metric.replace(plural, singular)}_{season_code}") | |
| if short_metric.endswith("_90"): | |
| candidates.append( | |
| f"{short_metric.replace(plural, singular).replace('_90', '')}_per_90_{season_code}" | |
| ) | |
| if base_metric in POSITION_SCORE_COLS: | |
| position_code = base_metric.replace("_score", "") | |
| candidates += [ | |
| f"{position_code}_score_{season_code}", | |
| f"{position_code}_{season_code}", | |
| f"{position_code.upper()}_score_{season_code}".lower(), | |
| ] | |
| clean_candidates = [] | |
| seen = set() | |
| for col in candidates: | |
| col = col.lower() | |
| if col not in seen: | |
| clean_candidates.append(col) | |
| seen.add(col) | |
| return clean_candidates | |
| def find_metric_value(row, base_metric, season_code=None): | |
| if row is None: | |
| return np.nan | |
| if season_code is None: | |
| if base_metric in row.index: | |
| return row.get(base_metric, np.nan) | |
| return np.nan | |
| for col in historical_candidate_columns(base_metric, season_code): | |
| if col in row.index: | |
| value = row.get(col, np.nan) | |
| if pd.notna(value): | |
| return value | |
| return np.nan | |
| def get_multiseason_row_for_player(player): | |
| if multi_df is None or multi_df.empty: | |
| return None | |
| player_key = clean_player_key(player) | |
| if "_player_key" not in multi_df.columns: | |
| return None | |
| matches = multi_df[multi_df["_player_key"] == player_key] | |
| if matches.empty: | |
| return None | |
| return matches.iloc[0] | |
| def build_performance_metric_options(): | |
| options = [] | |
| for base_metric in PERFORMANCE_TIME_METRICS: | |
| current_exists = base_metric in df.columns | |
| historical_exists = False | |
| for season_code in HISTORICAL_SEASONS.keys(): | |
| for candidate in historical_candidate_columns(base_metric, season_code): | |
| if candidate in df.columns: | |
| historical_exists = True | |
| break | |
| if not multi_df.empty and candidate in multi_df.columns: | |
| historical_exists = True | |
| break | |
| if historical_exists: | |
| break | |
| if current_exists or historical_exists: | |
| options.append((pretty_label(base_metric), base_metric)) | |
| return options | |
| # ============================================================ | |
| # NUMERIC CLEANING | |
| # ============================================================ | |
| numeric_cols = available_cols( | |
| KEY_METRICS | |
| + ATTR_COLS | |
| + CAT_COLS | |
| + POSITION_SCORE_COLS | |
| + ARCHETYPE_SCORE_COLS | |
| + [ | |
| AGE_COL, | |
| HEIGHT_COL, | |
| WEIGHT_COL, | |
| MINUTES_COL, | |
| MARKET_VALUE_COL, | |
| ATTAINABILITY_COL, | |
| TARGET_SCORE_COL, | |
| ARCHETYPE_SCORE_COL, | |
| CLUB_RANK_COL, | |
| MATCH_TOUGHNESS_COL, | |
| WEIGHTED_MATCH_TOUGHNESS_COL, | |
| ELO_COL, | |
| COMPETITION_RANK_COL, | |
| ] | |
| ) | |
| historical_numeric_cols = [ | |
| col for col in df.columns | |
| if any(col.endswith(f"_{season}") for season in HISTORICAL_SEASONS.keys()) | |
| ] | |
| for col in numeric_cols + historical_numeric_cols: | |
| if col in df.columns: | |
| df[col] = pd.to_numeric(df[col], errors="coerce") | |
| if not multi_df.empty: | |
| multi_historical_numeric_cols = [ | |
| col for col in multi_df.columns | |
| if any(col.endswith(f"_{season}") for season in HISTORICAL_SEASONS.keys()) | |
| ] | |
| for col in multi_historical_numeric_cols: | |
| multi_df[col] = pd.to_numeric(multi_df[col], errors="coerce") | |
| # ============================================================ | |
| # DROPDOWN OPTIONS | |
| # ============================================================ | |
| player_options = sorted(df[PLAYER_COL].dropna().astype(str).unique().tolist()) | |
| competition_options = sorted(df[COMP_COL].dropna().astype(str).unique().tolist()) if COMP_COL in df.columns else [] | |
| team_options = sorted(df[TEAM_COL].dropna().astype(str).unique().tolist()) if TEAM_COL in df.columns else [] | |
| position_options = sorted(df[POSITION_COL].dropna().astype(str).unique().tolist()) if POSITION_COL in df.columns else [] | |
| country_options = sorted(df[COUNTRY_COL].dropna().astype(str).unique().tolist()) if COUNTRY_COL in df.columns else [] | |
| age_min = int(np.floor(df[AGE_COL].min())) if AGE_COL in df.columns and df[AGE_COL].notna().any() else 15 | |
| age_max = int(np.ceil(df[AGE_COL].max())) if AGE_COL in df.columns and df[AGE_COL].notna().any() else 45 | |
| minutes_max = int(df[MINUTES_COL].max()) if MINUTES_COL in df.columns and df[MINUTES_COL].notna().any() else 5000 | |
| performance_metric_options = build_performance_metric_options() | |
| shortlist = [] | |
| def make_player_dropdown_choices(): | |
| choices = [] | |
| base_cols = available_cols([PLAYER_COL, POSITION_COL, TEAM_COL]) | |
| for _, row in df[base_cols].drop_duplicates().iterrows(): | |
| player = str(row.get(PLAYER_COL, "")) | |
| position = str(row.get(POSITION_COL, "")) | |
| team = str(row.get(TEAM_COL, "")) | |
| label = f"{player} | {position} | {team}" | |
| choices.append((label, player)) | |
| choices = sorted(choices, key=lambda x: x[0]) | |
| return choices | |
| player_dropdown_choices = make_player_dropdown_choices() | |
| # ============================================================ | |
| # SEARCH | |
| # ============================================================ | |
| def search_players(search, competitions, teams, positions, countries, min_age, max_age, min_minutes): | |
| data = df.copy() | |
| if min_age > max_age: | |
| min_age, max_age = max_age, min_age | |
| if search and PLAYER_COL in data.columns: | |
| cleaned_search = str(search).strip() | |
| data = data[ | |
| data[PLAYER_COL] | |
| .astype(str) | |
| .str.contains(cleaned_search, case=False, na=False, regex=False) | |
| ] | |
| if competitions and COMP_COL in data.columns: | |
| competitions = [str(x) for x in competitions] | |
| data = data[data[COMP_COL].astype(str).isin(competitions)] | |
| if teams and TEAM_COL in data.columns: | |
| teams = [str(x) for x in teams] | |
| data = data[data[TEAM_COL].astype(str).isin(teams)] | |
| if positions and POSITION_COL in data.columns: | |
| positions = [str(x) for x in positions] | |
| data = data[data[POSITION_COL].astype(str).isin(positions)] | |
| if countries and COUNTRY_COL in data.columns: | |
| countries = [str(x) for x in countries] | |
| data = data[data[COUNTRY_COL].astype(str).isin(countries)] | |
| if AGE_COL in data.columns: | |
| data = data[ | |
| (data[AGE_COL].fillna(-999) >= min_age) & | |
| (data[AGE_COL].fillna(999) <= max_age) | |
| ] | |
| if MINUTES_COL in data.columns: | |
| data = data[data[MINUTES_COL].fillna(0) >= min_minutes] | |
| table_cols = available_cols(SEARCH_TABLE_COLS) | |
| out = data[table_cols].copy() | |
| if out.empty: | |
| empty = pd.DataFrame({"Message": ["No players found. Try clearing one filter or lowering minimum minutes."]}) | |
| return empty, empty | |
| sort_col = TARGET_SCORE_COL if TARGET_SCORE_COL in out.columns else ATTAINABILITY_COL | |
| if sort_col in out.columns: | |
| out = out.sort_values(sort_col, ascending=False, na_position="last") | |
| out = pretty_df(out).reset_index(drop=True) | |
| return out, out | |
| # ============================================================ | |
| # PLAYER PROFILE | |
| # ============================================================ | |
| def player_profile(player): | |
| row = get_player_row(player) | |
| if row is None: | |
| return "Select a player to view their profile." | |
| lines = [] | |
| lines.append(f"# {row.get(PLAYER_COL, 'Unknown Player')}") | |
| lines.append(f"### {row.get(TEAM_COL, 'N/A')} | {row.get(COMP_COL, 'N/A')}") | |
| lines.append("") | |
| lines.append("## Player Details") | |
| lines.append(f"- **Primary Position:** {clean_value(row.get(POSITION_COL, np.nan))}") | |
| lines.append(f"- **Secondary Position:** {clean_value(row.get(SECONDARY_POSITION_COL, np.nan))}") | |
| lines.append(f"- **Age:** {clean_value(row.get(AGE_COL, np.nan))}") | |
| lines.append(f"- **Country:** {clean_value(row.get(COUNTRY_COL, np.nan))}") | |
| lines.append(f"- **Height:** {clean_value(row.get(HEIGHT_COL, np.nan))} cm") | |
| lines.append(f"- **Weight:** {clean_value(row.get(WEIGHT_COL, np.nan))} kg") | |
| lines.append(f"- **Market Value:** {format_money(row.get(MARKET_VALUE_COL, np.nan))}") | |
| lines.append(f"- **Contract:** {clean_value(row.get(CONTRACT_COL, np.nan))}") | |
| lines.append(f"- **Minutes:** {clean_value(row.get(MINUTES_COL, np.nan))}") | |
| return "\n".join(lines) | |
| def key_performance_summary(player): | |
| row = get_player_row(player) | |
| if row is None: | |
| return pd.DataFrame({"Metric": ["Select a player"], "Value": [""]}) | |
| rows = [ | |
| {"Metric": "Best Archetype", "Value": clean_value(row.get(ARCHETYPE_COL, np.nan))}, | |
| {"Metric": "Best Archetype Score", "Value": clean_value(row.get(ARCHETYPE_SCORE_COL, np.nan))}, | |
| {"Metric": "Target Score", "Value": clean_value(row.get(TARGET_SCORE_COL, np.nan))}, | |
| {"Metric": "Attainability", "Value": clean_value(row.get(ATTAINABILITY_COL, np.nan))}, | |
| {"Metric": "Club Rank", "Value": clean_value(row.get(CLUB_RANK_COL, np.nan))}, | |
| {"Metric": "Match Toughness", "Value": clean_value(row.get(MATCH_TOUGHNESS_COL, np.nan))}, | |
| {"Metric": "Club ELO", "Value": clean_value(row.get(ELO_COL, np.nan))}, | |
| ] | |
| return pd.DataFrame(rows) | |
| def profile_metric_dropdown_table(player, metric_group): | |
| row = get_player_row(player) | |
| if row is None: | |
| return pd.DataFrame({"Metric": ["Select a player"], "Score": [""]}) | |
| if metric_group == "Attributes": | |
| cols = ATTR_COLS | |
| elif metric_group == "Position Scores": | |
| cols = POSITION_SCORE_COLS | |
| elif metric_group == "Archetype Scores": | |
| cols = ARCHETYPE_SCORE_COLS | |
| elif metric_group == "Key Season Stats": | |
| cols = KEY_METRICS | |
| else: | |
| cols = ATTR_COLS | |
| rows = [] | |
| for col in available_cols(cols): | |
| value = row.get(col, np.nan) | |
| if pd.notna(value): | |
| rows.append({ | |
| "Metric": pretty_label(col), | |
| "Score": round(float(value), 2) if isinstance(value, (int, float, np.integer, np.floating)) else value | |
| }) | |
| if not rows: | |
| return pd.DataFrame({"Metric": ["No metrics available"], "Score": [""]}) | |
| out = pd.DataFrame(rows) | |
| if "Score" in out.columns: | |
| out = out.sort_values("Score", ascending=False, na_position="last") | |
| return out.reset_index(drop=True) | |
| # ============================================================ | |
| # CHARTS | |
| # ============================================================ | |
| def radar_chart(player): | |
| row = get_player_row(player) | |
| if row is None: | |
| return go.Figure() | |
| metrics = top_available_attr_cols(row, max_cols=8) | |
| if len(metrics) < 3: | |
| fig = go.Figure() | |
| fig.update_layout( | |
| title="Not enough attribute metrics available for radar chart.", | |
| height=620, | |
| margin=dict(l=160, r=160, t=110, b=120), | |
| ) | |
| return fig | |
| group = get_player_group(row) | |
| labels = [pretty_label(m) for m in metrics] | |
| player_values = [row.get(m, 0) if pd.notna(row.get(m, np.nan)) else 0 for m in metrics] | |
| avg_values = [group[m].mean() if m in group.columns else 0 for m in metrics] | |
| max_radar_value = max(100, np.nanmax(player_values + avg_values) * 1.1) | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatterpolar( | |
| r=player_values, | |
| theta=labels, | |
| fill="toself", | |
| name=str(player), | |
| )) | |
| fig.add_trace(go.Scatterpolar( | |
| r=avg_values, | |
| theta=labels, | |
| fill="toself", | |
| name="Position/Competition Avg", | |
| )) | |
| fig.update_layout( | |
| title=dict( | |
| text=f"{player} Attribute Radar", | |
| x=0.5, | |
| xanchor="center", | |
| ), | |
| polar=dict( | |
| domain=dict(x=[0.18, 0.82], y=[0.14, 0.86]), | |
| radialaxis=dict( | |
| visible=True, | |
| range=[0, max_radar_value], | |
| ), | |
| ), | |
| height=720, | |
| margin=dict(l=170, r=170, t=120, b=120), | |
| showlegend=True, | |
| legend=dict( | |
| orientation="h", | |
| y=-0.08, | |
| x=0.5, | |
| xanchor="center" | |
| ), | |
| ) | |
| return fig | |
| def percentile_chart(player): | |
| row = get_player_row(player) | |
| if row is None: | |
| return go.Figure() | |
| group = get_player_group(row) | |
| rows = [] | |
| for metric in available_cols(PERCENTILE_METRICS): | |
| value = row.get(metric, np.nan) | |
| values = pd.to_numeric(group[metric], errors="coerce").dropna() | |
| if pd.notna(value) and len(values) > 1: | |
| percentile = (values < value).mean() * 100 | |
| rows.append({ | |
| "Metric": pretty_label(metric), | |
| "Percentile": round(percentile, 1), | |
| "Percent Label": f"{round(percentile, 1)}%", | |
| "Value": round(float(value), 2), | |
| }) | |
| plot_df = pd.DataFrame(rows) | |
| if plot_df.empty: | |
| fig = go.Figure() | |
| fig.update_layout( | |
| title="No percentile data available.", | |
| height=550, | |
| margin=dict(l=140, r=100, t=90, b=80), | |
| ) | |
| return fig | |
| plot_df = plot_df.sort_values("Percentile") | |
| fig = px.bar( | |
| plot_df, | |
| x="Percentile", | |
| y="Metric", | |
| orientation="h", | |
| text="Percent Label", | |
| hover_data=["Value"], | |
| range_x=[0, 100], | |
| title=f"{player} Percentiles vs Same Position and Competition", | |
| ) | |
| fig.update_traces(textposition="outside", cliponaxis=False) | |
| fig.update_layout( | |
| height=max(550, 32 * len(plot_df)), | |
| margin=dict(l=190, r=100, t=90, b=80), | |
| xaxis_title="Percentile", | |
| yaxis_title="", | |
| title=dict(x=0.5, xanchor="center"), | |
| ) | |
| return fig | |
| def performance_chart(player, metric): | |
| main_row = get_player_row(player) | |
| multi_row = get_multiseason_row_for_player(player) | |
| if main_row is None or not metric: | |
| fig = go.Figure() | |
| fig.update_layout( | |
| title="Select a player and metric.", | |
| height=500, | |
| margin=dict(l=80, r=80, t=90, b=80), | |
| ) | |
| return fig | |
| label_to_metric = {pretty_label(m): m for m in PERFORMANCE_TIME_METRICS} | |
| if metric not in PERFORMANCE_TIME_METRICS and metric in label_to_metric: | |
| metric = label_to_metric[metric] | |
| rows = [] | |
| for season_code, season_label in HISTORICAL_SEASONS.items(): | |
| value = np.nan | |
| if multi_row is not None: | |
| value = find_metric_value(multi_row, metric, season_code) | |
| if pd.isna(value): | |
| value = find_metric_value(main_row, metric, season_code) | |
| if pd.notna(value): | |
| rows.append({ | |
| "Season": season_label, | |
| "Score": float(value), | |
| }) | |
| current_value = find_metric_value(main_row, metric, season_code=None) | |
| if pd.notna(current_value): | |
| rows = [r for r in rows if r["Season"] != CURRENT_MAIN_SEASON_LABEL] | |
| rows.append({ | |
| "Season": CURRENT_MAIN_SEASON_LABEL, | |
| "Score": float(current_value), | |
| }) | |
| plot_df = pd.DataFrame(rows) | |
| if plot_df.empty: | |
| fig = go.Figure() | |
| fig.update_layout( | |
| title=f"No performance data found for {player}: {pretty_label(metric)}.", | |
| height=500, | |
| margin=dict(l=80, r=80, t=90, b=80), | |
| ) | |
| return fig | |
| season_order = ["2021-22", "2022-23", "2023-24", "2024-25", "2025-26"] | |
| plot_df["Season"] = pd.Categorical( | |
| plot_df["Season"], | |
| categories=season_order, | |
| ordered=True | |
| ) | |
| plot_df = plot_df.sort_values("Season") | |
| fig = px.line( | |
| plot_df, | |
| x="Season", | |
| y="Score", | |
| markers=True, | |
| title=f"{player}: {pretty_label(metric)} Over Time", | |
| ) | |
| fig.update_traces( | |
| mode="lines+markers+text", | |
| text=plot_df["Score"].round(2), | |
| textposition="top center", | |
| ) | |
| y_min = plot_df["Score"].min() | |
| y_max = plot_df["Score"].max() | |
| if y_min == y_max: | |
| y_buffer = max(abs(y_max) * 0.25, 1) | |
| else: | |
| y_buffer = (y_max - y_min) * 0.20 | |
| fig.update_layout( | |
| height=500, | |
| margin=dict(l=80, r=80, t=90, b=80), | |
| title=dict(x=0.5, xanchor="center"), | |
| yaxis_title=pretty_label(metric), | |
| xaxis_title="Season", | |
| yaxis=dict(range=[y_min - y_buffer, y_max + y_buffer]), | |
| ) | |
| return fig | |
| # ============================================================ | |
| # PDF-SAFE CHARTS | |
| # ============================================================ | |
| def make_pdf_radar_png(player, filename): | |
| row = get_player_row(player) | |
| if row is None: | |
| return None | |
| metrics = top_available_attr_cols(row, max_cols=8) | |
| if len(metrics) < 3: | |
| return None | |
| group = get_player_group(row) | |
| labels = [pretty_label(m) for m in metrics] | |
| player_values = [ | |
| float(row.get(m, 0)) if pd.notna(row.get(m, np.nan)) else 0 | |
| for m in metrics | |
| ] | |
| avg_values = [ | |
| float(group[m].mean()) if m in group.columns and pd.notna(group[m].mean()) else 0 | |
| for m in metrics | |
| ] | |
| angles = np.linspace(0, 2 * np.pi, len(metrics), endpoint=False).tolist() | |
| player_values += player_values[:1] | |
| avg_values += avg_values[:1] | |
| angles += angles[:1] | |
| labels += labels[:1] | |
| fig = plt.figure(figsize=(8, 8)) | |
| ax = plt.subplot(111, polar=True) | |
| ax.plot(angles, player_values, linewidth=2, label=str(player)) | |
| ax.fill(angles, player_values, alpha=0.20) | |
| ax.plot(angles, avg_values, linewidth=2, linestyle="--", label="Position/Competition Avg") | |
| ax.fill(angles, avg_values, alpha=0.10) | |
| ax.set_xticks(angles[:-1]) | |
| ax.set_xticklabels(labels[:-1], fontsize=9) | |
| ax.set_ylim(0, max(100, max(player_values + avg_values) * 1.1)) | |
| ax.set_title(f"{player} Attribute Radar", pad=25, fontsize=14, fontweight="bold") | |
| ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.08), ncol=2) | |
| plt.tight_layout() | |
| plt.savefig(filename, dpi=200, bbox_inches="tight") | |
| plt.close(fig) | |
| return filename | |
| def make_pdf_percentile_png(player, filename): | |
| row = get_player_row(player) | |
| if row is None: | |
| return None | |
| group = get_player_group(row) | |
| rows = [] | |
| for metric in available_cols(PERCENTILE_METRICS): | |
| value = row.get(metric, np.nan) | |
| values = pd.to_numeric(group[metric], errors="coerce").dropna() | |
| if pd.notna(value) and len(values) > 1: | |
| percentile = (values < value).mean() * 100 | |
| rows.append({ | |
| "Metric": pretty_label(metric), | |
| "Percentile": round(percentile, 1), | |
| }) | |
| plot_df = pd.DataFrame(rows) | |
| if plot_df.empty: | |
| return None | |
| plot_df = plot_df.sort_values("Percentile").tail(14) | |
| fig, ax = plt.subplots(figsize=(9, 7)) | |
| ax.barh(plot_df["Metric"], plot_df["Percentile"]) | |
| for i, value in enumerate(plot_df["Percentile"]): | |
| ax.text(value + 1, i, f"{value:.1f}%", va="center", fontsize=9) | |
| ax.set_xlim(0, 105) | |
| ax.set_xlabel("Percentile") | |
| ax.set_title(f"{player} Percentiles", fontsize=14, fontweight="bold") | |
| ax.grid(axis="x", alpha=0.25) | |
| plt.tight_layout() | |
| plt.savefig(filename, dpi=200, bbox_inches="tight") | |
| plt.close(fig) | |
| return filename | |
| # ============================================================ | |
| # PLAYER COMPARISON | |
| # ============================================================ | |
| def compare_players(player_1, player_2, player_3): | |
| players = [p for p in [player_1, player_2, player_3] if p] | |
| if not players: | |
| empty = pd.DataFrame({"Message": ["Select at least one player."]}) | |
| return empty, empty | |
| data = df[df[PLAYER_COL].astype(str).isin(players)].copy() | |
| cols = available_cols(COMPARISON_COLS) | |
| out = data[cols].copy() | |
| if out.empty: | |
| empty = pd.DataFrame({"Message": ["No comparison data found."]}) | |
| return empty, empty | |
| out = pretty_df(out).reset_index(drop=True) | |
| return out, out | |
| def comparison_radar(player_1, player_2, player_3): | |
| players = [p for p in [player_1, player_2, player_3] if p] | |
| fig = go.Figure() | |
| if not players: | |
| fig.update_layout( | |
| title="Select players to compare.", | |
| height=650, | |
| margin=dict(l=140, r=140, t=100, b=100), | |
| ) | |
| return fig | |
| first_row = get_player_row(players[0]) | |
| if first_row is None: | |
| return fig | |
| metrics = top_available_attr_cols(first_row, max_cols=8) | |
| if len(metrics) < 3: | |
| fig.update_layout( | |
| title="Not enough attributes available for radar chart.", | |
| height=650, | |
| margin=dict(l=140, r=140, t=100, b=100), | |
| ) | |
| return fig | |
| labels = [pretty_label(m) for m in metrics] | |
| max_value = 100 | |
| for player in players: | |
| row = get_player_row(player) | |
| if row is not None: | |
| values = [row.get(m, 0) if pd.notna(row.get(m, np.nan)) else 0 for m in metrics] | |
| max_value = max(max_value, np.nanmax(values)) | |
| fig.add_trace(go.Scatterpolar( | |
| r=values, | |
| theta=labels, | |
| fill="toself", | |
| name=str(player), | |
| )) | |
| fig.update_layout( | |
| title=dict( | |
| text="Player Attribute Radar Comparison", | |
| x=0.5, | |
| xanchor="center", | |
| ), | |
| polar=dict( | |
| domain=dict(x=[0.16, 0.84], y=[0.12, 0.88]), | |
| radialaxis=dict( | |
| visible=True, | |
| range=[0, max(100, max_value * 1.1)], | |
| ), | |
| ), | |
| height=700, | |
| margin=dict(l=140, r=140, t=110, b=110), | |
| showlegend=True, | |
| legend=dict(orientation="h", y=-0.08, x=0.5, xanchor="center"), | |
| ) | |
| return fig | |
| # ============================================================ | |
| # FIT SCORE CALCULATOR | |
| # ============================================================ | |
| def fit_score( | |
| competitions, | |
| positions, | |
| pressing_w, | |
| duels_w, | |
| aerial_w, | |
| possession_w, | |
| blocking_w, | |
| progression_w, | |
| impact_w, | |
| discipline_w, | |
| dribbling_w, | |
| chance_w, | |
| finishing_w, | |
| crossing_w, | |
| box_w, | |
| holdup_w, | |
| target_w, | |
| attain_w, | |
| ): | |
| data = df.copy() | |
| if competitions and COMP_COL in data.columns: | |
| data = data[data[COMP_COL].astype(str).isin([str(x) for x in competitions])] | |
| if positions and POSITION_COL in data.columns: | |
| data = data[data[POSITION_COL].astype(str).isin([str(x) for x in positions])] | |
| if data.empty: | |
| empty = pd.DataFrame({"Message": ["No players found for selected competitions/positions."]}) | |
| return empty, empty | |
| weights = { | |
| "attr_pressing": pressing_w, | |
| "attr_duels": duels_w, | |
| "attr_aerial": aerial_w, | |
| "attr_possession_retention": possession_w, | |
| "attr_blocking": blocking_w, | |
| "attr_progression": progression_w, | |
| "attr_impact": impact_w, | |
| "attr_discipline": discipline_w, | |
| "attr_dribbling": dribbling_w, | |
| "attr_chance_creation": chance_w, | |
| "attr_finishing": finishing_w, | |
| "attr_crossing": crossing_w, | |
| "attr_box_presence": box_w, | |
| "attr_holdup": holdup_w, | |
| TARGET_SCORE_COL: target_w, | |
| ATTAINABILITY_COL: attain_w, | |
| } | |
| total_weight = sum(weights.values()) | |
| if total_weight == 0: | |
| empty = pd.DataFrame({"Message": ["At least one scouting weight must be above 0."]}) | |
| return empty, empty | |
| fit_score_values = pd.Series(np.zeros(len(data)), index=data.index) | |
| for col, weight in weights.items(): | |
| if col in data.columns and weight > 0: | |
| fit_score_values += normalize_0_100(data[col]).fillna(0) * weight | |
| data["fit_score"] = fit_score_values / total_weight | |
| cols = available_cols([ | |
| PLAYER_COL, | |
| POSITION_COL, | |
| TEAM_COL, | |
| COMP_COL, | |
| AGE_COL, | |
| MINUTES_COL, | |
| MARKET_VALUE_COL, | |
| CONTRACT_COL, | |
| ARCHETYPE_COL, | |
| ARCHETYPE_SCORE_COL, | |
| TARGET_SCORE_COL, | |
| ATTAINABILITY_COL, | |
| "attr_pressing", | |
| "attr_duels", | |
| "attr_aerial", | |
| "attr_possession_retention", | |
| "attr_blocking", | |
| "attr_progression", | |
| "attr_impact", | |
| "attr_discipline", | |
| "attr_dribbling", | |
| "attr_chance_creation", | |
| "attr_finishing", | |
| "attr_crossing", | |
| "attr_box_presence", | |
| "attr_holdup", | |
| ]) + ["fit_score"] | |
| out = data[cols].sort_values("fit_score", ascending=False).head(50).copy() | |
| out = pretty_df(out).reset_index(drop=True) | |
| return out, out | |
| # ============================================================ | |
| # SIMILAR PLAYER FINDER | |
| # ============================================================ | |
| def similar_players(player): | |
| row = get_player_row(player) | |
| if row is None: | |
| empty = pd.DataFrame({"Message": ["Select a player."]}) | |
| return empty, empty | |
| metrics = [] | |
| for metric in available_cols(SIMILARITY_METRICS): | |
| if pd.notna(row.get(metric, np.nan)): | |
| metrics.append(metric) | |
| metrics = metrics[:24] | |
| if not metrics: | |
| empty = pd.DataFrame({"Message": ["No similarity metrics available."]}) | |
| return empty, empty | |
| pos = row.get(POSITION_COL, None) | |
| if POSITION_COL in df.columns and pd.notna(pos): | |
| candidates = df[ | |
| (df[PLAYER_COL].astype(str) != str(player)) & | |
| (df[POSITION_COL] == pos) | |
| ].copy() | |
| else: | |
| candidates = df[df[PLAYER_COL].astype(str) != str(player)].copy() | |
| if candidates.empty: | |
| candidates = df[df[PLAYER_COL].astype(str) != str(player)].copy() | |
| for metric in metrics: | |
| values = pd.to_numeric(df[metric], errors="coerce") | |
| sd = values.std() | |
| if pd.isna(sd) or sd == 0: | |
| candidates[f"dist_{metric}"] = 0 | |
| else: | |
| candidates[f"dist_{metric}"] = ((pd.to_numeric(candidates[metric], errors="coerce") - row[metric]) / sd) ** 2 | |
| dist_cols = [f"dist_{metric}" for metric in metrics] | |
| candidates["Similarity Distance"] = candidates[dist_cols].sum(axis=1) | |
| candidates["Similarity Score"] = 100 / (1 + candidates["Similarity Distance"]) | |
| cols = available_cols([ | |
| PLAYER_COL, | |
| TEAM_COL, | |
| COMP_COL, | |
| POSITION_COL, | |
| AGE_COL, | |
| MARKET_VALUE_COL, | |
| ARCHETYPE_COL, | |
| ARCHETYPE_SCORE_COL, | |
| TARGET_SCORE_COL, | |
| ATTAINABILITY_COL, | |
| ]) + ["Similarity Score"] | |
| out = candidates[cols].sort_values("Similarity Score", ascending=False).head(10).copy() | |
| out = pretty_df(out).reset_index(drop=True) | |
| return out, out | |
| # ============================================================ | |
| # SHORTLIST | |
| # ============================================================ | |
| def add_to_shortlist(player): | |
| global shortlist | |
| if player and player not in shortlist: | |
| shortlist.append(player) | |
| return view_shortlist() | |
| def clear_shortlist(): | |
| global shortlist | |
| shortlist = [] | |
| return view_shortlist() | |
| def view_shortlist(): | |
| if not shortlist: | |
| return pd.DataFrame({"Message": ["No players added to shortlist yet."]}) | |
| data = df[df[PLAYER_COL].astype(str).isin(shortlist)].copy() | |
| cols = available_cols(SHORTLIST_COLS) | |
| out = data[cols].copy() | |
| if out.empty: | |
| return pd.DataFrame({"Message": ["Shortlist is empty or columns were not found."]}) | |
| out = pretty_df(out).reset_index(drop=True) | |
| return out | |
| def export_shortlist_csv(): | |
| if not shortlist: | |
| return None | |
| data = df[df[PLAYER_COL].astype(str).isin(shortlist)].copy() | |
| cols = available_cols(SHORTLIST_COLS) | |
| out = data[cols].copy() | |
| if MARKET_VALUE_COL in out.columns: | |
| out[MARKET_VALUE_COL] = out[MARKET_VALUE_COL].apply(format_money) | |
| numeric_cols = out.select_dtypes(include=np.number).columns | |
| out[numeric_cols] = out[numeric_cols].round(2) | |
| out = out.rename(columns={c: pretty_label(c) for c in out.columns}) | |
| out_file = "shortlist_export.csv" | |
| out.to_csv(out_file, index=False) | |
| return out_file | |
| # ============================================================ | |
| # PDF REPORT EXPORT | |
| # ============================================================ | |
| def export_player_report(player, notes): | |
| row = get_player_row(player) | |
| if row is None: | |
| return None | |
| safe_name = re.sub(r"[^A-Za-z0-9_]+", "_", str(row.get(PLAYER_COL, "player"))) | |
| out_file = f"{safe_name}_scouting_report.pdf" | |
| pdf = FPDF() | |
| pdf.set_auto_page_break(auto=True, margin=15) | |
| pdf.add_page() | |
| pdf.set_font("Arial", "B", 18) | |
| pdf.cell(0, 10, safe_pdf_text("Player Scouting Report"), ln=True) | |
| pdf.set_font("Arial", "B", 15) | |
| pdf.cell(0, 9, safe_pdf_text(row.get(PLAYER_COL, "Unknown Player")), ln=True) | |
| pdf.set_font("Arial", "", 10) | |
| pdf.cell(0, 7, safe_pdf_text(f"Club: {row.get(TEAM_COL, 'N/A')}"), ln=True) | |
| pdf.cell(0, 7, safe_pdf_text(f"Competition: {row.get(COMP_COL, 'N/A')}"), ln=True) | |
| pdf.cell(0, 7, safe_pdf_text(f"Position: {row.get(POSITION_COL, 'N/A')}"), ln=True) | |
| pdf.cell(0, 7, safe_pdf_text(f"Age: {clean_value(row.get(AGE_COL, np.nan))}"), ln=True) | |
| pdf.cell(0, 7, safe_pdf_text(f"Country: {clean_value(row.get(COUNTRY_COL, np.nan))}"), ln=True) | |
| pdf.cell(0, 7, safe_pdf_text(f"Height: {clean_value(row.get(HEIGHT_COL, np.nan))} cm"), ln=True) | |
| pdf.cell(0, 7, safe_pdf_text(f"Market Value: {format_money(row.get(MARKET_VALUE_COL, np.nan))}"), ln=True) | |
| pdf.cell(0, 7, safe_pdf_text(f"Contract: {clean_value(row.get(CONTRACT_COL, np.nan))}"), ln=True) | |
| pdf.cell(0, 7, safe_pdf_text(f"Minutes: {clean_value(row.get(MINUTES_COL, np.nan))}"), ln=True) | |
| pdf.ln(3) | |
| pdf.set_font("Arial", "B", 12) | |
| pdf.cell(0, 8, safe_pdf_text("Scoring Summary"), ln=True) | |
| pdf.set_font("Arial", "", 10) | |
| scoring_lines = [ | |
| ("Best Archetype", row.get(ARCHETYPE_COL, "N/A")), | |
| ("Best Archetype Score", clean_value(row.get(ARCHETYPE_SCORE_COL, np.nan))), | |
| ("Target Score", clean_value(row.get(TARGET_SCORE_COL, np.nan))), | |
| ("Attainability", clean_value(row.get(ATTAINABILITY_COL, np.nan))), | |
| ("Club Rank", clean_value(row.get(CLUB_RANK_COL, np.nan))), | |
| ("Match Toughness", clean_value(row.get(MATCH_TOUGHNESS_COL, np.nan))), | |
| ("Club ELO", clean_value(row.get(ELO_COL, np.nan))), | |
| ] | |
| for label, value in scoring_lines: | |
| pdf.cell(0, 6, safe_pdf_text(f"{label}: {value}"), ln=True) | |
| pdf.ln(3) | |
| pdf.set_font("Arial", "B", 12) | |
| pdf.cell(0, 8, safe_pdf_text("Top Attribute Scores"), ln=True) | |
| pdf.set_font("Arial", "", 10) | |
| attr_rows = [] | |
| for col in available_cols(ATTR_COLS): | |
| value = row.get(col, np.nan) | |
| if pd.notna(value): | |
| attr_rows.append((pretty_label(col), float(value))) | |
| attr_rows = sorted(attr_rows, key=lambda x: x[1], reverse=True) | |
| for label, value in attr_rows[:20]: | |
| pdf.cell(0, 6, safe_pdf_text(f"{label}: {round(value, 2)}"), ln=True) | |
| pdf.ln(3) | |
| pdf.set_font("Arial", "B", 12) | |
| pdf.cell(0, 8, safe_pdf_text("Key Season Metrics"), ln=True) | |
| pdf.set_font("Arial", "", 10) | |
| for col in available_cols(KEY_METRICS): | |
| value = row.get(col, np.nan) | |
| if pd.notna(value): | |
| pdf.cell(0, 6, safe_pdf_text(f"{pretty_label(col)}: {round(float(value), 2)}"), ln=True) | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| radar_file = os.path.join(tmpdir, "radar.png") | |
| pct_file = os.path.join(tmpdir, "percentile.png") | |
| radar_saved = make_pdf_radar_png(player, radar_file) | |
| pct_saved = make_pdf_percentile_png(player, pct_file) | |
| if radar_saved: | |
| pdf.add_page() | |
| pdf.set_font("Arial", "B", 13) | |
| pdf.cell(0, 8, safe_pdf_text("Attribute Radar"), ln=True) | |
| pdf.image(radar_saved, x=15, y=25, w=180) | |
| if pct_saved: | |
| pdf.add_page() | |
| pdf.set_font("Arial", "B", 13) | |
| pdf.cell(0, 8, safe_pdf_text("Percentile Bars"), ln=True) | |
| pdf.image(pct_saved, x=12, y=25, w=185) | |
| pdf.add_page() | |
| pdf.set_font("Arial", "B", 13) | |
| pdf.cell(0, 8, safe_pdf_text("Scout Notes"), ln=True) | |
| pdf.set_font("Arial", "", 10) | |
| pdf.multi_cell(0, 6, safe_pdf_text(notes if notes else "No notes entered.")) | |
| pdf.output(out_file) | |
| return out_file | |
| # ============================================================ | |
| # APP LAYOUT | |
| # ============================================================ | |
| with gr.Blocks(title="Oldham Athletic Player Scouting", css=CUSTOM_CSS) as app: | |
| gr.Markdown( | |
| """ | |
| # Oldham Athletic Player Scouting | |
| Search, filter, compare, shortlist, and generate scouting reports for players in the database. | |
| """ | |
| ) | |
| search_state = gr.State() | |
| comparison_state = gr.State() | |
| fit_state = gr.State() | |
| similar_state = gr.State() | |
| with gr.Tabs(): | |
| with gr.Tab("Player Search"): | |
| gr.Markdown("## Search and Filter Players") | |
| with gr.Row(): | |
| search_box = gr.Textbox(label="Search Player Name") | |
| competition_filter = gr.Dropdown( | |
| choices=competition_options, | |
| value=[], | |
| label="Competition", | |
| multiselect=True, | |
| ) | |
| team_filter = gr.Dropdown( | |
| choices=team_options, | |
| value=[], | |
| label="Team", | |
| multiselect=True, | |
| ) | |
| with gr.Row(): | |
| position_filter = gr.Dropdown( | |
| choices=position_options, | |
| value=[], | |
| label="Position", | |
| multiselect=True, | |
| ) | |
| country_filter = gr.Dropdown( | |
| choices=country_options, | |
| value=[], | |
| label="Country", | |
| multiselect=True, | |
| ) | |
| with gr.Row(): | |
| min_age_filter = gr.Slider( | |
| minimum=age_min, | |
| maximum=age_max, | |
| value=age_min, | |
| step=1, | |
| label="Minimum Age", | |
| ) | |
| max_age_filter = gr.Slider( | |
| minimum=age_min, | |
| maximum=age_max, | |
| value=age_max, | |
| step=1, | |
| label="Maximum Age", | |
| ) | |
| minutes_filter = gr.Slider( | |
| minimum=0, | |
| maximum=minutes_max, | |
| value=0, | |
| step=100, | |
| label="Minimum Minutes", | |
| ) | |
| search_button = gr.Button("Search Players") | |
| search_results = gr.Dataframe( | |
| label="Player Results", | |
| interactive=False, | |
| ) | |
| search_status = gr.Markdown("Click a player row to load them into the Player Profile tab.") | |
| with gr.Tab("Player Profile"): | |
| gr.Markdown("## Full Player Profile") | |
| selected_player = gr.Dropdown( | |
| choices=player_dropdown_choices, | |
| label="Select Player" | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| profile_output = gr.Markdown() | |
| with gr.Column(scale=1): | |
| gr.Markdown("### Key Performance Summary") | |
| key_summary_output = gr.Dataframe( | |
| label="", | |
| interactive=False, | |
| ) | |
| gr.Markdown("## Player Metrics") | |
| metric_group_dropdown = gr.Dropdown( | |
| choices=[ | |
| "Attributes", | |
| "Position Scores", | |
| "Archetype Scores", | |
| "Key Season Stats" | |
| ], | |
| value="Attributes", | |
| label="Metric Group" | |
| ) | |
| metric_table_output = gr.Dataframe( | |
| label="Metric Breakdown", | |
| interactive=False, | |
| ) | |
| with gr.Row(): | |
| radar_output = gr.Plot(label="Attribute Radar") | |
| percentile_output = gr.Plot(label="Percentile Bars") | |
| with gr.Row(): | |
| profile_metric = gr.Dropdown( | |
| choices=performance_metric_options, | |
| value=performance_metric_options[0][1] if performance_metric_options else None, | |
| label="Performance Metric", | |
| ) | |
| trend_button = gr.Button("Show Performance Chart") | |
| trend_output = gr.Plot(label="Performance Over Time") | |
| scout_notes = gr.Textbox( | |
| label="Scout Notes", | |
| lines=5, | |
| placeholder="Enter notes to include in the scouting report.", | |
| ) | |
| with gr.Row(): | |
| report_button = gr.Button("Generate Scouting Report PDF") | |
| shortlist_button = gr.Button("Add Player to Shortlist") | |
| report_file = gr.File(label="Download Scouting Report") | |
| shortlist_from_profile = gr.Dataframe(label="Current Shortlist", interactive=False) | |
| with gr.Tab("Player Comparison Tool"): | |
| gr.Markdown("## Compare Up To Three Players") | |
| with gr.Row(): | |
| compare_1 = gr.Dropdown(choices=player_dropdown_choices, label="Player 1") | |
| compare_2 = gr.Dropdown(choices=player_dropdown_choices, label="Player 2") | |
| compare_3 = gr.Dropdown(choices=player_dropdown_choices, label="Player 3") | |
| compare_button = gr.Button("Compare Players") | |
| comparison_table = gr.Dataframe( | |
| label="Comparison Table", | |
| interactive=False, | |
| ) | |
| comparison_status = gr.Markdown("Click a player row to load them into the Player Profile tab.") | |
| comparison_radar_plot = gr.Plot(label="Attribute Radar Comparison") | |
| with gr.Tab("Fit Score Calculator"): | |
| gr.Markdown( | |
| """ | |
| ## Fit Score Calculator | |
| Select the competitions and positions you want to search, then adjust the trait weights to generate a ranked recommendation list. | |
| """ | |
| ) | |
| with gr.Row(): | |
| fit_competition_filter = gr.Dropdown( | |
| choices=competition_options, | |
| value=[], | |
| label="Competitions to Search", | |
| multiselect=True, | |
| ) | |
| fit_position_filter = gr.Dropdown( | |
| choices=position_options, | |
| value=[], | |
| label="Positions to Search", | |
| multiselect=True, | |
| ) | |
| with gr.Row(): | |
| pressing_w = gr.Slider(0, 10, value=5, step=1, label="Pressing") | |
| duels_w = gr.Slider(0, 10, value=5, step=1, label="Duels") | |
| aerial_w = gr.Slider(0, 10, value=4, step=1, label="Aerial") | |
| with gr.Row(): | |
| possession_w = gr.Slider(0, 10, value=5, step=1, label="Possession Retention") | |
| blocking_w = gr.Slider(0, 10, value=4, step=1, label="Blocking") | |
| progression_w = gr.Slider(0, 10, value=6, step=1, label="Progression") | |
| with gr.Row(): | |
| impact_w = gr.Slider(0, 10, value=6, step=1, label="Impact") | |
| discipline_w = gr.Slider(0, 10, value=3, step=1, label="Discipline") | |
| dribbling_w = gr.Slider(0, 10, value=4, step=1, label="Dribbling") | |
| with gr.Row(): | |
| chance_w = gr.Slider(0, 10, value=5, step=1, label="Chance Creation") | |
| finishing_w = gr.Slider(0, 10, value=3, step=1, label="Finishing") | |
| crossing_w = gr.Slider(0, 10, value=3, step=1, label="Crossing") | |
| with gr.Row(): | |
| box_w = gr.Slider(0, 10, value=3, step=1, label="Box Presence") | |
| holdup_w = gr.Slider(0, 10, value=3, step=1, label="Holdup") | |
| target_w = gr.Slider(0, 10, value=7, step=1, label="Target Score") | |
| with gr.Row(): | |
| attain_w = gr.Slider(0, 10, value=6, step=1, label="Attainability") | |
| fit_button = gr.Button("Generate Ranked Recommendations") | |
| fit_table = gr.Dataframe( | |
| label="Fit Score Recommendations", | |
| interactive=False, | |
| ) | |
| fit_status = gr.Markdown("Click a player row to load them into the Player Profile tab.") | |
| with gr.Tab("Similar Player Finder"): | |
| gr.Markdown("## Find Similar Players") | |
| similar_player_select = gr.Dropdown(choices=player_dropdown_choices, label="Select Player") | |
| similar_button = gr.Button("Find Similar Players") | |
| similar_table = gr.Dataframe( | |
| label="Similar Players", | |
| interactive=False, | |
| ) | |
| similar_status = gr.Markdown("Click a player row to load them into the Player Profile tab.") | |
| with gr.Tab("Shortlist Manager"): | |
| gr.Markdown("## Shortlist Manager") | |
| with gr.Row(): | |
| shortlist_player = gr.Dropdown(choices=player_dropdown_choices, label="Add Player") | |
| add_shortlist_button = gr.Button("Add to Shortlist") | |
| clear_shortlist_button = gr.Button("Clear Shortlist") | |
| export_shortlist_button = gr.Button("Export Shortlist CSV") | |
| shortlist_table = gr.Dataframe( | |
| label="Saved Players", | |
| interactive=False, | |
| ) | |
| shortlist_file = gr.File(label="Download Shortlist CSV") | |
| # ======================================================== | |
| # EVENTS | |
| # ======================================================== | |
| search_button.click( | |
| fn=search_players, | |
| inputs=[ | |
| search_box, | |
| competition_filter, | |
| team_filter, | |
| position_filter, | |
| country_filter, | |
| min_age_filter, | |
| max_age_filter, | |
| minutes_filter, | |
| ], | |
| outputs=[search_results, search_state], | |
| ) | |
| selected_player.change(player_profile, selected_player, profile_output) | |
| selected_player.change(key_performance_summary, selected_player, key_summary_output) | |
| selected_player.change( | |
| profile_metric_dropdown_table, | |
| [selected_player, metric_group_dropdown], | |
| metric_table_output | |
| ) | |
| metric_group_dropdown.change( | |
| profile_metric_dropdown_table, | |
| [selected_player, metric_group_dropdown], | |
| metric_table_output | |
| ) | |
| selected_player.change(radar_chart, selected_player, radar_output) | |
| selected_player.change(percentile_chart, selected_player, percentile_output) | |
| trend_button.click(performance_chart, [selected_player, profile_metric], trend_output) | |
| report_button.click(export_player_report, [selected_player, scout_notes], report_file) | |
| shortlist_button.click(add_to_shortlist, selected_player, shortlist_from_profile) | |
| search_results.select( | |
| fn=selected_player_from_table, | |
| inputs=search_state, | |
| outputs=selected_player, | |
| ).then( | |
| fn=selected_player_status, | |
| inputs=selected_player, | |
| outputs=search_status, | |
| ) | |
| compare_button.click( | |
| fn=compare_players, | |
| inputs=[compare_1, compare_2, compare_3], | |
| outputs=[comparison_table, comparison_state], | |
| ) | |
| compare_button.click( | |
| fn=comparison_radar, | |
| inputs=[compare_1, compare_2, compare_3], | |
| outputs=comparison_radar_plot, | |
| ) | |
| comparison_table.select( | |
| fn=selected_player_from_table, | |
| inputs=comparison_state, | |
| outputs=selected_player, | |
| ).then( | |
| fn=selected_player_status, | |
| inputs=selected_player, | |
| outputs=comparison_status, | |
| ) | |
| fit_button.click( | |
| fn=fit_score, | |
| inputs=[ | |
| fit_competition_filter, | |
| fit_position_filter, | |
| pressing_w, | |
| duels_w, | |
| aerial_w, | |
| possession_w, | |
| blocking_w, | |
| progression_w, | |
| impact_w, | |
| discipline_w, | |
| dribbling_w, | |
| chance_w, | |
| finishing_w, | |
| crossing_w, | |
| box_w, | |
| holdup_w, | |
| target_w, | |
| attain_w, | |
| ], | |
| outputs=[fit_table, fit_state], | |
| ) | |
| fit_table.select( | |
| fn=selected_player_from_table, | |
| inputs=fit_state, | |
| outputs=selected_player, | |
| ).then( | |
| fn=selected_player_status, | |
| inputs=selected_player, | |
| outputs=fit_status, | |
| ) | |
| similar_button.click( | |
| fn=similar_players, | |
| inputs=similar_player_select, | |
| outputs=[similar_table, similar_state], | |
| ) | |
| similar_table.select( | |
| fn=selected_player_from_table, | |
| inputs=similar_state, | |
| outputs=selected_player, | |
| ).then( | |
| fn=selected_player_status, | |
| inputs=selected_player, | |
| outputs=similar_status, | |
| ) | |
| add_shortlist_button.click(add_to_shortlist, shortlist_player, shortlist_table) | |
| clear_shortlist_button.click(clear_shortlist, None, shortlist_table) | |
| export_shortlist_button.click(export_shortlist_csv, None, shortlist_file) | |
| app.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| share=True | |
| ) |