cpflecht commited on
Commit
a8d496a
·
verified ·
1 Parent(s): 717a1f8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +249 -449
app.py CHANGED
@@ -3,7 +3,6 @@ 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
@@ -23,105 +22,99 @@ df.columns = (
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
  # ============================================================
@@ -132,28 +125,25 @@ 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:
@@ -163,72 +153,49 @@ if AGE_COL in df.columns:
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()
@@ -236,31 +203,17 @@ def search_players(search, competitions, teams, positions, min_age, max_age, min
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
  # ============================================================
@@ -273,7 +226,6 @@ def get_player_row(player):
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
 
@@ -284,19 +236,22 @@ def player_profile(player):
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):
@@ -306,7 +261,6 @@ def player_profile(player):
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
 
@@ -315,10 +269,13 @@ def category_table(player):
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
  # ============================================================
@@ -327,48 +284,31 @@ def category_table(player):
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
  # ============================================================
@@ -377,45 +317,33 @@ def radar_chart(player):
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
 
@@ -431,26 +359,16 @@ def performance_chart(player, metric):
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
  # ============================================================
@@ -459,82 +377,61 @@ def performance_chart(player, metric):
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()
@@ -544,42 +441,26 @@ def fit_score(defense_w, chance_w, progression_w, passing_w, pressing_w, securit
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
  # ============================================================
@@ -588,57 +469,33 @@ def fit_score(defense_w, chance_w, progression_w, passing_w, pressing_w, securit
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
  # ============================================================
@@ -647,10 +504,8 @@ def similar_players(player):
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():
@@ -663,34 +518,19 @@ def view_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)
@@ -702,189 +542,155 @@ def export_shortlist_csv():
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)
@@ -892,59 +698,53 @@ with gr.Blocks(title="Oldham Athletic Player Scouting") as app:
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()
 
3
  import numpy as np
4
  import plotly.graph_objects as go
5
  import plotly.express as px
 
6
 
7
  # ============================================================
8
  # LOAD DATA
 
22
  )
23
 
24
  # ============================================================
25
+ # COLUMN SETUP
26
  # ============================================================
27
 
28
+ PLAYER_COL = "player_name"
29
+ TEAM_COL = "team_name"
30
+ COMP_COL = "competition_name"
31
+ POSITION_COL = "primary_position"
32
+ AGE_COL = "age"
33
+ SEASON_COL = "season_name"
34
 
35
+ HEIGHT_COL = "player_height"
36
+ MINUTES_COL = "player_season_minutes"
 
 
 
37
 
38
+ # GK-specific scoring columns
39
+ ARCHETYPE_COL = "best_gk_archetype"
40
+ ARCHETYPE_SCORE_COL = "best_gk_archetype_score"
41
+ GK_SCORE_COL = "gk_score"
 
 
 
 
 
 
 
 
 
42
 
43
+ # GK category scores (gk_cat_*)
44
  CATEGORY_METRICS = [
45
+ "gk_cat_shot_stopping",
46
+ "gk_cat_sweeping",
47
+ "gk_cat_short_passing",
48
+ "gk_cat_long_passing",
49
+ "gk_cat_ball_claiming",
50
+ "gk_cat_overall_value",
 
 
 
 
 
 
 
 
51
  ]
52
 
53
+ # GK archetype scores
54
  SCORING_METRICS = [
55
+ "shot_stopper_score",
56
+ "sweeper_keeper_score",
57
+ "ball_playing_gk_score",
58
+ "organiser_score",
59
+ "best_gk_archetype",
60
+ "best_gk_archetype_score",
61
+ "gk_score",
62
+ ]
63
+
64
+ # Key per-90 / ratio metrics shown in tables and profile
65
+ KEY_METRICS = [
66
+ "player_season_save_ratio",
67
+ "player_season_gsaa_90",
68
+ "player_season_gsaa",
69
+ "player_season_shots_faced_90",
70
+ "player_season_goals_faced_90",
71
+ "player_season_errors_90",
72
+ "player_season_clcaa",
73
+ "player_season_da_aggressive_distance",
74
+ "player_season_passing_ratio",
75
+ "player_season_long_ball_ratio",
76
+ "player_season_aerial_ratio",
77
+ "player_season_obv_gk_90",
78
+ "player_season_obv_90",
79
+ "player_season_pressures_90",
80
  ]
81
 
82
+ # Radar uses the GK category scores
83
  RADAR_METRICS = [
84
+ "gk_cat_shot_stopping",
85
+ "gk_cat_sweeping",
86
+ "gk_cat_short_passing",
87
+ "gk_cat_long_passing",
88
+ "gk_cat_ball_claiming",
89
+ "gk_cat_overall_value",
 
 
 
 
90
  ]
91
 
92
+ # Percentile bar chart metrics
93
  PERCENTILE_METRICS = [
94
+ "player_season_save_ratio",
95
+ "player_season_gsaa_90",
96
+ "player_season_shots_faced_90",
97
+ "player_season_goals_faced_90",
98
+ "player_season_errors_90",
99
+ "player_season_clcaa",
100
+ "player_season_da_aggressive_distance",
101
  "player_season_passing_ratio",
102
+ "player_season_long_ball_ratio",
103
+ "player_season_aerial_ratio",
104
+ "player_season_obv_gk_90",
105
+ "gk_cat_shot_stopping",
106
+ "gk_cat_sweeping",
107
+ "gk_cat_overall_value",
108
  ]
109
 
110
+ # Fit score calculator — weights map to these columns
111
  FIT_SCORE_METRICS = [
112
+ "gk_cat_shot_stopping",
113
+ "gk_cat_sweeping",
114
+ "gk_cat_short_passing",
115
+ "gk_cat_long_passing",
116
+ "gk_cat_ball_claiming",
117
+ "gk_cat_overall_value",
 
 
 
118
  ]
119
 
120
  # ============================================================
 
125
  return [c for c in cols if c in df.columns]
126
 
127
  def label_col(col):
128
+ return (
129
+ col.replace("player_season_", "")
130
+ .replace("gk_cat_", "")
131
+ .replace("_90", " per 90")
132
+ .replace("_", " ")
133
+ .title()
134
+ )
 
 
 
 
 
 
 
135
 
136
  def safe_numeric(data, col):
137
  if col in data.columns:
138
  return pd.to_numeric(data[col], errors="coerce")
139
  return pd.Series(dtype=float)
140
 
141
+ # Coerce numeric columns
142
+ _numeric_cols = available_cols(
143
+ KEY_METRICS + CATEGORY_METRICS + SCORING_METRICS + PERCENTILE_METRICS + FIT_SCORE_METRICS
144
+ )
145
+ for col in _numeric_cols:
146
+ if col != ARCHETYPE_COL:
147
  df[col] = pd.to_numeric(df[col], errors="coerce")
148
 
149
  if AGE_COL in df.columns:
 
153
  # DROPDOWN OPTIONS
154
  # ============================================================
155
 
156
+ player_options = sorted(df[PLAYER_COL].dropna().astype(str).unique().tolist())
157
+ competition_options = sorted(df[COMP_COL].dropna().astype(str).unique().tolist())
158
+ team_options = sorted(df[TEAM_COL].dropna().astype(str).unique().tolist())
159
+ position_options = sorted(df[POSITION_COL].dropna().astype(str).unique().tolist())
160
 
161
  age_min = int(np.floor(df[AGE_COL].min())) if AGE_COL in df.columns else 15
162
+ age_max = int(np.ceil(df[AGE_COL].max())) if AGE_COL in df.columns else 45
163
 
164
  metric_options = available_cols(KEY_METRICS + CATEGORY_METRICS + SCORING_METRICS)
165
 
166
  shortlist = []
167
 
168
  # ============================================================
169
+ # PLAYER SEARCH
 
170
  # ============================================================
171
 
172
  def search_players(search, competitions, teams, positions, min_age, max_age, min_minutes):
173
  data = df.copy()
174
 
 
175
  if search and PLAYER_COL in data.columns:
176
+ data = data[data[PLAYER_COL].astype(str).str.contains(str(search), case=False, na=False)]
 
 
 
 
177
 
 
178
  if competitions and COMP_COL in data.columns:
179
  data = data[data[COMP_COL].astype(str).isin(competitions)]
180
 
 
181
  if teams and TEAM_COL in data.columns:
182
  data = data[data[TEAM_COL].astype(str).isin(teams)]
183
 
 
184
  if positions and POSITION_COL in data.columns:
185
  data = data[data[POSITION_COL].astype(str).isin(positions)]
186
 
 
187
  if AGE_COL in data.columns:
188
  data[AGE_COL] = pd.to_numeric(data[AGE_COL], errors="coerce")
189
+ data = data[(data[AGE_COL] >= min_age) & (data[AGE_COL] <= max_age)]
 
 
 
190
 
 
191
  if MINUTES_COL in data.columns:
192
  data[MINUTES_COL] = pd.to_numeric(data[MINUTES_COL], errors="coerce")
193
  data = data[data[MINUTES_COL].fillna(0) >= min_minutes]
194
 
195
  table_cols = available_cols([
196
+ PLAYER_COL, TEAM_COL, COMP_COL, POSITION_COL,
197
+ AGE_COL, MINUTES_COL,
198
+ ARCHETYPE_COL, ARCHETYPE_SCORE_COL, GK_SCORE_COL,
 
 
 
 
 
 
 
 
 
199
  ] + KEY_METRICS)
200
 
201
  out = data[table_cols].copy()
 
203
  if out.empty:
204
  return pd.DataFrame({"Message": ["No players found. Try clearing some filters."]})
205
 
 
 
 
206
  if AGE_COL in out.columns:
207
  out[AGE_COL] = pd.to_numeric(out[AGE_COL], errors="coerce").round(1)
208
 
209
  numeric_cols = out.select_dtypes(include=np.number).columns
210
  out[numeric_cols] = out[numeric_cols].round(2)
211
 
212
+ if GK_SCORE_COL in out.columns:
213
+ out = out.sort_values(by=GK_SCORE_COL, ascending=False)
214
 
215
  return out.reset_index(drop=True)
216
 
 
 
 
 
 
 
 
 
 
 
 
217
  # ============================================================
218
  # PLAYER PROFILE
219
  # ============================================================
 
226
 
227
  def player_profile(player):
228
  row = get_player_row(player)
 
229
  if row is None:
230
  return "Select a player to view their profile."
231
 
 
236
  lines.append("## Player Details")
237
  lines.append(f"- **Position:** {row.get(POSITION_COL, 'N/A')}")
238
  lines.append(f"- **Age:** {round(row.get(AGE_COL, np.nan), 1) if pd.notna(row.get(AGE_COL, np.nan)) else 'N/A'}")
239
+ lines.append(f"- **Height:** {row.get(HEIGHT_COL, 'N/A')} cm")
 
 
240
  lines.append(f"- **Minutes:** {round(row.get(MINUTES_COL, 0), 0)}")
241
+ lines.append(f"- **Appearances:** {row.get('player_season_appearances', 'N/A')}")
242
  lines.append("")
243
+ lines.append("## GK Scoring")
244
+ lines.append(f"- **Best Archetype:** {row.get(ARCHETYPE_COL, 'N/A')}")
245
+ lines.append(f"- **Best Archetype Score:** {round(row.get(ARCHETYPE_SCORE_COL, np.nan), 2) if pd.notna(row.get(ARCHETYPE_SCORE_COL, np.nan)) else 'N/A'}")
246
+ lines.append(f"- **Overall GK Score:** {round(row.get(GK_SCORE_COL, np.nan), 2) if pd.notna(row.get(GK_SCORE_COL, np.nan)) else 'N/A'}")
247
+ lines.append("")
248
+ lines.append("## Archetype Scores")
249
+ for col in ["shot_stopper_score", "sweeper_keeper_score", "ball_playing_gk_score", "organiser_score"]:
250
+ val = row.get(col, np.nan)
251
+ if col in df.columns and pd.notna(val):
252
+ lines.append(f"- **{label_col(col.replace('_score', ''))}:** {round(val, 2)}")
253
  lines.append("")
254
  lines.append("## Key Season Stats")
 
255
  for col in available_cols(KEY_METRICS):
256
  value = row.get(col, np.nan)
257
  if pd.notna(value):
 
261
 
262
  def category_table(player):
263
  row = get_player_row(player)
 
264
  if row is None:
265
  return pd.DataFrame({"Message": ["Select a player."]})
266
 
 
269
  value = row.get(col, np.nan)
270
  if pd.notna(value):
271
  rows.append({
272
+ "Category": label_col(col),
273
  "Score": round(value, 2)
274
  })
275
 
276
+ if not rows:
277
+ return pd.DataFrame({"Message": ["No category data available."]})
278
+
279
  return pd.DataFrame(rows).sort_values("Score", ascending=False)
280
 
281
  # ============================================================
 
284
 
285
  def radar_chart(player):
286
  row = get_player_row(player)
 
287
  if row is None:
288
  return go.Figure()
289
 
290
  metrics = available_cols(RADAR_METRICS)
 
291
  if len(metrics) < 3:
292
  fig = go.Figure()
293
  fig.update_layout(title="Need at least 3 radar metrics.")
294
  return fig
295
 
296
+ comp = row[COMP_COL]
297
+ group = df[df[COMP_COL] == comp].copy()
298
 
299
+ labels = [label_col(m) for m in metrics]
300
+ player_values = [row[m] if pd.notna(row[m]) else 0 for m in metrics]
301
+ avg_values = [group[m].mean() for m in metrics]
 
 
302
 
303
  fig = go.Figure()
304
+ fig.add_trace(go.Scatterpolar(r=player_values, theta=labels, fill="toself", name=str(player)))
305
+ fig.add_trace(go.Scatterpolar(r=avg_values, theta=labels, fill="toself", name=f"GK Avg in {comp}"))
 
 
 
 
 
 
 
 
 
 
 
 
306
 
307
  fig.update_layout(
308
+ title=f"{player} vs Competition Average",
309
+ polar=dict(radialaxis=dict(visible=True, range=[0, 100])),
310
  showlegend=True
311
  )
 
312
  return fig
313
 
314
  # ============================================================
 
317
 
318
  def percentile_chart(player):
319
  row = get_player_row(player)
 
320
  if row is None:
321
  return go.Figure()
322
 
323
+ comp = row[COMP_COL]
324
+ group = df[df[COMP_COL] == comp].copy()
 
325
 
326
  rows = []
 
327
  for metric in available_cols(PERCENTILE_METRICS):
328
+ value = row.get(metric, np.nan)
329
  values = pd.to_numeric(group[metric], errors="coerce").dropna()
 
330
  if pd.notna(value) and len(values) > 1:
331
  pct = (values < value).mean() * 100
332
+ rows.append({"Metric": label_col(metric), "Percentile": round(pct, 1), "Value": round(value, 2)})
 
 
 
 
333
 
334
+ if not rows:
 
 
335
  fig = go.Figure()
336
  fig.update_layout(title="No percentile data available.")
337
  return fig
338
 
339
+ plot_df = pd.DataFrame(rows)
340
  fig = px.bar(
341
  plot_df.sort_values("Percentile"),
342
+ x="Percentile", y="Metric", orientation="h",
 
 
343
  hover_data=["Value"],
344
+ title=f"{player} Percentiles vs Same Competition",
345
  range_x=[0, 100]
346
  )
 
347
  fig.update_layout(yaxis_title="", xaxis_title="Percentile")
348
  return fig
349
 
 
359
 
360
  if SEASON_COL not in df.columns or row_data[SEASON_COL].nunique() <= 1:
361
  fig = go.Figure()
362
+ fig.add_trace(go.Bar(x=[label_col(metric)], y=[row_data.iloc[0][metric]]))
 
 
 
363
  fig.update_layout(
364
+ title="Only one season in this file showing single-season value.",
365
  yaxis_title=label_col(metric)
366
  )
367
  return fig
368
 
369
  row_data[metric] = pd.to_numeric(row_data[metric], errors="coerce")
370
+ fig = px.line(row_data, x=SEASON_COL, y=metric, markers=True,
371
+ title=f"{player}: {label_col(metric)} Over Time")
 
 
 
 
 
 
 
372
  return fig
373
 
374
  # ============================================================
 
377
 
378
  def compare_players(player_1, player_2, player_3):
379
  players = [p for p in [player_1, player_2, player_3] if p]
 
380
  if not players:
381
  return pd.DataFrame({"Message": ["Select at least one player."]})
382
 
383
  data = df[df[PLAYER_COL].astype(str).isin(players)].copy()
384
 
385
  cols = available_cols([
386
+ PLAYER_COL, TEAM_COL, COMP_COL, POSITION_COL,
387
+ AGE_COL, MINUTES_COL, HEIGHT_COL,
388
+ ARCHETYPE_COL, ARCHETYPE_SCORE_COL, GK_SCORE_COL,
389
+ "shot_stopper_score", "sweeper_keeper_score",
390
+ "ball_playing_gk_score", "organiser_score",
 
 
 
 
 
 
 
391
  ] + KEY_METRICS + CATEGORY_METRICS)
