Syntrex Claude Sonnet 4.6 commited on
Commit
70d19e1
·
1 Parent(s): d63e096

Hitter selector: ID-based role dominance classification

Browse files

Replaces name-based exclusion with batter/pitcher numeric ID comparison.
Two queries against statcast_event_core:
1. batter id -> (player_name, batter_count) via GROUP BY batter
2. pitcher id -> pitcher_count via GROUP BY pitcher

Eligibility rule: batter_count >= 20 AND batter_count > pitcher_count.
Display names come from player_name (the batter name column in the table).
No name-matching logic used for eligibility decisions.

Diagnostic logging preserved: logs Ivan Herrera / Mick Abel / Jason Adam
with player_id, batter_count, pitcher_count, and hitter_eligible result.
Hitter cache remains disabled pending log verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. visualization/cards/card_queries.py +85 -70
visualization/cards/card_queries.py CHANGED
@@ -73,99 +73,114 @@ def normalize_name_for_compare(name: str) -> str:
73
  return " ".join(s.lower().split())
74
 
75
 
 
 
 
76
  def get_card_lab_hitters(conn, year: int | None = None, limit: int = 500) -> list[str]:
77
  """
78
- Batter names filtered by dominance: must have >= 20 batter-side pitches AND
79
- not be a known pitcher (unless they have >= 100 batter-side pitches, covering
80
- genuine two-way players).
81
 
82
- Uses get_card_lab_pitchers() to build the exclusion set — no need to rewrite
83
- pitcher logic here.
 
 
 
 
 
84
  """
85
  try:
86
- # Step A: batter candidates with pitch count
87
  if year:
88
- sql = text("""
89
- SELECT player_name, COUNT(*) AS batter_pitch_count
90
  FROM statcast_event_core
91
  WHERE source_season = :year
92
- AND player_name IS NOT NULL
93
- GROUP BY player_name
94
- HAVING COUNT(*) >= 20
95
  LIMIT :limit
96
  """)
97
- rows = conn.execute(sql, {"year": int(year), "limit": limit}).fetchall()
 
 
98
  else:
99
- sql = text("""
100
- SELECT player_name, COUNT(*) AS batter_pitch_count
101
  FROM statcast_event_core
102
- WHERE player_name IS NOT NULL
103
- GROUP BY player_name
104
- HAVING COUNT(*) >= 20
105
  LIMIT :limit
106
  """)
107
- rows = conn.execute(sql, {"limit": limit}).fetchall()
108
-
109
- # Step B: build pitcher exclusion set from the working pitcher selector
110
- # Exclude "Pitcher {id}" fallbacks — only named pitchers are excluded
111
- pitcher_tuples = get_card_lab_pitchers(conn, year=year)
112
- pitcher_set = {
113
- normalize_name_for_compare(name)
114
- for _, name in pitcher_tuples
115
- if name and not name.startswith("Pitcher ")
116
- }
117
-
118
- # Diagnostic: raw snapshot
119
- logger.info(
120
- "[card_lab_hitter_diag] raw_candidates=%s pitcher_set_size=%s",
121
- len(rows), len(pitcher_set),
122
- )
123
- if rows:
124
- logger.info(
125
- "[card_lab_hitter_diag] first_25_candidates=%s",
126
- [(r[0], r[1]) for r in rows[:25]],
127
- )
128
- if pitcher_set:
129
- logger.info(
130
- "[card_lab_hitter_diag] first_25_pitcher_set=%s",
131
- sorted(pitcher_set)[:25],
132
- )
133
 
