Skip to content

Commit e95538c

Browse files
ejmccallaclaude
andcommitted
Update for 2026gal: event key, bug fixes, prior event DPR/fouls blending
- schedule_picklist.ps1: update event key to 2026gal, start date 2026-04-29 - tba.py: fix prior event selection to skip events with no COPR/OPR data; add get_prior_event_oprs() for per-team prior DPR fallback - frc2026_picklist_analysis.py: fix per-team EPA weight redistribution when prior COPR missing; add blended foulPoints and dprs to picklist output; fix Unicode arrow in print statement - frc2026_picklist_runner.py: wire in prior_oprs_df; fix first_event_teams to use teams_with_prior set instead of prior_coprs_df index - .gitignore: exclude *.csv and *.txt local cache files - CLAUDE.md: add initial codebase documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 347a416 commit e95538c

6 files changed

Lines changed: 318 additions & 45 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,5 +161,9 @@ cython_debug/
161161

162162
.ps_history
163163

164+
# Local data cache
165+
*.csv
166+
*.txt
167+
164168
# Claude Code memory
165169
memory/

CLAUDE.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Commands
6+
7+
**Setup (first time):**
8+
```bash
9+
setup.bat # Creates venv, installs deps, installs pre-commit hooks
10+
```
11+
12+
**Run analysis:**
13+
```bash
14+
run_picklist --event_key <key> # Basic run (e.g. 2026ndgf)
15+
run_picklist --event_key <key> --save # Save event-specific JSON to GitHub
16+
run_picklist --event_key <key> --save --post # Also update latest_*.json (what webapp reads)
17+
run_picklist --event_key <key> --force # Re-fetch all cached data
18+
run_picklist --event_key <key> --teams 4607 1234 # Analyze specific teams only
19+
```
20+
21+
**Scheduling during events (Windows):**
22+
```powershell
23+
schedule_picklist.ps1 # Register scheduled task (every 30 min, 9am–7pm)
24+
unschedule_picklist.ps1 # Remove scheduled task
25+
```
26+
27+
**Linting/formatting:**
28+
```bash
29+
ruff format src/ # Format Python
30+
ruff check src/ --fix # Lint with auto-fix
31+
```
32+
Pre-commit hooks run both automatically on every commit.
33+
34+
## Architecture
35+
36+
This is a Python pipeline that pulls scouting data from multiple sources, blends them into team rankings, and publishes JSON to GitHub where a static webapp reads it.
37+
38+
**Entry point:** `src/scouting_analysis/frc2026_picklist_runner.py``main()` (registered as `run_picklist` CLI command in `pyproject.toml`)
39+
40+
### Data Sources
41+
42+
| Module | Source | What it provides |
43+
|--------|--------|-----------------|
44+
| `tba.py` | The Blue Alliance API | Team list, match schedule, match breakdowns, COPR, OPR |
45+
| `sb.py` | Statbotics API | EPA (Expected Points Added) per phase per team |
46+
| `sdb.py` | `api2.lanzersys.com` (4607 internal) | Raw scouting observations, pit data (hopper size) |
47+
48+
All fetched data is cached locally as CSV files and reused on subsequent runs. `--force` bypasses the cache.
49+
50+
### Core Scoring (frc2026_picklist_analysis.py)
51+
52+
`FRC2026PicklistAnalysis` blends data with **dynamic weights** that shift as the event progresses:
53+
54+
```
55+
alpha = played_quals / total_quals # 0.0 → 1.0 through the event
56+
copr_weight = alpha * 0.75 # 0% → 75%
57+
epa_weight = 1.0 - copr_weight # 100% → 25%
58+
59+
AUTO/TELEOP score = 10% scouting + copr_weight*COPR + epa_weight*EPA
60+
ENDGAME score = 50% TBA climb data + 50% EPA endgame
61+
```
62+
63+
This means early in an event the score is EPA-dominated (prediction); late in the event it's COPR-dominated (observed).
64+
65+
### Output & Publishing
66+
67+
The runner generates four JSON files and pushes them to this GitHub repo via REST API:
68+
- `webapp/{event_key}_picklist.json` / `webapp/{event_key}_planner.json` — historical per-event
69+
- `webapp/latest_picklist.json` / `webapp/latest_planner.json` — what the webapp actively reads (`--post` flag)
70+
71+
GitHub Actions (`.github/workflows/notify_slack.yaml`) detects pushes to `webapp/latest_*.json` and sends a Slack notification.
72+
73+
### Webapp
74+
75+
Static HTML/CSS/JS in `webapp/` — no build step. `planner.html` and `picklist.html` fetch JSON from `raw.githubusercontent.com`. Changes to HTML deploy immediately on push.
76+
77+
## Environment
78+
79+
Requires a `.env` file (never committed) with:
80+
```
81+
X-TBA-Auth-Key=<TBA personal API key>
82+
GITHUB_TOKEN=<GitHub PAT with repo write access>
83+
WORKSPACE=<absolute path to project root>
84+
PYTHONPYCACHEPREFIX=C:\Windows\Temp
85+
```
86+
87+
## Key Implementation Details
88+
89+
**CSV parsing in `sdb.py`:** The internal scouting database returns malformed CSV (rows split across lines, extra commas in text fields). The parser explicitly handles both cases: stitching split rows together and truncating over-wide rows at the comments column.
90+
91+
**Climb scoring:** Auto=15pts (any level), Endgame Level1=10/Level2=20/Level3=30pts.
92+
93+
**Prior event COPRs:** For teams with prior events, the runner fetches and averages their previous COPR values as a fallback when current-event data is sparse.
94+
95+
**GitHub push:** Uses the GitHub Contents API (requires fetching the existing file's SHA before updating).
96+
97+
**NaN sanitization:** All floats are sanitized before `json.dumps` because `NaN` is invalid JSON.

schedule_picklist.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
$root = "C:\Users\ejmcc\Documents\FRC4607\Scouting-Analysis"
22

33
$action = New-ScheduledTaskAction -Execute "cmd.exe" `
4-
-Argument "/c cd /d $root && venv\Scripts\run_picklist.exe --event_key 2026mnmi2 --save --post" `
4+
-Argument "/c cd /d $root && venv\Scripts\run_picklist.exe --event_key 2026gal --save --post" `
55
-WorkingDirectory $root
66

77
$trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) `
8-
-Once -At "2026-04-10 09:00" -RepetitionDuration (New-TimeSpan -Hours 10)
8+
-Once -At "2026-04-29 09:00" -RepetitionDuration (New-TimeSpan -Hours 10)
99

1010
Register-ScheduledTask -TaskName "FRC4607-Picklist" -Action $action -Trigger $trigger
1111

src/scouting_analysis/frc2026_picklist_analysis.py

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ class FRC2026PicklistAnalysis:
2323
pits_df (pd.DataFrame): Pits database rows for the event.
2424
coprs_df (pd.DataFrame): TBA COPR data for the event.
2525
epa_df (pd.DataFrame): Statbotics EPA data for the event.
26+
prior_coprs_df (pd.DataFrame): COPR data from each team's most recent prior event.
27+
oprs_df (pd.DataFrame): TBA OPR/DPR data for the current event.
28+
prior_oprs_df (pd.DataFrame): OPR/DPR data from each team's most recent prior event.
2629
"""
2730

2831
def __init__(
@@ -34,13 +37,17 @@ def __init__(
3437
coprs_df: pd.DataFrame,
3538
epa_df: pd.DataFrame = pd.DataFrame(),
3639
prior_coprs_df: pd.DataFrame = pd.DataFrame(),
40+
oprs_df: pd.DataFrame = pd.DataFrame(),
41+
prior_oprs_df: pd.DataFrame = pd.DataFrame(),
3742
) -> None:
3843
self.scouting_df = scouting_df
3944
self.metric = metric
4045
self.pits_df = pits_df
4146
self.coprs_df = coprs_df
4247
self.epa_df = epa_df
4348
self.prior_coprs_df = prior_coprs_df
49+
self.oprs_df = oprs_df
50+
self.prior_oprs_df = prior_oprs_df
4451

4552
# Dynamic weights based on quals played.
4653
# With prior COPR: EPA fixed at 25%, prior→current COPR share the remaining 75%.
@@ -66,7 +73,7 @@ def __init__(
6673

6774
print(
6875
f"\nMatch progress: {played}/{total} quals played (alpha={alpha:.2f})"
69-
f" current_copr={self.current_copr_weight:.2f},"
76+
f" -> current_copr={self.current_copr_weight:.2f},"
7077
f" prior_copr={self.prior_copr_weight:.2f},"
7178
f" epa={self.epa_weight:.2f}\n"
7279
)
@@ -80,6 +87,8 @@ def __init__(
8087
self.auto_df = self._get_auto_summary()
8188
self.teleop_df = self._get_teleop_summary()
8289
self.endgame_df = self._get_tba_endgame_summary()
90+
self.fouls_df = self._get_fouls_summary()
91+
self.dpr_df = self._get_dpr_summary()
8392
self.rank_df = self._get_rank_summary()
8493
self.breakdown_df = self._get_breakdown_summary()
8594
self.comments_df = self._get_comments_summary()
@@ -99,13 +108,17 @@ def get_picklist_summary(self) -> pd.DataFrame:
99108
auto_pl = self.auto_df[["team_number", m, "n"]].rename(columns={m: f"auto_{m}"})
100109
teleop_pl = self.teleop_df[["team_number", m]].rename(columns={m: f"teleop_{m}"})
101110
endgame_pl = self.endgame_df[["team_number", m]].rename(columns={m: f"endgame_{m}"})
111+
fouls_pl = self.fouls_df[["team_number", "foulPoints"]]
112+
dpr_pl = self.dpr_df[["team_number", "dprs"]]
102113
rank_pl = self.rank_df[["team_number", "drive_rank", "defense_rank"]]
103114
breakdown_pl = self.breakdown_df[["team_number", "breakdown"]]
104115
comments_pl = self.comments_df[["team_number", "comments"]]
105116

106117
df = (
107118
auto_pl.merge(teleop_pl, on="team_number")
108119
.merge(endgame_pl, on="team_number", how="left")
120+
.merge(fouls_pl, on="team_number", how="left")
121+
.merge(dpr_pl, on="team_number", how="left")
109122
.merge(rank_pl, on="team_number")
110123
.merge(breakdown_pl, on="team_number")
111124
.merge(comments_pl, on="team_number")
@@ -129,7 +142,20 @@ def get_picklist_summary(self) -> pd.DataFrame:
129142
)
130143

131144
return df[
132-
["team", "score", "auto", "teleop", "endgame", "n", "drive_rank", "defense_rank", "breakdown", "comments"]
145+
[
146+
"team",
147+
"score",
148+
"auto",
149+
"teleop",
150+
"endgame",
151+
"n",
152+
"foulPoints",
153+
"dprs",
154+
"drive_rank",
155+
"defense_rank",
156+
"breakdown",
157+
"comments",
158+
]
133159
]
134160

135161
# ------------------------------------------------------------------ #
@@ -172,11 +198,13 @@ def _get_auto_summary(self) -> pd.DataFrame:
172198

173199
# Prior event COPR
174200
prior_copr_auto = pd.Series(0.0, index=df.index)
201+
has_prior_copr = pd.Series(False, index=df.index)
175202
if not self.prior_coprs_df.empty and "Hub Auto Fuel Count" in self.prior_coprs_df.columns:
176203
prior = self.prior_coprs_df[["Hub Auto Fuel Count"]].copy()
177204
prior["team_number"] = prior.index.str.replace("frc", "").astype("Int64")
178205
prior.rename(columns={"Hub Auto Fuel Count": "prior_copr_auto_fuel"}, inplace=True)
179206
df = df.merge(prior[["team_number", "prior_copr_auto_fuel"]], on="team_number", how="left")
207+
has_prior_copr = df["prior_copr_auto_fuel"].notna()
180208
prior_copr_auto = df["prior_copr_auto_fuel"].fillna(0).clip(lower=0)
181209

182210
# EPA
@@ -187,10 +215,10 @@ def _get_auto_summary(self) -> pd.DataFrame:
187215
df = df.merge(epa, on="team_number", how="left")
188216
epa_auto = df["auto_epa"].fillna(0)
189217

190-
# Weighted blend
191-
df["total_auto_points"] = (
192-
self.current_copr_weight * copr_auto + self.prior_copr_weight * prior_copr_auto + self.epa_weight * epa_auto
193-
)
218+
# Weighted blend — teams missing prior COPR data get that weight redistributed to EPA
219+
prior_w = self.prior_copr_weight * has_prior_copr.astype(float)
220+
epa_w = self.epa_weight + self.prior_copr_weight * (~has_prior_copr).astype(float)
221+
df["total_auto_points"] = self.current_copr_weight * copr_auto + prior_w * prior_copr_auto + epa_w * epa_auto
194222

195223
# Debug
196224
print("\n--- Auto Summary Debug ---")
@@ -247,11 +275,13 @@ def _get_teleop_summary(self) -> pd.DataFrame:
247275

248276
# Prior event COPR
249277
prior_copr_teleop = pd.Series(0.0, index=df.index)
278+
has_prior_copr = pd.Series(False, index=df.index)
250279
if not self.prior_coprs_df.empty and "Hub Teleop Fuel Count" in self.prior_coprs_df.columns:
251280
prior = self.prior_coprs_df[["Hub Teleop Fuel Count"]].copy()
252281
prior["team_number"] = prior.index.str.replace("frc", "").astype("Int64")
253282
prior.rename(columns={"Hub Teleop Fuel Count": "prior_copr_teleop"}, inplace=True)
254283
df = df.merge(prior[["team_number", "prior_copr_teleop"]], on="team_number", how="left")
284+
has_prior_copr = df["prior_copr_teleop"].notna()
255285
prior_copr_teleop = df["prior_copr_teleop"].fillna(0).clip(lower=0)
256286

257287
# EPA
@@ -262,11 +292,11 @@ def _get_teleop_summary(self) -> pd.DataFrame:
262292
df = df.merge(epa, on="team_number", how="left")
263293
epa_teleop = df["teleop_epa"].fillna(0)
264294

265-
# Weighted blend
295+
# Weighted blend — teams missing prior COPR data get that weight redistributed to EPA
296+
prior_w = self.prior_copr_weight * has_prior_copr.astype(float)
297+
epa_w = self.epa_weight + self.prior_copr_weight * (~has_prior_copr).astype(float)
266298
df["total_teleop_points"] = (
267-
self.current_copr_weight * copr_teleop
268-
+ self.prior_copr_weight * prior_copr_teleop
269-
+ self.epa_weight * epa_teleop
299+
self.current_copr_weight * copr_teleop + prior_w * prior_copr_teleop + epa_w * epa_teleop
270300
)
271301

272302
# Debug
@@ -322,11 +352,13 @@ def _get_tba_endgame_summary(self) -> pd.DataFrame:
322352

323353
# Prior event COPR Hub Endgame Fuel Count (no climb available for prior events)
324354
prior_endgame = pd.Series(0.0, index=df_merged.index)
355+
has_prior_copr = pd.Series(False, index=df_merged.index)
325356
if not self.prior_coprs_df.empty and "Hub Endgame Fuel Count" in self.prior_coprs_df.columns:
326357
prior = self.prior_coprs_df[["Hub Endgame Fuel Count"]].copy()
327358
prior["team_number"] = prior.index.str.replace("frc", "").astype("Int64")
328359
prior.rename(columns={"Hub Endgame Fuel Count": "prior_endgame_fuel"}, inplace=True)
329360
df_merged = df_merged.merge(prior[["team_number", "prior_endgame_fuel"]], on="team_number", how="left")
361+
has_prior_copr = df_merged["prior_endgame_fuel"].notna()
330362
prior_endgame = df_merged["prior_endgame_fuel"].fillna(0).clip(lower=0)
331363

332364
# EPA
@@ -337,11 +369,11 @@ def _get_tba_endgame_summary(self) -> pd.DataFrame:
337369
df_merged = df_merged.merge(epa, on="team_number", how="left")
338370
epa_endgame = df_merged["endgame_epa"].fillna(0).clip(lower=0)
339371

340-
# Weighted blend
372+
# Weighted blend — teams missing prior COPR data get that weight redistributed to EPA
373+
prior_w = self.prior_copr_weight * has_prior_copr.astype(float)
374+
epa_w = self.epa_weight + self.prior_copr_weight * (~has_prior_copr).astype(float)
341375
df_merged["total_endgame_points"] = (
342-
self.current_copr_weight * current_tba_endgame
343-
+ self.prior_copr_weight * prior_endgame
344-
+ self.epa_weight * epa_endgame
376+
self.current_copr_weight * current_tba_endgame + prior_w * prior_endgame + epa_w * epa_endgame
345377
)
346378

347379
# Debug
@@ -365,6 +397,66 @@ def _get_tba_endgame_summary(self) -> pd.DataFrame:
365397

366398
return self._get_stats_summary(df_merged, "total_endgame_points")
367399

400+
def _get_fouls_summary(self) -> pd.DataFrame:
401+
"""Compute per-team blended foul points from current and prior event COPR.
402+
403+
Blend: alpha * current_foulPoints + (1-alpha) * prior_foulPoints
404+
No EPA equivalent; prior COPR fills the full weight when no matches played.
405+
406+
Returns:
407+
pd.DataFrame: foulPoints keyed by team_number.
408+
"""
409+
alpha = self.played_quals / self.total_quals if self.total_quals > 0 else 0.0
410+
teams = self.scouting_df[["team_number"]].drop_duplicates().copy()
411+
teams["team_number"] = teams["team_number"].astype("Int64")
412+
413+
current = pd.Series(0.0, index=teams.index)
414+
if not self.coprs_df.empty and "foulPoints" in self.coprs_df.columns:
415+
c = self.coprs_df[["foulPoints"]].copy()
416+
c["team_number"] = c.index.str.replace("frc", "").astype("Int64")
417+
teams = teams.merge(c.rename(columns={"foulPoints": "_cur_fouls"}), on="team_number", how="left")
418+
current = teams["_cur_fouls"].fillna(0)
419+
420+
prior = pd.Series(0.0, index=teams.index)
421+
if not self.prior_coprs_df.empty and "foulPoints" in self.prior_coprs_df.columns:
422+
p = self.prior_coprs_df[["foulPoints"]].copy()
423+
p["team_number"] = p.index.str.replace("frc", "").astype("Int64")
424+
teams = teams.merge(p.rename(columns={"foulPoints": "_pri_fouls"}), on="team_number", how="left")
425+
prior = teams["_pri_fouls"].fillna(0)
426+
427+
teams["foulPoints"] = alpha * current + (1 - alpha) * prior
428+
return teams[["team_number", "foulPoints"]]
429+
430+
def _get_dpr_summary(self) -> pd.DataFrame:
431+
"""Compute per-team blended DPR from current and prior event OPRs.
432+
433+
Blend: alpha * current_dprs + (1-alpha) * prior_dprs
434+
No EPA equivalent; prior OPR fills the full weight when no matches played.
435+
436+
Returns:
437+
pd.DataFrame: dprs keyed by team_number.
438+
"""
439+
alpha = self.played_quals / self.total_quals if self.total_quals > 0 else 0.0
440+
teams = self.scouting_df[["team_number"]].drop_duplicates().copy()
441+
teams["team_number"] = teams["team_number"].astype("Int64")
442+
443+
current = pd.Series(0.0, index=teams.index)
444+
if not self.oprs_df.empty and "dprs" in self.oprs_df.columns:
445+
c = self.oprs_df[["dprs"]].copy()
446+
c["team_number"] = c.index.str.replace("frc", "").astype("Int64")
447+
teams = teams.merge(c.rename(columns={"dprs": "_cur_dpr"}), on="team_number", how="left")
448+
current = teams["_cur_dpr"].fillna(0)
449+
450+
prior = pd.Series(0.0, index=teams.index)
451+
if not self.prior_oprs_df.empty and "dprs" in self.prior_oprs_df.columns:
452+
p = self.prior_oprs_df[["dprs"]].copy()
453+
p["team_number"] = p.index.str.replace("frc", "").astype("Int64")
454+
teams = teams.merge(p.rename(columns={"dprs": "_pri_dpr"}), on="team_number", how="left")
455+
prior = teams["_pri_dpr"].fillna(0)
456+
457+
teams["dprs"] = alpha * current + (1 - alpha) * prior
458+
return teams[["team_number", "dprs"]]
459+
368460
def _get_rank_summary(self) -> pd.DataFrame:
369461
"""Compute mean drive and defense rankings per team.
370462

0 commit comments

Comments
 (0)