392
 
393
  out = data[cols].copy()
 
 
 
 
394
  numeric_cols = out.select_dtypes(include=np.number).columns
395
  out[numeric_cols] = out[numeric_cols].round(2)
 
396
  return out.reset_index(drop=True)
397
 
398
  def comparison_radar(player_1, player_2, player_3):
399
  players = [p for p in [player_1, player_2, player_3] if p]
400
  metrics = available_cols(RADAR_METRICS)
 
401
  fig = go.Figure()
402
 
403
  if not players or len(metrics) < 3:
404
  fig.update_layout(title="Select players to compare.")
405
  return fig
406
 
407
+ labels = [label_col(m) for m in metrics]
 
408
  for player in players:
409
  row = get_player_row(player)
410
  if row is not None:
411
  fig.add_trace(go.Scatterpolar(
412
+ r=[row[m] if pd.notna(row[m]) else 0 for m in metrics],
413
+ theta=labels, fill="toself", name=str(player)
 
 
414
  ))
415
 
416
  fig.update_layout(
417
  title="Side-by-Side Radar Comparison",
418
+ polar=dict(radialaxis=dict(visible=True, range=[0, 100])),
419
  showlegend=True
420
  )
 
421
  return fig
422
 
423
  # ============================================================
424
  # FIT SCORE CALCULATOR
425
  # ============================================================
426
 
427
+ def fit_score(shot_stopping_w, sweeping_w, short_passing_w, long_passing_w, ball_claiming_w, overall_value_w):
428
  weights = {
429
+ "gk_cat_shot_stopping": shot_stopping_w,
430
+ "gk_cat_sweeping": sweeping_w,
431
+ "gk_cat_short_passing": short_passing_w,
432
+ "gk_cat_long_passing": long_passing_w,
433
+ "gk_cat_ball_claiming": ball_claiming_w,
434
+ "gk_cat_overall_value": overall_value_w,
 
 
 
435
  }
436
 
437
  data = df.copy()
 
441
  return pd.DataFrame({"Message": ["At least one weight must be above 0."]})
442
 
443
  score = 0
 
444
  for col, weight in weights.items():
445
  if col in data.columns:
446
  values = pd.to_numeric(data[col], errors="coerce")
447
+ min_v, max_v = values.min(), values.max()
 
 
448
  if pd.notna(min_v) and pd.notna(max_v) and max_v != min_v:
449
  normalized = ((values - min_v) / (max_v - min_v)) * 100
450
  else:
451
  normalized = values
 
452
  score += normalized.fillna(0) * weight
453
 
454
  data["custom_fit_score"] = score / total_weight
455
 
456
  cols = available_cols([
457
+ PLAYER_COL, TEAM_COL, COMP_COL, AGE_COL, MINUTES_COL,
458
+ ARCHETYPE_COL, ARCHETYPE_SCORE_COL, GK_SCORE_COL,
 
 
 
 
 
 
 
459
  ]) + ["custom_fit_score"]
460
 
461
  out = data[cols].sort_values("custom_fit_score", ascending=False).head(25).copy()
 
 
 
 
462
  numeric_cols = out.select_dtypes(include=np.number).columns
463
  out[numeric_cols] = out[numeric_cols].round(2)
 
464
  return out.reset_index(drop=True)
465
 
466
  # ============================================================
 
469
 
470
  def similar_players(player):
471
  row = get_player_row(player)
 
472
  if row is None:
473
  return pd.DataFrame({"Message": ["Select a player."]})
474
 
475
+ metrics = available_cols(CATEGORY_METRICS + KEY_METRICS)
476
+ candidates = df[df[PLAYER_COL].astype(str) != str(player)].copy()
 
 
 
 
 
 
 
 
 
 
477
 
478
  for metric in metrics:
479
  candidates[metric] = pd.to_numeric(candidates[metric], errors="coerce")
480
+ sd = pd.to_numeric(df[metric], errors="coerce").std()
481
+ player_val = row[metric] if pd.notna(row[metric]) else 0
 
482
  if pd.isna(sd) or sd == 0:
483
  candidates[f"dist_{metric}"] = 0
484
  else:
485
+ candidates[f"dist_{metric}"] = ((candidates[metric] - player_val) / sd) ** 2
486
 
487
  dist_cols = [f"dist_{m}" for m in metrics]