134
- # Diagnostic: named-player checks
135
- _raw_map = {normalize_name_for_compare(r[0]): (r[0], r[1]) for r in rows if r[0]}
136
- for check_name in ("Ivan Herrera", "Mick Abel", "Jason Adam"):
137
- norm = normalize_name_for_compare(check_name)
138
- raw_present = norm in _raw_map
139
- b_count = _raw_map[norm][1] if raw_present else 0
140
- in_ps = norm in pitcher_set
141
- included = raw_present and (b_count >= 20) and (norm not in pitcher_set or b_count >= 100)
142
- logger.info(
143
- '[card_lab_hitter_diag] name="%s" raw_present=%s batter_pitch_count=%s '
144
- 'normalized="%s" in_pitcher_set=%s included=%s',
145
- check_name, raw_present, b_count, norm, in_ps, included,
146
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
- # Step C/D: filter by dominance
149
  names: list[str] = []
150
  excluded = 0
151
- for row in rows:
152
- raw_name, batter_count = row[0], row[1]
153
- if not raw_name:
154
- continue
155
- if normalize_name_for_compare(raw_name) in pitcher_set and batter_count < 100:
 
156
  excluded += 1
157
  continue
158
- names.append(raw_name)
159
-
160
- names.sort()
161
 
162
- # Diagnostic: final output
163
- logger.info("[card_lab_hitter_diag] final_count=%s", len(names))
164
- logger.info("[card_lab_hitter_diag] first_25_final=%s", names[:25])
 
 
 
 
 
 
 
 
 
 
 
 
165
 
 
 
166
  logger.info(
167
- "[card_lab_db_players] type=hitter year=%s raw=%s filtered=%s excluded_pitchers=%s",
168
- year, len(rows), len(names), excluded,
169
  )
170
  return names
171
  except Exception as exc:
 
73
  return " ".join(s.lower().split())
74
 
75
 
76
+ _HITTER_MIN_PITCHES = 20 # minimum batter-side pitches to appear in hitter selector
77
+
78
+
79
  def get_card_lab_hitters(conn, year: int | None = None, limit: int = 500) -> list[str]:
80
  """
81
+ ID-based hitter classification.
 
 
82
 
83
+ A player is hitter-eligible if:
84
+ - batter_count >= _HITTER_MIN_PITCHES (at least ~5 PA)
85
+ - batter_count > pitcher_count (batter role dominates)
86
+
87
+ Both sides are measured by numeric player IDs from statcast_event_core
88
+ (batter / pitcher columns). Name matching is NOT used for eligibility.
89
+ Display names come from player_name (which IS the batter's name in this table).
90
  """
91
  try:
92
+ # --- Query 1: batter_id → (display_name, batter_count) ---
93
  if year:
94
+ batter_sql = text("""
95
+ SELECT batter, MAX(player_name) AS player_name, COUNT(*) AS batter_count
96
  FROM statcast_event_core
97
  WHERE source_season = :year
98
+ AND batter IS NOT NULL
99
+ GROUP BY batter
100
+ HAVING COUNT(*) >= :min_pitches
101
  LIMIT :limit
102
  """)
103
+ batter_rows = conn.execute(
104
+ batter_sql, {"year": int(year), "min_pitches": _HITTER_MIN_PITCHES, "limit": limit}
105
+ ).fetchall()
106
  else:
107
+ batter_sql = text("""
108
+ SELECT batter, MAX(player_name) AS player_name, COUNT(*) AS batter_count
109
  FROM statcast_event_core
110
+ WHERE batter IS NOT NULL
111
+ GROUP BY batter
112
+ HAVING COUNT(*) >= :min_pitches
113
  LIMIT :limit
114
  """)
115
+ batter_rows = conn.execute(
116
+ batter_sql, {"min_pitches": _HITTER_MIN_PITCHES, "limit": limit}
117
+ ).fetchall()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
+ # --- Query 2: pitcher_id → pitcher_count ---
120
+ if year:
121
+ pitcher_count_rows = conn.execute(
122
+ text("""
123
+ SELECT pitcher, COUNT(*) AS pitcher_count
124
+ FROM statcast_event_core
125
+ WHERE source_season = :year AND pitcher IS NOT NULL
126
+ GROUP BY pitcher
127
+ """),
128
+ {"year": int(year)},
129
+ ).fetchall()
130
+ else:
131
+ pitcher_count_rows = conn.execute(
132
+ text("""
133
+ SELECT pitcher, COUNT(*) AS pitcher_count
134
+ FROM statcast_event_core
135
+ WHERE pitcher IS NOT NULL
136
+ GROUP BY pitcher
137
+ """)
138
+ ).fetchall()
139
+
140
+ pitcher_counts: dict[int, int] = {row[0]: row[1] for row in pitcher_count_rows}
141
+
142
+ # --- Classify by dominance ---
143
+ # Build name→row map for diagnostic checks only
144
+ _name_map: dict[str, tuple] = {}
145
+ for row in batter_rows:
146
+ key = normalize_name_for_compare(row[1] or "")
147
+ if key:
148
+ _name_map[key] = row
149
 
 
150
  names: list[str] = []
151
  excluded = 0
152
+
153
+ for row in batter_rows:
154
+ batter_id, player_name, batter_count = row[0], row[1], row[2]
155
+ pitcher_count = pitcher_counts.get(batter_id, 0)
156
+ eligible = batter_count >= _HITTER_MIN_PITCHES and batter_count > pitcher_count
157
+ if not eligible:
158
  excluded += 1
159
  continue
160
+ display = player_name if player_name else f"Player {batter_id}"
161
+ names.append(display)
 
162
 
163
+ # --- Diagnostic: known-player checks ---
164
+ for check_name in ("Ivan Herrera", "Mick Abel", "Jason Adam"):
165
+ norm = normalize_name_for_compare(check_name)
166
+ row = _name_map.get(norm)
167
+ if row:
168
+ bid, pname, b_count = row[0], row[1], row[2]
169
+ p_count = pitcher_counts.get(bid, 0)
170
+ eligible = b_count >= _HITTER_MIN_PITCHES and b_count > p_count
171
+ else:
172
+ bid, pname, b_count, p_count, eligible = None, None, 0, 0, False
173
+ logger.info(
174
+ '[card_lab_hitter_diag] name="%s" player_id=%s display_name="%s" '
175
+ "batter_count=%s pitcher_count=%s hitter_eligible=%s",
176
+ check_name, bid, pname, b_count, p_count, eligible,
177
+ )
178
 
179
+ names.sort()
180
+ logger.info("[card_lab_hitter_diag] final_count=%s first_25=%s", len(names), names[:25])
181
  logger.info(
182
+ "[card_lab_db_players] type=hitter year=%s batter_ids=%s final=%s excluded=%s",
183
+ year, len(batter_rows), len(names), excluded,
184
  )
185
  return names
186
  except Exception as exc: