MorelandMSBA commited on
Commit
ca54a16
·
verified ·
1 Parent(s): 67569c6

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +101 -54
  2. app_players_2023.csv +0 -0
app.py CHANGED
@@ -18,7 +18,6 @@ import numpy as np
18
  import gradio as gr
19
 
20
  HERE = os.path.dirname(os.path.abspath(__file__))
21
- DATA_PATH = os.path.join(HERE, "data", "app_players.csv")
22
 
23
  # Hudl IQ palette (sampled from hudl.com/products/football-iq)
24
  ORANGE = "#FF6300" # Hudl brand orange
@@ -33,14 +32,30 @@ TIER_ORDER = ["Elite", "Star", "Above Avg", "Starter", "Rotation", "Below"]
33
  TIER_COLORS = {"Elite": ORANGE, "Star": "#FF8A3D", "Above Avg": BLUE,
34
  "Starter": "#9DAAB8", "Rotation": "#7A8A99", "Below": "#4E5D6C"}
35
 
36
- if os.path.exists(DATA_PATH):
37
- df = pd.read_csv(DATA_PATH)
38
- else:
39
- df = pd.DataFrame()
 
 
 
 
 
 
 
 
40
 
41
 
42
- def _teams():
43
- return ["All"] + sorted(df["team_short_name"].dropna().unique().tolist()) if len(df) else ["All"]
 
 
 
 
 
 
 
 
44
 
45
 
46
  def top_route(p):
@@ -54,6 +69,13 @@ def top_route(p):
54
  return name.replace("_", "/")
55
 
56
 
 
 
 
 
 
 
 
57
  # ── card + carousel rendering ──────────────────────────────────────────────
58
 
59
  def _subtitle(p):
@@ -121,11 +143,12 @@ def _spotlight_cards(q, pos_label):
121
  return cards
122
 
123
 
124
- def spotlight_row():
125
  """Leaderboard hero row: WR and TE callouts side by side, six across."""
126
- if not len(df):
127
- return "<p>data/app_players.csv not found</p>"
128
- q = df[df["n"] >= 15]
 
129
  cards = []
130
  for pos in ("WR", "TE"):
131
  cards.extend(_spotlight_cards(q[q["position"] == pos], pos))
@@ -140,10 +163,11 @@ LB_LABELS = ["Player", "Team", "Pos", "Targets", "Separation Rating", "Tier",
140
  "Percentile", "Sched Rank", "RSOE (shrunk)"]
141
 
142
 
143
- def leaderboard(search, team, pos, tier, min_targets):
144
- if not len(df):
145
- return pd.DataFrame({"error": ["data/app_players.csv not found — run the prep notebook and add it"]})
146
- out = df.copy()
 
147
  if search:
148
  out = out[out["player_name"].str.contains(search, case=False, na=False)]
149
  if team != "All":
@@ -163,19 +187,13 @@ DRAFT_CLASSES = ["SR", "RS-SR", "Graduate"]
163
  ELIG_CHOICES = ["Any", "Returning (transfer candidates)", "Seniors & grads (draft)"]
164
 
165
 
166
- def _route_choices():
167
- if not len(df):
168
- return ["Any"]
169
- routes = sorted({top_route(p) for _, p in df.iterrows()} - {"n/a"})
170
- return ["Any"] + routes
171
-
172
-
173
- def recruit_search(pos, elig, tier, min_rating, min_targets, route, min_contested,
174
  min_rz, min_height, min_weight):
175
  """The recruiting finder: filter the board by what a staff is shopping for."""
176
- if not len(df):
177
- return "data/app_players.csv not found", "", pd.DataFrame()
178
- s = df.copy()
 
179
  s["top_route"] = s.apply(top_route, axis=1)
180
  if pos != "All":
181
  s = s[s["position"] == pos]