488
  candidates["similarity_distance"] = candidates[dist_cols].sum(axis=1)
489
+ candidates["similarity_score"] = 100 / (1 + candidates["similarity_distance"])
490
 
491
  cols = available_cols([
492
+ PLAYER_COL, TEAM_COL, COMP_COL, AGE_COL, MINUTES_COL,
493
+ ARCHETYPE_COL, ARCHETYPE_SCORE_COL, GK_SCORE_COL,
 
 
 
 
 
 
 
494
  ]) + ["similarity_score"]
495
 
496
  out = candidates[cols].sort_values("similarity_score", ascending=False).head(5).copy()
 
 
 
 
497
  numeric_cols = out.select_dtypes(include=np.number).columns
498
  out[numeric_cols] = out[numeric_cols].round(2)
 
499
  return out.reset_index(drop=True)
500
 
501
  # ============================================================
 
504
 
505
  def add_to_shortlist(player):
506
  global shortlist
 
507
  if player and player not in shortlist:
508
  shortlist.append(player)
 
509
  return view_shortlist()
510
 
511
  def clear_shortlist():
 
518
  return pd.DataFrame({"Message": ["No players added to shortlist yet."]})
519
 
520
  data = df[df[PLAYER_COL].astype(str).isin(shortlist)].copy()
 
521
  cols = available_cols([
522
+ PLAYER_COL, TEAM_COL, COMP_COL, POSITION_COL,
523
+ AGE_COL, MINUTES_COL,
524
+ ARCHETYPE_COL, ARCHETYPE_SCORE_COL, GK_SCORE_COL,
 
 
 
 
 
 
 
525
  ] + KEY_METRICS)
 
526
  out = data[cols].copy()
 
 
 
 
527
  numeric_cols = out.select_dtypes(include=np.number).columns
528
  out[numeric_cols] = out[numeric_cols].round(2)
 
529
  return out.reset_index(drop=True)
530
 
531
  def export_shortlist_csv():
532
  if not shortlist:
533
  return None
 
534
  data = df[df[PLAYER_COL].astype(str).isin(shortlist)].copy()
535
  out_file = "shortlist_export.csv"
536
  data.to_csv(out_file, index=False)
 
542
 
543
  def export_player_report(player, notes):
544
  row = get_player_row(player)
 
545
  if row is None:
546
  return None
547
 
548
  report = []
549
+ report.append(f"GK Scouting Report: {row[PLAYER_COL]}")
550
  report.append("=" * 60)
551
  report.append("")
552
+ report.append(f"Club: {row.get(TEAM_COL, 'N/A')}")
553
  report.append(f"Competition: {row.get(COMP_COL, 'N/A')}")
554
+ report.append(f"Position: {row.get(POSITION_COL, 'N/A')}")
555
+ report.append(f"Age: {round(row.get(AGE_COL, np.nan), 1) if pd.notna(row.get(AGE_COL, np.nan)) else 'N/A'}")
556
+ report.append(f"Height: {row.get(HEIGHT_COL, 'N/A')} cm")
557
+ report.append(f"Minutes: {round(row.get(MINUTES_COL, 0), 0)}")
 
558
  report.append("")
559
+ report.append("GK Scoring")
560
  report.append("-" * 60)
561
+ report.append(f"Best Archetype: {row.get(ARCHETYPE_COL, 'N/A')}")
562
+ report.append(f"Best Archetype Score: {round(row.get(ARCHETYPE_SCORE_COL, np.nan), 2) if pd.notna(row.get(ARCHETYPE_SCORE_COL, np.nan)) else 'N/A'}")
563
+ report.append(f"Overall GK Score: {round(row.get(GK_SCORE_COL, np.nan), 2) if pd.notna(row.get(GK_SCORE_COL, np.nan)) else 'N/A'}")
 
564
  report.append("")
565
+ report.append("Archetype Scores")
566
+ report.append("-" * 60)
567
+ for col in ["shot_stopper_score", "sweeper_keeper_score", "ball_playing_gk_score", "organiser_score"]:
568
+ if col in df.columns:
569
+ val = row.get(col, np.nan)
570
+ if pd.notna(val):
571
+ report.append(f"{label_col(col.replace('_score', ''))}: {round(val, 2)}")
572
+ report.append("")
573
+ report.append("Key Season Metrics")
574
  report.append("-" * 60)
 
