cpflecht commited on
Commit
7da5667
·
verified ·
1 Parent(s): 953464a

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +950 -0
app.py CHANGED
@@ -0,0 +1,950 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import pandas as pd
3
+ import numpy as np
4
+ import plotly.graph_objects as go
5
+ import plotly.express as px
6
+ from io import StringIO
7
+
8
+ # ============================================================
9
+ # LOAD DATA
10
+ # ============================================================
11
+
12
+ DATA_FILE = "midfielder_scores_final.csv"
13
+
14
+ df = pd.read_csv(DATA_FILE)
15
+
16
+ df.columns = (
17
+ df.columns
18
+ .str.strip()
19
+ .str.lower()
20
+ .str.replace(" ", "_")
21
+ .str.replace("-", "_")
22
+ .str.replace("/", "_")
23
+ )
24
+
25
+ # ============================================================
26
+ # COLUMN SETUP BASED ON YOUR SPREADSHEET
27
+ # ============================================================
28
+
29
+ PLAYER_COL = "player_name"
30
+ TEAM_COL = "team_name"
31
+ COMP_COL = "competition_name"
32
+ POSITION_COL = "primary_position"
33
+ AGE_COL = "age"
34
+ SEASON_COL = "season_name"
35
+
36
+ MARKET_VALUE_COL = "market_value_eur"
37
+ CONTRACT_COL = "contract_status"
38
+ HEIGHT_COL = "player_height"
39
+ MINUTES_COL = "player_season_minutes"
40
+ ATTAINABILITY_COL = "attainability"
41
+
42
+ # Main metrics shown in search/profile tables
43
+ KEY_METRICS = [
44
+ "player_season_goals_90",
45
+ "player_season_assists_90",
46
+ "player_season_np_xg_90",
47
+ "player_season_xa_90",
48
+ "player_season_passing_ratio",
49
+ "player_season_defensive_actions_90",
50
+ "player_season_pressures_90",
51
+ "player_season_key_passes_90",
52
+ "player_season_dribbles_90",
53
+ "player_season_obv_90"
54
+ ]
55
+
56
+ # Category scores for scouting profile
57
+ CATEGORY_METRICS = [
58
+ "cat_defensive_ability",
59
+ "cat_aerial_ability",
60
+ "cat_finishing",
61
+ "cat_chance_creation",
62
+ "cat_dribbling",
63
+ "cat_ball_progression",
64
+ "cat_passing",
65
+ "cat_defensive_intelligence",
66
+ "cat_pressing_work_rate",
67
+ "cat_possession_security",
68
+ "cat_goal_threat",
69
+ "cat_wide_delivery",
70
+ "cat_impact",
71
+ "cat_discipline"
72
+ ]
73
+
74
+ # Archetype/scoring system columns
75
+ SCORING_METRICS = [
76
+ "defensive_mid_score",
77
+ "deep_lying_playmaker_score",
78
+ "box_to_box_score",
79
+ "advanced_playmaker_score",
80
+ "wide_mid_score",
81
+ "attacking_runner_score",
82
+ "best_midfield_archetype",
83
+ "best_midfield_archetype_score",
84
+ "raw_score",
85
+ "attainability"
86
+ ]
87
+
88
+ RADAR_METRICS = [
89
+ "cat_defensive_ability",
90
+ "cat_chance_creation",
91
+ "cat_dribbling",
92
+ "cat_ball_progression",
93
+ "cat_passing",
94
+ "cat_defensive_intelligence",
95
+ "cat_pressing_work_rate",
96
+ "cat_possession_security",
97
+ "cat_goal_threat",
98
+ "cat_impact"
99
+ ]
100
+
101
+ PERCENTILE_METRICS = [
102
+ "player_season_goals_90",
103
+ "player_season_assists_90",
104
+ "player_season_np_xg_90",
105
+ "player_season_xa_90",
106
+ "player_season_key_passes_90",
107
+ "player_season_passing_ratio",
108
+ "player_season_defensive_actions_90",
109
+ "player_season_pressures_90",
110
+ "player_season_dribbles_90",
111
+ "player_season_obv_90",
112
+ "attainability"
113
+ ]
114
+
115
+ FIT_SCORE_METRICS = [
116
+ "cat_defensive_ability",
117
+ "cat_chance_creation",
118
+ "cat_ball_progression",
119
+ "cat_passing",
120
+ "cat_pressing_work_rate",
121
+ "cat_possession_security",
122
+ "cat_goal_threat",
123
+ "cat_impact",
124
+ "attainability"
125
+ ]
126
+
127
+ # ============================================================
128
+ # CLEANING HELPERS
129
+ # ============================================================
130
+
131
+ def available_cols(cols):
132
+ return [c for c in cols if c in df.columns]
133
+
134
+ def label_col(col):
135
+ return col.replace("player_season_", "").replace("_90", " per 90").replace("_", " ").title()
136
+
137
+ def format_money(x):
138
+ try:
139
+ if pd.isna(x):
140
+ return "Not listed"
141
+ x = float(x)
142
+ if x >= 1000000:
143
+ return f"€{x/1000000:.1f}M"
144
+ if x >= 1000:
145
+ return f"€{x/1000:.0f}K"
146
+ return f"€{x:.0f}"
147
+ except:
148
+ return "Not listed"
149
+
150
+ def safe_numeric(data, col):
151
+ if col in data.columns:
152
+ return pd.to_numeric(data[col], errors="coerce")
153
+ return pd.Series(dtype=float)
154
+
155
+ for col in available_cols(KEY_METRICS + CATEGORY_METRICS + SCORING_METRICS + PERCENTILE_METRICS + FIT_SCORE_METRICS):
156
+ if col != "best_midfield_archetype":
157
+ df[col] = pd.to_numeric(df[col], errors="coerce")
158
+
159
+ if AGE_COL in df.columns:
160
+ df[AGE_COL] = pd.to_numeric(df[AGE_COL], errors="coerce")
161
+
162
+ # ============================================================
163
+ # DROPDOWN OPTIONS
164
+ # ============================================================
165
+
166
+ player_options = sorted(df[PLAYER_COL].dropna().astype(str).unique().tolist())
167
+ competition_options = ["All"] + sorted(df[COMP_COL].dropna().astype(str).unique().tolist())
168
+ team_options = ["All"] + sorted(df[TEAM_COL].dropna().astype(str).unique().tolist())
169
+ position_options = ["All"] + sorted(df[POSITION_COL].dropna().astype(str).unique().tolist())
170
+
171
+ age_min = int(np.floor(df[AGE_COL].min())) if AGE_COL in df.columns else 15
172
+ age_max = int(np.ceil(df[AGE_COL].max())) if AGE_COL in df.columns else 45
173
+
174
+ metric_options = available_cols(KEY_METRICS + CATEGORY_METRICS + SCORING_METRICS)
175
+
176
+ shortlist = []
177
+
178
+ # ============================================================
179
+ # ============================================================
180
+ # REPLACED YOUR PLAYER SEARCH FUNCTION WITH THIS
181
+ # ============================================================
182
+
183
+ def search_players(search, competitions, teams, positions, min_age, max_age, min_minutes):
184
+ data = df.copy()
185
+
186
+ # Player name search
187
+ if search and PLAYER_COL in data.columns:
188
+ data = data[
189
+ data[PLAYER_COL]
190
+ .astype(str)
191
+ .str.contains(str(search), case=False, na=False)
192
+ ]
193
+
194
+ # Multi-select competition filter
195
+ if competitions and COMP_COL in data.columns:
196
+ data = data[data[COMP_COL].astype(str).isin(competitions)]
197
+
198
+ # Multi-select team filter
199
+ if teams and TEAM_COL in data.columns:
200
+ data = data[data[TEAM_COL].astype(str).isin(teams)]
201
+
202
+ # Multi-select position filter
203
+ if positions and POSITION_COL in data.columns:
204
+ data = data[data[POSITION_COL].astype(str).isin(positions)]
205
+
206
+ # Age filters
207
+ if AGE_COL in data.columns:
208
+ data[AGE_COL] = pd.to_numeric(data[AGE_COL], errors="coerce")
209
+ data = data[
210
+ (data[AGE_COL] >= min_age) &
211
+ (data[AGE_COL] <= max_age)
212
+ ]
213
+
214
+ # Minimum minutes filter
215
+ if MINUTES_COL in data.columns:
216
+ data[MINUTES_COL] = pd.to_numeric(data[MINUTES_COL], errors="coerce")
217
+ data = data[data[MINUTES_COL].fillna(0) >= min_minutes]
218
+
219
+ table_cols = available_cols([
220
+ PLAYER_COL,
221
+ TEAM_COL,
222
+ COMP_COL,
223
+ POSITION_COL,
224
+ AGE_COL,
225
+ MINUTES_COL,
226
+ MARKET_VALUE_COL,
227
+ CONTRACT_COL,
228
+ "best_midfield_archetype",
229
+ "best_midfield_archetype_score",
230
+ "raw_score",
231
+ ATTAINABILITY_COL
232
+ ] + KEY_METRICS)
233
+
234
+ out = data[table_cols].copy()
235
+
236
+ if out.empty:
237
+ return pd.DataFrame({"Message": ["No players found. Try clearing some filters."]})
238
+
239
+ if MARKET_VALUE_COL in out.columns:
240
+ out[MARKET_VALUE_COL] = out[MARKET_VALUE_COL].apply(format_money)
241
+
242
+ if AGE_COL in out.columns:
243
+ out[AGE_COL] = pd.to_numeric(out[AGE_COL], errors="coerce").round(1)
244
+
245
+ numeric_cols = out.select_dtypes(include=np.number).columns
246
+ out[numeric_cols] = out[numeric_cols].round(2)
247
+
248
+ if ATTAINABILITY_COL in out.columns:
249
+ out = out.sort_values(by=ATTAINABILITY_COL, ascending=False)
250
+
251
+ return out.reset_index(drop=True)
252
+
253
+ if MARKET_VALUE_COL in out.columns:
254
+ out[MARKET_VALUE_COL] = out[MARKET_VALUE_COL].apply(format_money)
255
+
256
+ if AGE_COL in out.columns:
257
+ out[AGE_COL] = out[AGE_COL].round(1)
258
+
259
+ numeric_cols = out.select_dtypes(include=np.number).columns
260
+ out[numeric_cols] = out[numeric_cols].round(2)
261
+
262
+ return out.sort_values(by=ATTAINABILITY_COL, ascending=False).reset_index(drop=True)
263
+
264
+ # ============================================================
265
+ # PLAYER PROFILE
266
+ # ============================================================
267
+
268
+ def get_player_row(player):
269
+ rows = df[df[PLAYER_COL].astype(str) == str(player)]
270
+ if rows.empty:
271
+ return None
272
+ return rows.iloc[0]
273
+
274
+ def player_profile(player):
275
+ row = get_player_row(player)
276
+
277
+ if row is None:
278
+ return "Select a player to view their profile."
279
+
280
+ lines = []
281
+ lines.append(f"# {row[PLAYER_COL]}")
282
+ lines.append(f"### {row.get(TEAM_COL, 'N/A')} | {row.get(COMP_COL, 'N/A')}")
283
+ lines.append("")
284
+ lines.append("## Player Details")
285
+ lines.append(f"- **Position:** {row.get(POSITION_COL, 'N/A')}")
286
+ lines.append(f"- **Age:** {round(row.get(AGE_COL, np.nan), 1) if pd.notna(row.get(AGE_COL, np.nan)) else 'N/A'}")
287
+ lines.append(f"- **Height:** {row.get(HEIGHT_COL, 'N/A')}")
288
+ lines.append(f"- **Market Value:** {format_money(row.get(MARKET_VALUE_COL, np.nan))}")
289
+ lines.append(f"- **Contract Status:** {row.get(CONTRACT_COL, 'N/A')}")
290
+ lines.append(f"- **Minutes:** {round(row.get(MINUTES_COL, 0), 0)}")
291
+ lines.append("")
292
+ lines.append("## Scoring System")
293
+ lines.append(f"- **Best Midfield Archetype:** {row.get('best_midfield_archetype', 'N/A')}")
294
+ lines.append(f"- **Best Archetype Score:** {round(row.get('best_midfield_archetype_score', np.nan), 2) if pd.notna(row.get('best_midfield_archetype_score', np.nan)) else 'N/A'}")
295
+ lines.append(f"- **Raw Player Score:** {round(row.get('raw_score', np.nan), 2) if pd.notna(row.get('raw_score', np.nan)) else 'N/A'}")
296
+ lines.append(f"- **Attainability Score:** {round(row.get(ATTAINABILITY_COL, np.nan), 2) if pd.notna(row.get(ATTAINABILITY_COL, np.nan)) else 'N/A'}")
297
+ lines.append("")
298
+ lines.append("## Key Season Stats")
299
+
300
+ for col in available_cols(KEY_METRICS):
301
+ value = row.get(col, np.nan)
302
+ if pd.notna(value):
303
+ lines.append(f"- **{label_col(col)}:** {round(value, 2)}")
304
+
305
+ return "\n".join(lines)
306
+
307
+ def category_table(player):
308
+ row = get_player_row(player)
309
+
310
+ if row is None:
311
+ return pd.DataFrame({"Message": ["Select a player."]})
312
+
313
+ rows = []
314
+ for col in available_cols(CATEGORY_METRICS):
315
+ value = row.get(col, np.nan)
316
+ if pd.notna(value):
317
+ rows.append({
318
+ "Category": label_col(col.replace("cat_", "")),
319
+ "Score": round(value, 2)
320
+ })
321
+
322
+ return pd.DataFrame(rows).sort_values("Score", ascending=False)
323
+
324
+ # ============================================================
325
+ # RADAR CHART
326
+ # ============================================================
327
+
328
+ def radar_chart(player):
329
+ row = get_player_row(player)
330
+
331
+ if row is None:
332
+ return go.Figure()
333
+
334
+ metrics = available_cols(RADAR_METRICS)
335
+
336
+ if len(metrics) < 3:
337
+ fig = go.Figure()
338
+ fig.update_layout(title="Need at least 3 radar metrics.")
339
+ return fig
340
+
341
+ comp = row[COMP_COL]
342
+ pos = row[POSITION_COL]
343
+
344
+ group = df[(df[COMP_COL] == comp) & (df[POSITION_COL] == pos)].copy()
345
+
346
+ labels = [label_col(m.replace("cat_", "")) for m in metrics]
347
+ player_values = [row[m] for m in metrics]
348
+ avg_values = [group[m].mean() for m in metrics]
349
+
350
+ fig = go.Figure()
351
+
352
+ fig.add_trace(go.Scatterpolar(
353
+ r=player_values,
354
+ theta=labels,
355
+ fill="toself",
356
+ name=str(player)
357
+ ))
358
+
359
+ fig.add_trace(go.Scatterpolar(
360
+ r=avg_values,
361
+ theta=labels,
362
+ fill="toself",
363
+ name=f"{pos} Avg in {comp}"
364
+ ))
365
+
366
+ fig.update_layout(
367
+ title=f"{player} vs Position/Competition Average",
368
+ polar=dict(radialaxis=dict(visible=True)),
369
+ showlegend=True
370
+ )
371
+
372
+ return fig
373
+
374
+ # ============================================================
375
+ # PERCENTILE BARS
376
+ # ============================================================
377
+
378
+ def percentile_chart(player):
379
+ row = get_player_row(player)
380
+
381
+ if row is None:
382
+ return go.Figure()
383
+
384
+ comp = row[COMP_COL]
385
+ pos = row[POSITION_COL]
386
+ group = df[(df[COMP_COL] == comp) & (df[POSITION_COL] == pos)].copy()
387
+
388
+ rows = []
389
+
390
+ for metric in available_cols(PERCENTILE_METRICS):
391
+ value = row.get(metric, np.nan)
392
+ values = pd.to_numeric(group[metric], errors="coerce").dropna()
393
+
394
+ if pd.notna(value) and len(values) > 1:
395
+ pct = (values < value).mean() * 100
396
+ rows.append({
397
+ "Metric": label_col(metric),
398
+ "Percentile": round(pct, 1),
399
+ "Value": round(value, 2)
400
+ })
401
+
402
+ plot_df = pd.DataFrame(rows)
403
+
404
+ if plot_df.empty:
405
+ fig = go.Figure()
406
+ fig.update_layout(title="No percentile data available.")
407
+ return fig
408
+
409
+ fig = px.bar(
410
+ plot_df.sort_values("Percentile"),
411
+ x="Percentile",
412
+ y="Metric",
413
+ orientation="h",
414
+ hover_data=["Value"],
415
+ title=f"{player} Percentiles vs Same Position and Competition",
416
+ range_x=[0, 100]
417
+ )
418
+
419
+ fig.update_layout(yaxis_title="", xaxis_title="Percentile")
420
+ return fig
421
+
422
+ # ============================================================
423
+ # PERFORMANCE OVER TIME
424
+ # ============================================================
425
+
426
+ def performance_chart(player, metric):
427
+ row_data = df[df[PLAYER_COL].astype(str) == str(player)].copy()
428
+
429
+ if row_data.empty or metric not in df.columns:
430
+ return go.Figure()
431
+
432
+ if SEASON_COL not in df.columns or row_data[SEASON_COL].nunique() <= 1:
433
+ fig = go.Figure()
434
+ fig.add_trace(go.Bar(
435
+ x=[label_col(metric)],
436
+ y=[row_data.iloc[0][metric]]
437
+ ))
438
+ fig.update_layout(
439
+ title="Only one season is currently in this file, so a true over-time line chart is not available yet.",
440
+ yaxis_title=label_col(metric)
441
+ )
442
+ return fig
443
+
444
+ row_data[metric] = pd.to_numeric(row_data[metric], errors="coerce")
445
+
446
+ fig = px.line(
447
+ row_data,
448
+ x=SEASON_COL,
449
+ y=metric,
450
+ markers=True,
451
+ title=f"{player}: {label_col(metric)} Over Time"
452
+ )
453
+
454
+ return fig
455
+
456
+ # ============================================================
457
+ # PLAYER COMPARISON
458
+ # ============================================================
459
+
460
+ def compare_players(player_1, player_2, player_3):
461
+ players = [p for p in [player_1, player_2, player_3] if p]
462
+
463
+ if not players:
464
+ return pd.DataFrame({"Message": ["Select at least one player."]})
465
+
466
+ data = df[df[PLAYER_COL].astype(str).isin(players)].copy()
467
+
468
+ cols = available_cols([
469
+ PLAYER_COL,
470
+ TEAM_COL,
471
+ COMP_COL,
472
+ POSITION_COL,
473
+ AGE_COL,
474
+ MINUTES_COL,
475
+ MARKET_VALUE_COL,
476
+ CONTRACT_COL,
477
+ "best_midfield_archetype",
478
+ "best_midfield_archetype_score",
479
+ "raw_score",
480
+ ATTAINABILITY_COL
481
+ ] + KEY_METRICS + CATEGORY_METRICS)
482
+
483
+ out = data[cols].copy()
484
+
485
+ if MARKET_VALUE_COL in out.columns:
486
+ out[MARKET_VALUE_COL] = out[MARKET_VALUE_COL].apply(format_money)
487
+
488
+ numeric_cols = out.select_dtypes(include=np.number).columns
489
+ out[numeric_cols] = out[numeric_cols].round(2)
490
+
491
+ return out.reset_index(drop=True)
492
+
493
+ def comparison_radar(player_1, player_2, player_3):
494
+ players = [p for p in [player_1, player_2, player_3] if p]
495
+ metrics = available_cols(RADAR_METRICS)
496
+
497
+ fig = go.Figure()
498
+
499
+ if not players or len(metrics) < 3:
500
+ fig.update_layout(title="Select players to compare.")
501
+ return fig
502
+
503
+ labels = [label_col(m.replace("cat_", "")) for m in metrics]
504
+
505
+ for player in players:
506
+ row = get_player_row(player)
507
+ if row is not None:
508
+ fig.add_trace(go.Scatterpolar(
509
+ r=[row[m] for m in metrics],
510
+ theta=labels,
511
+ fill="toself",
512
+ name=str(player)
513
+ ))
514
+
515
+ fig.update_layout(
516
+ title="Side-by-Side Radar Comparison",
517
+ polar=dict(radialaxis=dict(visible=True)),
518
+ showlegend=True
519
+ )
520
+
521
+ return fig
522
+
523
+ # ============================================================
524
+ # FIT SCORE CALCULATOR
525
+ # ============================================================
526
+
527
+ def fit_score(defense_w, chance_w, progression_w, passing_w, pressing_w, security_w, goal_w, impact_w, attain_w):
528
+ weights = {
529
+ "cat_defensive_ability": defense_w,
530
+ "cat_chance_creation": chance_w,
531
+ "cat_ball_progression": progression_w,
532
+ "cat_passing": passing_w,
533
+ "cat_pressing_work_rate": pressing_w,
534
+ "cat_possession_security": security_w,
535
+ "cat_goal_threat": goal_w,
536
+ "cat_impact": impact_w,
537
+ "attainability": attain_w
538
+ }
539
+
540
+ data = df.copy()
541
+ total_weight = sum(weights.values())
542
+
543
+ if total_weight == 0:
544
+ return pd.DataFrame({"Message": ["At least one weight must be above 0."]})
545
+
546
+ score = 0
547
+
548
+ for col, weight in weights.items():
549
+ if col in data.columns:
550
+ values = pd.to_numeric(data[col], errors="coerce")
551
+ min_v = values.min()
552
+ max_v = values.max()
553
+
554
+ if pd.notna(min_v) and pd.notna(max_v) and max_v != min_v:
555
+ normalized = ((values - min_v) / (max_v - min_v)) * 100
556
+ else:
557
+ normalized = values
558
+
559
+ score += normalized.fillna(0) * weight
560
+
561
+ data["custom_fit_score"] = score / total_weight
562
+
563
+ cols = available_cols([
564
+ PLAYER_COL,
565
+ TEAM_COL,
566
+ COMP_COL,
567
+ POSITION_COL,
568
+ AGE_COL,
569
+ MARKET_VALUE_COL,
570
+ "best_midfield_archetype",
571
+ "raw_score",
572
+ ATTAINABILITY_COL
573
+ ]) + ["custom_fit_score"]
574
+
575
+ out = data[cols].sort_values("custom_fit_score", ascending=False).head(25).copy()
576
+
577
+ if MARKET_VALUE_COL in out.columns:
578
+ out[MARKET_VALUE_COL] = out[MARKET_VALUE_COL].apply(format_money)
579
+
580
+ numeric_cols = out.select_dtypes(include=np.number).columns
581
+ out[numeric_cols] = out[numeric_cols].round(2)
582
+
583
+ return out.reset_index(drop=True)
584
+
585
+ # ============================================================
586
+ # SIMILAR PLAYER FINDER
587
+ # ============================================================
588
+
589
+ def similar_players(player):
590
+ row = get_player_row(player)
591
+
592
+ if row is None:
593
+ return pd.DataFrame({"Message": ["Select a player."]})
594
+
595
+ metrics = available_cols(CATEGORY_METRICS + KEY_METRICS)
596
+
597
+ comp = row[COMP_COL]
598
+ pos = row[POSITION_COL]
599
+
600
+ candidates = df[
601
+ (df[PLAYER_COL].astype(str) != str(player)) &
602
+ (df[POSITION_COL] == pos)
603
+ ].copy()
604
+
605
+ if candidates.empty:
606
+ candidates = df[df[PLAYER_COL].astype(str) != str(player)].copy()
607
+
608
+ for metric in metrics:
609
+ candidates[metric] = pd.to_numeric(candidates[metric], errors="coerce")
610
+ df_metric = pd.to_numeric(df[metric], errors="coerce")
611
+ sd = df_metric.std()
612
+
613
+ if pd.isna(sd) or sd == 0:
614
+ candidates[f"dist_{metric}"] = 0
615
+ else:
616
+ candidates[f"dist_{metric}"] = ((candidates[metric] - row[metric]) / sd) ** 2
617
+
618
+ dist_cols = [f"dist_{m}" for m in metrics]
619
+ candidates["similarity_distance"] = candidates[dist_cols].sum(axis=1)
620
+ candidates["similarity_score"] = 100 / (1 + candidates["similarity_distance"])
621
+
622
+ cols = available_cols([
623
+ PLAYER_COL,
624
+ TEAM_COL,
625
+ COMP_COL,
626
+ POSITION_COL,
627
+ AGE_COL,
628
+ MARKET_VALUE_COL,
629
+ "best_midfield_archetype",
630
+ "raw_score",
631
+ ATTAINABILITY_COL
632
+ ]) + ["similarity_score"]
633
+
634
+ out = candidates[cols].sort_values("similarity_score", ascending=False).head(5).copy()
635
+
636
+ if MARKET_VALUE_COL in out.columns:
637
+ out[MARKET_VALUE_COL] = out[MARKET_VALUE_COL].apply(format_money)
638
+
639
+ numeric_cols = out.select_dtypes(include=np.number).columns
640
+ out[numeric_cols] = out[numeric_cols].round(2)
641
+
642
+ return out.reset_index(drop=True)
643
+
644
+ # ============================================================
645
+ # SHORTLIST
646
+ # ============================================================
647
+
648
+ def add_to_shortlist(player):
649
+ global shortlist
650
+
651
+ if player and player not in shortlist:
652
+ shortlist.append(player)
653
+
654
+ return view_shortlist()
655
+
656
+ def clear_shortlist():
657
+ global shortlist
658
+ shortlist = []
659
+ return view_shortlist()
660
+
661
+ def view_shortlist():
662
+ if not shortlist:
663
+ return pd.DataFrame({"Message": ["No players added to shortlist yet."]})
664
+
665
+ data = df[df[PLAYER_COL].astype(str).isin(shortlist)].copy()
666
+
667
+ cols = available_cols([
668
+ PLAYER_COL,
669
+ TEAM_COL,
670
+ COMP_COL,
671
+ POSITION_COL,
672
+ AGE_COL,
673
+ MARKET_VALUE_COL,
674
+ CONTRACT_COL,
675
+ "best_midfield_archetype",
676
+ "raw_score",
677
+ ATTAINABILITY_COL
678
+ ] + KEY_METRICS)
679
+
680
+ out = data[cols].copy()
681
+
682
+ if MARKET_VALUE_COL in out.columns:
683
+ out[MARKET_VALUE_COL] = out[MARKET_VALUE_COL].apply(format_money)
684
+
685
+ numeric_cols = out.select_dtypes(include=np.number).columns
686
+ out[numeric_cols] = out[numeric_cols].round(2)
687
+
688
+ return out.reset_index(drop=True)
689
+
690
+ def export_shortlist_csv():
691
+ if not shortlist:
692
+ return None
693
+
694
+ data = df[df[PLAYER_COL].astype(str).isin(shortlist)].copy()
695
+ out_file = "shortlist_export.csv"
696
+ data.to_csv(out_file, index=False)
697
+ return out_file
698
+
699
+ # ============================================================
700
+ # SCOUTING REPORT EXPORT
701
+ # ============================================================
702
+
703
+ def export_player_report(player, notes):
704
+ row = get_player_row(player)
705
+
706
+ if row is None:
707
+ return None
708
+
709
+ report = []
710
+ report.append(f"Player Scouting Report: {row[PLAYER_COL]}")
711
+ report.append("=" * 60)
712
+ report.append("")
713
+ report.append(f"Club: {row.get(TEAM_COL, 'N/A')}")
714
+ report.append(f"Competition: {row.get(COMP_COL, 'N/A')}")
715
+ report.append(f"Position: {row.get(POSITION_COL, 'N/A')}")
716
+ report.append(f"Age: {round(row.get(AGE_COL, np.nan), 1) if pd.notna(row.get(AGE_COL, np.nan)) else 'N/A'}")
717
+ report.append(f"Height: {row.get(HEIGHT_COL, 'N/A')}")
718
+ report.append(f"Market Value: {format_money(row.get(MARKET_VALUE_COL, np.nan))}")
719
+ report.append(f"Contract Status: {row.get(CONTRACT_COL, 'N/A')}")
720
+ report.append("")
721
+ report.append("Scoring System")
722
+ report.append("-" * 60)
723
+ report.append(f"Best Midfield Archetype: {row.get('best_midfield_archetype', 'N/A')}")
724
+ report.append(f"Best Archetype Score: {round(row.get('best_midfield_archetype_score', np.nan), 2) if pd.notna(row.get('best_midfield_archetype_score', np.nan)) else 'N/A'}")
725
+ report.append(f"Raw Score: {round(row.get('raw_score', np.nan), 2) if pd.notna(row.get('raw_score', np.nan)) else 'N/A'}")
726
+ report.append(f"Attainability: {round(row.get(ATTAINABILITY_COL, np.nan), 2) if pd.notna(row.get(ATTAINABILITY_COL, np.nan)) else 'N/A'}")
727
+ report.append("")
728
+ report.append("Key Metrics")
729
+ report.append("-" * 60)
730
+
731
+ for col in available_cols(KEY_METRICS):
732
+ value = row.get(col, np.nan)
733
+ if pd.notna(value):
734
+ report.append(f"{label_col(col)}: {round(value, 2)}")
735
+
736
+ report.append("")
737
+ report.append("Category Scores")
738
+ report.append("-" * 60)
739
+
740
+ for col in available_cols(CATEGORY_METRICS):
741
+ value = row.get(col, np.nan)
742
+ if pd.notna(value):
743
+ report.append(f"{label_col(col.replace('cat_', ''))}: {round(value, 2)}")
744
+
745
+ report.append("")
746
+ report.append("Scout Notes")
747
+ report.append("-" * 60)
748
+ report.append(notes if notes else "No notes entered.")
749
+
750
+ safe_name = str(row[PLAYER_COL]).replace(" ", "_").replace("/", "_")
751
+ out_file = f"{safe_name}_scouting_report.txt"
752
+
753
+ with open(out_file, "w", encoding="utf-8") as f:
754
+ f.write("\n".join(report))
755
+
756
+ return out_file
757
+
758
+ # ============================================================
759
+ # APP LAYOUT
760
+ # ============================================================
761
+
762
+ with gr.Blocks(title="Oldham Athletic Player Scouting") as app:
763
+
764
+ gr.Markdown(
765
+ """
766
+ # Oldham Athletic Player Scouting
767
+
768
+ Interactive player scouting dashboard for midfielders across League One, League Two, the National League, National League N/S, and the Scottish Championship.
769
+ """
770
+ )
771
+
772
+ with gr.Tab("Player Search"):
773
+ gr.Markdown("## Search and Filter Players")
774
+
775
+ with gr.Row():
776
+ search_box = gr.Textbox(label="Search Player Name")
777
+
778
+ competition_filter = gr.Dropdown(
779
+ choices=competition_options[1:],
780
+ value=[],
781
+ label="Competition",
782
+ multiselect=True
783
+ )
784
+
785
+ team_filter = gr.Dropdown(
786
+ choices=team_options[1:],
787
+ value=[],
788
+ label="Team",
789
+ multiselect=True
790
+ )
791
+
792
+ with gr.Row():
793
+ position_filter = gr.Dropdown(
794
+ choices=position_options[1:],
795
+ value=[],
796
+ label="Position",
797
+ multiselect=True
798
+ )
799
+
800
+ min_age_filter = gr.Slider(
801
+ minimum=age_min,
802
+ maximum=age_max,
803
+ value=age_min,
804
+ step=1,
805
+ label="Minimum Age"
806
+ )
807
+
808
+ max_age_filter = gr.Slider(
809
+ minimum=age_min,
810
+ maximum=age_max,
811
+ value=age_max,
812
+ step=1,
813
+ label="Maximum Age"
814
+ )
815
+
816
+ minutes_filter = gr.Slider(
817
+ minimum=0,
818
+ maximum=int(df[MINUTES_COL].max()) if MINUTES_COL in df.columns else 3000,
819
+ value=0,
820
+ step=100,
821
+ label="Minimum Minutes"
822
+ )
823
+
824
+ search_button = gr.Button("Search Players")
825
+ search_results = gr.Dataframe(label="Sortable Player Results", interactive=False)
826
+
827
+ search_button.click(
828
+ fn=search_players,
829
+ inputs=[
830
+ search_box,
831
+ competition_filter,
832
+ team_filter,
833
+ position_filter,
834
+ min_age_filter,
835
+ max_age_filter,
836
+ minutes_filter
837
+ ],
838
+ outputs=search_results
839
+ )
840
+
841
+ with gr.Tab("Player Profile"):
842
+ gr.Markdown("## Full Player Profile")
843
+
844
+ selected_player = gr.Dropdown(player_options, label="Select Player")
845
+
846
+ with gr.Row():
847
+ profile_output = gr.Markdown()
848
+ category_output = gr.Dataframe(label="Season Stats by Category", interactive=False)
849
+
850
+ with gr.Row():
851
+ radar_output = gr.Plot(label="Radar Chart")
852
+ percentile_output = gr.Plot(label="Percentile Bars")
853
+
854
+ with gr.Row():
855
+ profile_metric = gr.Dropdown(metric_options, value=metric_options[0] if metric_options else None, label="Performance Metric")
856
+ trend_button = gr.Button("Show Performance Chart")
857
+
858
+ trend_output = gr.Plot(label="Performance Over Time")
859
+
860
+ scout_notes = gr.Textbox(label="Scout Notes", lines=5, placeholder="Enter notes to include in the scouting report.")
861
+ report_button = gr.Button("Generate Scouting Report")
862
+ report_file = gr.File(label="Download Scouting Report")
863
+
864
+ shortlist_button = gr.Button("Add Player to Shortlist")
865
+ shortlist_from_profile = gr.Dataframe(label="Current Shortlist", interactive=False)
866
+
867
+ selected_player.change(player_profile, selected_player, profile_output)
868
+ selected_player.change(category_table, selected_player, category_output)
869
+ selected_player.change(radar_chart, selected_player, radar_output)
870
+ selected_player.change(percentile_chart, selected_player, percentile_output)
871
+
872
+ trend_button.click(performance_chart, [selected_player, profile_metric], trend_output)
873
+ report_button.click(export_player_report, [selected_player, scout_notes], report_file)
874
+ shortlist_button.click(add_to_shortlist, selected_player, shortlist_from_profile)
875
+
876
+ with gr.Tab("Player Comparison Tool"):
877
+ gr.Markdown("## Compare Up To Three Players")
878
+
879
+ with gr.Row():
880
+ compare_1 = gr.Dropdown(player_options, label="Player 1")
881
+ compare_2 = gr.Dropdown(player_options, label="Player 2")
882
+ compare_3 = gr.Dropdown(player_options, label="Player 3")
883
+
884
+ compare_button = gr.Button("Compare Players")
885
+
886
+ comparison_table = gr.Dataframe(label="Stat Comparison Table", interactive=False)
887
+ comparison_radar_plot = gr.Plot(label="Side-by-Side Radar Chart")
888
+
889
+ compare_button.click(compare_players, [compare_1, compare_2, compare_3], comparison_table)
890
+ compare_button.click(comparison_radar, [compare_1, compare_2, compare_3], comparison_radar_plot)
891
+
892
+ with gr.Tab("Fit Score Calculator"):
893
+ gr.Markdown(
894
+ """
895
+ ## Custom Fit Score Calculator
896
+
897
+ Move the sliders to weight the traits Oldham cares about most.
898
+ The app will rank players based on your custom scouting profile.
899
+ """
900
+ )
901
+
902
+ with gr.Row():
903
+ defense_w = gr.Slider(0, 10, value=5, step=1, label="Defensive Ability")
904
+ chance_w = gr.Slider(0, 10, value=5, step=1, label="Chance Creation")
905
+ progression_w = gr.Slider(0, 10, value=5, step=1, label="Ball Progression")
906
+
907
+ with gr.Row():
908
+ passing_w = gr.Slider(0, 10, value=5, step=1, label="Passing")
909
+ pressing_w = gr.Slider(0, 10, value=5, step=1, label="Pressing Work Rate")
910
+ security_w = gr.Slider(0, 10, value=5, step=1, label="Possession Security")
911
+
912
+ with gr.Row():
913
+ goal_w = gr.Slider(0, 10, value=3, step=1, label="Goal Threat")
914
+ impact_w = gr.Slider(0, 10, value=5, step=1, label="Impact")
915
+ attain_w = gr.Slider(0, 10, value=5, step=1, label="Attainability")
916
+
917
+ fit_button = gr.Button("Generate Ranked Recommendations")
918
+ fit_table = gr.Dataframe(label="Ranked Recommendations", interactive=False)
919
+
920
+ fit_button.click(
921
+ fit_score,
922
+ [defense_w, chance_w, progression_w, passing_w, pressing_w, security_w, goal_w, impact_w, attain_w],
923
+ fit_table
924
+ )
925
+
926
+ with gr.Tab("Similar Player Finder"):
927
+ gr.Markdown("## Find Similar Players")
928
+
929
+ similar_player_select = gr.Dropdown(player_options, label="Select Player")
930
+ similar_button = gr.Button("Find Similar Players")
931
+ similar_table = gr.Dataframe(label="Five Similar Players", interactive=False)
932
+
933
+ similar_button.click(similar_players, similar_player_select, similar_table)
934
+
935
+ with gr.Tab("Shortlist Manager"):
936
+ gr.Markdown("## Shortlist Manager")
937
+
938
+ shortlist_player = gr.Dropdown(player_options, label="Add Player")
939
+ add_shortlist_button = gr.Button("Add to Shortlist")
940
+ clear_shortlist_button = gr.Button("Clear Shortlist")
941
+ export_shortlist_button = gr.Button("Export Shortlist CSV")
942
+
943
+ shortlist_table = gr.Dataframe(label="Saved Players", interactive=False)
944
+ shortlist_file = gr.File(label="Download Shortlist CSV")
945
+
946
+ add_shortlist_button.click(add_to_shortlist, shortlist_player, shortlist_table)
947
+ clear_shortlist_button.click(clear_shortlist, None, shortlist_table)
948
+ export_shortlist_button.click(export_shortlist_csv, None, shortlist_file)
949
+
950
+ app.launch()