@@ -210,18 +228,19 @@ def recruit_search(pos, elig, tier, min_rating, min_targets, route, min_conteste
210
  view = s[["player_name", "team_short_name", "position", "class_type", "sep_rating",
211
  "tier", "shrunk", "n", "catches", "top_route", "contested_win_rate",
212
  "rz_rating"]].copy()
213
- view.columns = ["Player", "2022 Team", "Pos", "Class", "Separation Rating", "Tier",
214
  "RSOE", "Targets", "Catches", "Most frequent route",
215
  "Contested win %", "RZ rating"]
216
  return summary, _carousel(cards), view.reset_index(drop=True)
217
 
218
 
219
- def player_card(name):
220
- if not len(df) or not name:
 
221
  return "Select a player."
222
- m = df[df["player_name"] == name]
223
  if not len(m):
224
- return f"No player named {name}."
225
  p = m.iloc[0]
226
  rz = f"{int(p['rz_rating'])} ({int(p['rz_n'])} RZ targets)" if pd.notna(p.get("rz_rating")) else "not enough red zone targets"
227
  cw = f"{p['contested_win_rate']:.0f}% on {int(p['contested_att'])} contested" if pd.notna(p.get("contested_win_rate")) else "no contested targets"
@@ -230,7 +249,7 @@ def player_card(name):
230
  hw = f"{int(p['height_inches'] // 12)}'{int(p['height_inches'] % 12)}\" · {int(p['weight_pounds'])} lbs" if pd.notna(p.get("weight_pounds")) else ""
231
  cls = p.get("class_type") if pd.notna(p.get("class_type")) else "n/a"
232
  card = f"""
233
- ## {p['player_name']} — {p['team_short_name']} ({p['position']})
234
  # Separation Rating: {int(p['sep_rating'])} · {p['tier']}
235
  | | |
236
  |---|---|
@@ -251,12 +270,16 @@ def player_card(name):
251
  return card
252
 
253
 
254
- # ── Start Here explainers ──────────────────────────────────────────────────
 
 
 
255
 
256
  def _named(name):
257
- if not len(df):
 
258
  return None
259
- m = df[df["player_name"] == name]
260
  return m.iloc[0] if len(m) else None
261
 
262
 
@@ -266,7 +289,8 @@ def _fmt(p):
266
 
267
  def build_explainers():
268
  """(question, answer) pairs for the Start Here accordions, using live numbers."""
269
- top = df[df["n"] >= 15].sort_values(["sep_rating", "shrunk"], ascending=False).iloc[0] if len(df) else None
 
270
  hyatt, mayer, tinsley = _named("Jalin Hyatt"), _named("Michael Mayer"), _named("Mitchell Tinsley")
271
 
272
  q1 = ("What is RSOE?",
@@ -290,10 +314,10 @@ def build_explainers():
290
  "target count next to the number.\n\n"
291
  + (f"Face validity check: {_fmt(hyatt)}, the 2022 Biletnikoff winner, rates "
292
  f"**{int(hyatt['sep_rating'])}**, and his coverage schedule ranked "
293
- f"{int(hyatt['sched_rank'])} of {len(df)} in difficulty (1 = toughest), so the number is "
294
  f"not a soft-schedule artifact." if hyatt is not None else ""))
295
 
296
- med_n = int(df["n"].median()) if len(df) else 0
297
  q2b = ("How do we know the rating is skill and not luck?",
298
  "This was the central test in developing the metric: prove that average RSOE measures the "
299
  "player, not his bounces.\n\n"
@@ -314,6 +338,9 @@ def build_explainers():
314
  "gets pulled hard; a 110-target player barely moves. So the rating you see already carries the "
315
  "appropriate skepticism, and a 90 on 25 targets means the raw number was spectacular enough to "
316
  "survive aggressive shrinkage.\n\n"
 
 
 
317
  + (f"**The honest limit**: the median player on this board has {med_n} targets, well short of "
318
  f"100, so most single-season ratings lean heavily on shrinkage. That is the reality of "
319
  f"one-season data, not a flaw of the metric, and it is the case for adding more seasons. "
@@ -364,9 +391,10 @@ def build_explainers():
364
 
365
  HOW_TO = """
366
  ### How to use the app
 
367
  - **Leaderboard**: the spotlight row shows WR and TE standouts. Cut the board with search, team, position, tier, and min targets; the table sorts by Separation Rating.
368
  - **Player card**: below the leaderboard, pick a name for the full profile: rating, red zone rating, contested win rate, schedule rank, season line, and RSOE split by route and by coverage.
369
- - **Recruiting Finder**: set the profile your room needs (position, eligibility, tier, primary route, rating floor, contested, red zone, size) and hit **Find players**. The team column shows 2022 teams.
370
  - **Mind the sample**: ratings are half signal at about 100 targets. Shrinkage protects against small-sample flukes, but always glance at the target count on the card.
371
  - **Compare across conferences freely**: every rating is opponent-adjusted, so a Sun Belt 90 and an SEC 90 mean the same thing. That is the recruiting edge.
372
  """
@@ -386,10 +414,13 @@ METHODOLOGY = """
386
  - Predicted separation tracks reality across every zone of the field (red zone compression: actual 3.79 to 2.60,
387
  expected 3.81 to 2.66, bias under 0.06 yards everywhere).
388
  - Rankings survive model swaps (0.85-0.98 correlation across four model generations) and screen exclusion.
 
 
389
  - Face validity: the 2022 Biletnikoff winner ranks top-10; famous contested-catch specialists correctly rank
390
  low on separation and are flagged for the contested-catch lane instead.
391
  - The same framework produces defender ratings (separation allowed on-field): the 2022 leaders include
392
- Kool-Aid McKinstry, Brian Branch, and the Alabama secondary.
 
393
 
394
  ### Honest limits
395
  - One-play separation is noisy: a player's rating is half signal at roughly 100 targets, which is why shrinkage
@@ -428,9 +459,11 @@ THEME = gr.themes.Base(
428
  )
429
 
430
  with gr.Blocks(title="Separation Rating", theme=THEME, css=CSS, js=FORCE_DARK) as demo:
431
- gr.Markdown("# **Separation** Rating\n**Who really gets open?** Scheme- and opponent-adjusted receiver "
432
- "evaluation for D1 football, built on Hudl IQ data. UVA MSBA x Hudl.",
433
- elem_id="hero-title")
 
 
434
 
435
  with gr.Tab("Start Here"):
436
  gr.Markdown("## The metric, in plain football terms\n"
@@ -441,21 +474,21 @@ with gr.Blocks(title="Separation Rating", theme=THEME, css=CSS, js=FORCE_DARK) a
441
  gr.Markdown(HOW_TO)
442
 
443
  with gr.Tab("Leaderboard"):
444
- gr.HTML(spotlight_row())
445
  with gr.Row():
446
  search = gr.Textbox(label="Search player", placeholder="e.g., Hyatt")
447
- team = gr.Dropdown(_teams(), value="All", label="Team")
448
  pos = gr.Dropdown(["All", "WR", "TE"], value="All", label="Position")
449
  tierdd = gr.Dropdown(["All"] + TIER_ORDER, value="All", label="Tier")
450
  min_t = gr.Slider(10, 80, value=15, step=5, label="Min targets")
451
- table = gr.Dataframe(value=leaderboard("", "All", "All", "All", 15), interactive=False, wrap=True)
 
452
  for c in (search, team, pos, tierdd, min_t):
453
- c.change(leaderboard, [search, team, pos, tierdd, min_t], table)
454
  gr.Markdown("---\n## Player card")
455
- psel = gr.Dropdown(sorted(df["player_name"].dropna().unique().tolist()) if len(df) else [],
456
- label="Select player")
457
  card = gr.Markdown()
458
- psel.change(player_card, psel, card)
459
 
460
  with gr.Tab("Recruiting Finder"):
461
  gr.Markdown("## Build your target board\n"
@@ -466,7 +499,7 @@ with gr.Blocks(title="Separation Rating", theme=THEME, css=CSS, js=FORCE_DARK) a
466
  r_pos = gr.Dropdown(["All", "WR", "TE"], value="All", label="Position")
467
  r_elig = gr.Dropdown(ELIG_CHOICES, value="Any", label="Eligibility")
468
  r_tier = gr.Dropdown(["All"] + TIER_ORDER, value="All", label="Tier")
469
- r_route = gr.Dropdown(_route_choices(), value="Any", label="Primary route")
470
  r_rating = gr.Slider(40, 99, value=80, step=1, label="Min Separation Rating")
471
  r_targets = gr.Slider(10, 80, value=15, step=5, label="Min targets")
472
  with gr.Row():
@@ -476,20 +509,34 @@ with gr.Blocks(title="Separation Rating", theme=THEME, css=CSS, js=FORCE_DARK) a
476
  r_height = gr.Slider(66, 80, value=66, step=1, label="Min height, inches (66 = off)")
477
  r_weight = gr.Slider(150, 260, value=150, step=5, label="Min weight, lbs (150 = off)")
478
  r_btn = gr.Button("Find players", variant="primary")
479
- _s0, _c0, _t0 = recruit_search("All", "Any", "All", 80, 15, "Any", 0, 40, 66, 150)
480
  r_summary = gr.Markdown(_s0)
481
  r_cards = gr.HTML(_c0)
482
  r_table = gr.Dataframe(value=_t0, interactive=False, wrap=True)
483
- r_inputs = [r_pos, r_elig, r_tier, r_rating, r_targets, r_route,
484
  r_contested, r_rz, r_height, r_weight]
485
  r_btn.click(recruit_search, r_inputs, [r_summary, r_cards, r_table])
486
  gr.Markdown("*Low separation + high contested win rate is its own lane (the big-body "
487
  "archetype), not a red flag: set the contested slider and drop the rating "
488
- "floor to shop that profile. Eligibility is class-based from 2022 rosters; "
489
  "early draft declares and actual transfer decisions are not tracked.*")
490
 
491
  with gr.Accordion("Methodology & validation", open=False):
492
  gr.Markdown(METHODOLOGY)
493
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  if __name__ == "__main__":
495
  demo.launch()
 
18
  import gradio as gr
19
 
20
  HERE = os.path.dirname(os.path.abspath(__file__))
 
21
 
22
  # Hudl IQ palette (sampled from hudl.com/products/football-iq)
23
  ORANGE = "#FF6300" # Hudl brand orange
 
32
  TIER_COLORS = {"Elite": ORANGE, "Star": "#FF8A3D", "Above Avg": BLUE,
33
  "Starter": "#9DAAB8", "Rotation": "#7A8A99", "Below": "#4E5D6C"}
34
 
35
+ # ── season data: one flat file per season in data/ ─────────────────────────
36
+ SEASON_FILES = {"2022": "app_players.csv", "2023": "app_players_2023.csv"}
37
+ DFS = {}
38
+ for _season, _fname in SEASON_FILES.items():
39
+ _p = os.path.join(HERE, "data", _fname)
40
+ if os.path.exists(_p):
41
+ _d = pd.read_csv(_p)
42
+ # decode-file placeholder weights (e.g., 0 lbs) display as n/a instead
43
+ _d.loc[_d["weight_pounds"].notna() & (_d["weight_pounds"] < 100), "weight_pounds"] = np.nan
44
+ DFS[_season] = _d
45
+ SEASONS = list(DFS.keys()) or ["2022"]
46
+ DEFAULT_SEASON = SEASONS[-1] # latest season present
47
 
48
 
49
+ def _df(season):
50
+ return DFS.get(season, pd.DataFrame())
51
+
52
+
53
+ def _teams(d):
54
+ return ["All"] + sorted(d["team_short_name"].dropna().unique().tolist()) if len(d) else ["All"]
55
+
56
+
57
+ def _players(d):
58
+ return sorted(d["player_name"].dropna().unique().tolist()) if len(d) else []
59
 
60
 
61
  def top_route(p):
 
69
  return name.replace("_", "/")
70
 
71
 
72
+ def _route_choices(d):
73
+ if not len(d):
74
+ return ["Any"]
75
+ routes = sorted({top_route(p) for _, p in d.iterrows()} - {"n/a"})
76
+ return ["Any"] + routes
77
+
78
+
79
  # ── card + carousel rendering ──────────────────────────────────────────────
80
 
81
  def _subtitle(p):
 
143
  return cards
144
 
145
 
146
+ def spotlight_row(season):
147
  """Leaderboard hero row: WR and TE callouts side by side, six across."""
148
+ d = _df(season)
149
+ if not len(d):
150
+ return f"<p>data/{SEASON_FILES.get(season, '?')} not found</p>"
151
+ q = d[d["n"] >= 15]
152
  cards = []
153
  for pos in ("WR", "TE"):
154
  cards.extend(_spotlight_cards(q[q["position"] == pos], pos))
 
163
  "Percentile", "Sched Rank", "RSOE (shrunk)"]
164
 
165
 
166
+ def leaderboard(season, search, team, pos, tier, min_targets):
167
+ d = _df(season)
168
+ if not len(d):
169
+ return pd.DataFrame({"error": [f"data/{SEASON_FILES.get(season, '?')} not found — run the prep notebook and add it"]})
170
+ out = d.copy()
171
  if search:
172
  out = out[out["player_name"].str.contains(search, case=False, na=False)]
173
  if team != "All":
 
187
  ELIG_CHOICES = ["Any", "Returning (transfer candidates)", "Seniors & grads (draft)"]
188
 
189
 
190
+ def recruit_search(season, pos, elig, tier, min_rating, min_targets, route, min_contested,
 
 
 
 
 
 
 
191
  min_rz, min_height, min_weight):
192
  """The recruiting finder: filter the board by what a staff is shopping for."""
193
+ d = _df(season)
194
+ if not len(d):
195
+ return f"data/{SEASON_FILES.get(season, '?')} not found", "", pd.DataFrame()
196
+ s = d.copy()
197
  s["top_route"] = s.apply(top_route, axis=1)
198
  if pos != "All":
199
  s = s[s["position"] == pos]
 
228
  view = s[["player_name", "team_short_name", "position", "class_type", "sep_rating",
229
  "tier", "shrunk", "n", "catches", "top_route", "contested_win_rate",
230
  "rz_rating"]].copy()
231
+ view.columns = ["Player", f"{season} Team", "Pos", "Class", "Separation Rating", "Tier",
232
  "RSOE", "Targets", "Catches", "Most frequent route",
233
  "Contested win %", "RZ rating"]
234
  return summary, _carousel(cards), view.reset_index(drop=True)
235
 
236
 
237
+ def player_card(season, name):
238
+ d = _df(season)
239
+ if not len(d) or not name:
240
  return "Select a player."
241
+ m = d[d["player_name"] == name]
242
  if not len(m):
243
+ return f"No player named {name} in {season}."
244
  p = m.iloc[0]
245
  rz = f"{int(p['rz_rating'])} ({int(p['rz_n'])} RZ targets)" if pd.notna(p.get("rz_rating")) else "not enough red zone targets"
246
  cw = f"{p['contested_win_rate']:.0f}% on {int(p['contested_att'])} contested" if pd.notna(p.get("contested_win_rate")) else "no contested targets"
 
249
  hw = f"{int(p['height_inches'] // 12)}'{int(p['height_inches'] % 12)}\" · {int(p['weight_pounds'])} lbs" if pd.notna(p.get("weight_pounds")) else ""
250
  cls = p.get("class_type") if pd.notna(p.get("class_type")) else "n/a"
251
  card = f"""
252
+ ## {p['player_name']} — {p['team_short_name']} ({p['position']}) · {season}
253
  # Separation Rating: {int(p['sep_rating'])} · {p['tier']}
254
  | | |
255
  |---|---|
 
270
  return card
271
 
272
 
273
+ # ── Start Here explainers (pinned to 2022, the season the numbers cite) ────
274
+
275
+ EXPLAIN_SEASON = "2022" if "2022" in DFS else DEFAULT_SEASON
276
+
277
 
278
  def _named(name):
279
+ d = _df(EXPLAIN_SEASON)
280
+ if not len(d):
281
  return None
282
+ m = d[d["player_name"] == name]
283
  return m.iloc[0] if len(m) else None
284
 
285
 
 
289
 
290
  def build_explainers():
291
  """(question, answer) pairs for the Start Here accordions, using live numbers."""
292
+ d = _df(EXPLAIN_SEASON)
293
+ top = d[d["n"] >= 15].sort_values(["sep_rating", "shrunk"], ascending=False).iloc[0] if len(d) else None
294
  hyatt, mayer, tinsley = _named("Jalin Hyatt"), _named("Michael Mayer"), _named("Mitchell Tinsley")
295
 
296
  q1 = ("What is RSOE?",
 
314
  "target count next to the number.\n\n"
315
  + (f"Face validity check: {_fmt(hyatt)}, the 2022 Biletnikoff winner, rates "
316
  f"**{int(hyatt['sep_rating'])}**, and his coverage schedule ranked "
317
+ f"{int(hyatt['sched_rank'])} of {len(d)} in difficulty (1 = toughest), so the number is "
318
  f"not a soft-schedule artifact." if hyatt is not None else ""))
319
 
320
+ med_n = int(d["n"].median()) if len(d) else 0
321
  q2b = ("How do we know the rating is skill and not luck?",
322
  "This was the central test in developing the metric: prove that average RSOE measures the "
323
  "player, not his bounces.\n\n"
 
338
  "gets pulled hard; a 110-target player barely moves. So the rating you see already carries the "
339
  "appropriate skepticism, and a 90 on 25 targets means the raw number was spectacular enough to "
340
  "survive aggressive shrinkage.\n\n"
341
+ "**Season two confirmed it.** With 2023 in hand, a player's 2022 rating predicts his 2023 "
342
+ "result at roughly 85% of the theoretical maximum his sample size allows. The signal is real "
343
+ "and it persists.\n\n"
344
  + (f"**The honest limit**: the median player on this board has {med_n} targets, well short of "
345
  f"100, so most single-season ratings lean heavily on shrinkage. That is the reality of "
346
  f"one-season data, not a flaw of the metric, and it is the case for adding more seasons. "
 
391
 
392
  HOW_TO = """
393
  ### How to use the app
394
+ - **Season**: the selector at the top switches every tab between the 2022 and 2023 boards.
395
  - **Leaderboard**: the spotlight row shows WR and TE standouts. Cut the board with search, team, position, tier, and min targets; the table sorts by Separation Rating.
396
  - **Player card**: below the leaderboard, pick a name for the full profile: rating, red zone rating, contested win rate, schedule rank, season line, and RSOE split by route and by coverage.
397
+ - **Recruiting Finder**: set the profile your room needs (position, eligibility, tier, primary route, rating floor, contested, red zone, size) and hit **Find players**.
398
  - **Mind the sample**: ratings are half signal at about 100 targets. Shrinkage protects against small-sample flukes, but always glance at the target count on the card.
399
  - **Compare across conferences freely**: every rating is opponent-adjusted, so a Sun Belt 90 and an SEC 90 mean the same thing. That is the recruiting edge.
400
  """
 
414
  - Predicted separation tracks reality across every zone of the field (red zone compression: actual 3.79 to 2.60,
415
  expected 3.81 to 2.66, bias under 0.06 yards everywhere).
416
  - Rankings survive model swaps (0.85-0.98 correlation across four model generations) and screen exclusion.
417
+ - The same frozen model spec generalizes across seasons (2022 R-squared 0.283, 2023 R-squared 0.295), and a
418
+ player's 2022 rating predicts his 2023 result at roughly 85% of the ceiling his sample size allows.
419
  - Face validity: the 2022 Biletnikoff winner ranks top-10; famous contested-catch specialists correctly rank
420
  low on separation and are flagged for the contested-catch lane instead.
421
  - The same framework produces defender ratings (separation allowed on-field): the 2022 leaders include
422
+ Kool-Aid McKinstry, Brian Branch, and the Alabama secondary; the 2023 leaders are headlined by the
423
+ Florida State secondary.
424
 
425
  ### Honest limits
426
  - One-play separation is noisy: a player's rating is half signal at roughly 100 targets, which is why shrinkage
 
459
  )
460
 
461
  with gr.Blocks(title="Separation Rating", theme=THEME, css=CSS, js=FORCE_DARK) as demo:
462
+ with gr.Row():
463
+ gr.Markdown("# **Separation** Rating\n**Who really gets open?** Scheme- and opponent-adjusted receiver "
464
+ "evaluation for D1 football, built on Hudl IQ data. UVA MSBA x Hudl.",
465
+ elem_id="hero-title", scale=4)
466
+ season = gr.Radio(SEASONS, value=DEFAULT_SEASON, label="Season", scale=1)
467
 
468
  with gr.Tab("Start Here"):
469
  gr.Markdown("## The metric, in plain football terms\n"
 
474
  gr.Markdown(HOW_TO)
475
 
476
  with gr.Tab("Leaderboard"):
477
+ spot = gr.HTML(spotlight_row(DEFAULT_SEASON))
478
  with gr.Row():
479
  search = gr.Textbox(label="Search player", placeholder="e.g., Hyatt")
480
+ team = gr.Dropdown(_teams(_df(DEFAULT_SEASON)), value="All", label="Team")
481
  pos = gr.Dropdown(["All", "WR", "TE"], value="All", label="Position")
482
  tierdd = gr.Dropdown(["All"] + TIER_ORDER, value="All", label="Tier")
483
  min_t = gr.Slider(10, 80, value=15, step=5, label="Min targets")
484
+ table = gr.Dataframe(value=leaderboard(DEFAULT_SEASON, "", "All", "All", "All", 15),
485
+ interactive=False, wrap=True)
486
  for c in (search, team, pos, tierdd, min_t):
487
+ c.change(leaderboard, [season, search, team, pos, tierdd, min_t], table)
488
  gr.Markdown("---\n## Player card")
489
+ psel = gr.Dropdown(_players(_df(DEFAULT_SEASON)), label="Select player")
 
490
  card = gr.Markdown()
491
+ psel.change(player_card, [season, psel], card)
492
 
493
  with gr.Tab("Recruiting Finder"):
494
  gr.Markdown("## Build your target board\n"
 
499
  r_pos = gr.Dropdown(["All", "WR", "TE"], value="All", label="Position")
500
  r_elig = gr.Dropdown(ELIG_CHOICES, value="Any", label="Eligibility")
501
  r_tier = gr.Dropdown(["All"] + TIER_ORDER, value="All", label="Tier")
502
+ r_route = gr.Dropdown(_route_choices(_df(DEFAULT_SEASON)), value="Any", label="Primary route")
503
  r_rating = gr.Slider(40, 99, value=80, step=1, label="Min Separation Rating")
504
  r_targets = gr.Slider(10, 80, value=15, step=5, label="Min targets")
505
  with gr.Row():
 
509
  r_height = gr.Slider(66, 80, value=66, step=1, label="Min height, inches (66 = off)")
510
  r_weight = gr.Slider(150, 260, value=150, step=5, label="Min weight, lbs (150 = off)")
511
  r_btn = gr.Button("Find players", variant="primary")
512
+ _s0, _c0, _t0 = recruit_search(DEFAULT_SEASON, "All", "Any", "All", 80, 15, "Any", 0, 40, 66, 150)
513
  r_summary = gr.Markdown(_s0)
514
  r_cards = gr.HTML(_c0)
515
  r_table = gr.Dataframe(value=_t0, interactive=False, wrap=True)
516
+ r_inputs = [season, r_pos, r_elig, r_tier, r_rating, r_targets, r_route,
517
  r_contested, r_rz, r_height, r_weight]
518
  r_btn.click(recruit_search, r_inputs, [r_summary, r_cards, r_table])
519
  gr.Markdown("*Low separation + high contested win rate is its own lane (the big-body "
520
  "archetype), not a red flag: set the contested slider and drop the rating "
521
+ "floor to shop that profile. Eligibility is class-based from the season's rosters; "
522
  "early draft declares and actual transfer decisions are not tracked.*")
523
 
524
  with gr.Accordion("Methodology & validation", open=False):
525
  gr.Markdown(METHODOLOGY)
526
 
527
+ def _on_season(s):
528
+ d = _df(s)
529
+ s0, c0, t0 = recruit_search(s, "All", "Any", "All", 80, 15, "Any", 0, 40, 66, 150)
530
+ return (spotlight_row(s),
531
+ gr.update(choices=_teams(d), value="All"),
532
+ leaderboard(s, "", "All", "All", "All", 15),
533
+ gr.update(choices=_players(d), value=None),
534
+ "Select a player.",
535
+ gr.update(choices=_route_choices(d), value="Any"),
536
+ s0, c0, t0)
537
+
538
+ season.change(_on_season, season,
539
+ [spot, team, table, psel, card, r_route, r_summary, r_cards, r_table])
540
+
541
  if __name__ == "__main__":
542
  demo.launch()
app_players_2023.csv ADDED
The diff for this file is too large to render. See raw diff