575
  for col in available_cols(KEY_METRICS):
576
  value = row.get(col, np.nan)
577
  if pd.notna(value):
578
  report.append(f"{label_col(col)}: {round(value, 2)}")
 
579
  report.append("")
580
  report.append("Category Scores")
581
  report.append("-" * 60)
 
582
  for col in available_cols(CATEGORY_METRICS):
583
  value = row.get(col, np.nan)
584
  if pd.notna(value):
585
+ report.append(f"{label_col(col)}: {round(value, 2)}")
 
586
  report.append("")
587
  report.append("Scout Notes")
588
  report.append("-" * 60)
589
  report.append(notes if notes else "No notes entered.")
590
 
591
  safe_name = str(row[PLAYER_COL]).replace(" ", "_").replace("/", "_")
592
+ out_file = f"{safe_name}_gk_scouting_report.txt"
 
593
  with open(out_file, "w", encoding="utf-8") as f:
594
  f.write("\n".join(report))
 
595
  return out_file
596
 
597
  # ============================================================
598
  # APP LAYOUT
599
  # ============================================================
600
 
601
+ with gr.Blocks(title="Oldham Athletic GK Scouting") as app:
602
 
603
  gr.Markdown(
604
  """
605
+ # Oldham Athletic GK Scouting
606
+ Interactive goalkeeper scouting dashboard powered by StatsBomb data.
 
607
  """
608
  )
609
 
610
  with gr.Tab("Player Search"):
611
+ gr.Markdown("## Search and Filter Goalkeepers")
612
+
613
  with gr.Row():
614
  search_box = gr.Textbox(label="Search Player Name")
 
615
  competition_filter = gr.Dropdown(
616
+ choices=competition_options, value=[], label="Competition", multiselect=True
 
 
 
617
  )
 
618
  team_filter = gr.Dropdown(
619
+ choices=team_options, value=[], label="Team", multiselect=True
 
 
 
620
  )
621
+
622
  with gr.Row():
623
  position_filter = gr.Dropdown(
624
+ choices=position_options, value=[], label="Position", multiselect=True
 
 
 
625
  )
626
+ min_age_filter = gr.Slider(minimum=age_min, maximum=age_max, value=age_min, step=1, label="Minimum Age")
627
+ max_age_filter = gr.Slider(minimum=age_min, maximum=age_max, value=age_max, step=1, label="Maximum Age")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
  minutes_filter = gr.Slider(
629
  minimum=0,
630
  maximum=int(df[MINUTES_COL].max()) if MINUTES_COL in df.columns else 3000,
631
+ value=0, step=100, label="Minimum Minutes"
 
 
632
  )
633
+
634
+ search_button = gr.Button("Search Players")
635
+ search_results = gr.Dataframe(label="Goalkeeper Results (sorted by GK Score)", interactive=False)
636
+
637
  search_button.click(
638
  fn=search_players,
639
+ inputs=[search_box, competition_filter, team_filter, position_filter,
640
+ min_age_filter, max_age_filter, minutes_filter],
 
 
 
 
 
 
 
641
  outputs=search_results
642
  )
643
 
644
  with gr.Tab("Player Profile"):
645
+ gr.Markdown("## Full GK Profile")
646
 
647
  selected_player = gr.Dropdown(player_options, label="Select Player")
648
 
649
  with gr.Row():
650
+ profile_output = gr.Markdown()
651
+ category_output = gr.Dataframe(label="GK Category Scores", interactive=False)
652
 
653
  with gr.Row():
654
+ radar_output = gr.Plot(label="Radar Chart")
655
  percentile_output = gr.Plot(label="Percentile Bars")
656
 
657
  with gr.Row():
658
+ profile_metric = gr.Dropdown(
659
+ metric_options,
660
+ value=metric_options[0] if metric_options else None,
661
+ label="Performance Metric"
662
+ )
663
  trend_button = gr.Button("Show Performance Chart")
664
 
665
  trend_output = gr.Plot(label="Performance Over Time")
666
 
667
+ scout_notes = gr.Textbox(label="Scout Notes", lines=5, placeholder="Enter notes to include in the scouting report.")
668
+ report_button = gr.Button("Generate Scouting Report")
669
+ report_file = gr.File(label="Download Scouting Report")
670
 
671
+ shortlist_button = gr.Button("Add Player to Shortlist")
672
  shortlist_from_profile = gr.Dataframe(label="Current Shortlist", interactive=False)
673
 
674
+ selected_player.change(player_profile, selected_player, profile_output)
675
+ selected_player.change(category_table, selected_player, category_output)
676
+ selected_player.change(radar_chart, selected_player, radar_output)
677
  selected_player.change(percentile_chart, selected_player, percentile_output)
678
 
679
+ trend_button.click(performance_chart, [selected_player, profile_metric], trend_output)
680
+ report_button.click(export_player_report,[selected_player, scout_notes], report_file)
681
  shortlist_button.click(add_to_shortlist, selected_player, shortlist_from_profile)
682
 
683
  with gr.Tab("Player Comparison Tool"):
684
+ gr.Markdown("## Compare Up To Three Goalkeepers")
685
 
686
  with gr.Row():
687
  compare_1 = gr.Dropdown(player_options, label="Player 1")
688
  compare_2 = gr.Dropdown(player_options, label="Player 2")
689
  compare_3 = gr.Dropdown(player_options, label="Player 3")
690
 
691
+ compare_button = gr.Button("Compare Players")
692
+ comparison_table = gr.Dataframe(label="Stat Comparison Table", interactive=False)
693
+ comparison_radar_plot = gr.Plot(label="Side-by-Side Radar Chart")
 
694
 
695
  compare_button.click(compare_players, [compare_1, compare_2, compare_3], comparison_table)
696
  compare_button.click(comparison_radar, [compare_1, compare_2, compare_3], comparison_radar_plot)
 
698
  with gr.Tab("Fit Score Calculator"):
699
  gr.Markdown(
700
  """
701
+ ## Custom GK Fit Score Calculator
702
+ Weight the GK attributes that matter most to Oldham.
703
+ The app will rank all goalkeepers based on your custom profile.
 
704
  """
705
  )
706
 
707
  with gr.Row():
708
+ shot_stopping_w = gr.Slider(0, 10, value=5, step=1, label="Shot Stopping")
709
+ sweeping_w = gr.Slider(0, 10, value=5, step=1, label="Sweeping")
710
+ short_passing_w = gr.Slider(0, 10, value=5, step=1, label="Short Passing")
 
 
 
 
 
711
 
712
  with gr.Row():
713
+ long_passing_w = gr.Slider(0, 10, value=5, step=1, label="Long Passing")
714
+ ball_claiming_w = gr.Slider(0, 10, value=5, step=1, label="Ball Claiming")
715
+ overall_value_w = gr.Slider(0, 10, value=5, step=1, label="Overall Value")
716
 
717
  fit_button = gr.Button("Generate Ranked Recommendations")
718
+ fit_table = gr.Dataframe(label="Ranked Recommendations", interactive=False)
719
 
720
  fit_button.click(
721
  fit_score,
722
+ [shot_stopping_w, sweeping_w, short_passing_w, long_passing_w, ball_claiming_w, overall_value_w],
723
  fit_table
724
  )
725
 
726
  with gr.Tab("Similar Player Finder"):
727
+ gr.Markdown("## Find Similar Goalkeepers")
728
 
729
  similar_player_select = gr.Dropdown(player_options, label="Select Player")
730
+ similar_button = gr.Button("Find Similar Players")
731
+ similar_table = gr.Dataframe(label="Five Most Similar Goalkeepers", interactive=False)
732
 
733
  similar_button.click(similar_players, similar_player_select, similar_table)
734
 
735
  with gr.Tab("Shortlist Manager"):
736
  gr.Markdown("## Shortlist Manager")
737
 
738
+ shortlist_player = gr.Dropdown(player_options, label="Add Player")
739
+ add_shortlist_button = gr.Button("Add to Shortlist")
740
+ clear_shortlist_button = gr.Button("Clear Shortlist")
741
  export_shortlist_button = gr.Button("Export Shortlist CSV")
742
 
743
  shortlist_table = gr.Dataframe(label="Saved Players", interactive=False)
744
+ shortlist_file = gr.File(label="Download Shortlist CSV")
745
 
746
+ add_shortlist_button.click(add_to_shortlist, shortlist_player, shortlist_table)
747
+ clear_shortlist_button.click(clear_shortlist, None, shortlist_table)
748
+ export_shortlist_button.click(export_shortlist_csv, None, shortlist_file)
749
 
750
  app.launch()