CLAUDE: Add safe WPA lookup with fallback to prevent KeyError crashes

Implements defensive error handling for WPA (Win Probability Added) calculations when rare game states are missing from the static lookup table. The safe_wpa_lookup() function uses a three-tier fallback strategy: exact lookup, bases-empty fallback, and 0.0 default value.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-11 18:25:59 -05:00
parent 660c6ad904
commit 80e94498fa
2 changed files with 72 additions and 3 deletions

View File

@ -33,6 +33,49 @@ TO_BASE = {
3: 'to third',
4: 'home'
}
def safe_wpa_lookup(inning_half: str, inning_num: int, starting_outs: int, on_base_code: int, run_diff: int) -> float:
"""
Safely lookup win probability from WPA_DF with fallback logic for missing keys.
Fallback strategy:
1. Try exact lookup
2. Try with simplified on_base_code (reduce to bases empty state)
3. Return 0.0 if no data available
Args:
inning_half: 'top' or 'bot'
inning_num: Inning number (1-9)
starting_outs: Number of outs (0-2)
on_base_code: Base occupation code (0-7)
run_diff: Home run differential (-6 to 6)
Returns:
float: Home team win expectancy (0.0 to 1.0)
"""
# Construct the primary key
key = f'{inning_half}_{inning_num}_{starting_outs}_out_{on_base_code}_obc_{run_diff}_home_run_diff'
# Try exact lookup first
try:
return float(WPA_DF.loc[key, 'home_win_ex'])
except KeyError:
logger.warning(f'WPA key not found: {key}, attempting fallback')
# Fallback 1: Try with simplified on_base_code (bases empty = 0)
if on_base_code != 0:
fallback_key = f'{inning_half}_{inning_num}_{starting_outs}_out_0_obc_{run_diff}_home_run_diff'
try:
result = float(WPA_DF.loc[fallback_key, 'home_win_ex'])
logger.info(f'WPA fallback successful using bases empty state: {fallback_key}')
return result
except KeyError:
logger.warning(f'WPA fallback key not found: {fallback_key}')
# Fallback 2: Return 0.0 if no data available
logger.warning(f'WPA using fallback value 0.0 for missing key: {key}')
return 0.0
AT_BASE = {
2: 'at second',
3: 'at third',
@ -503,11 +546,11 @@ def get_wpa(this_play: Play, next_play: Play):
new_win_ex = 1.0
else:
inning_num = 9 if next_play.inning_num > 9 else next_play.inning_num
new_win_ex = WPA_DF.loc[f'{next_play.inning_half}_{inning_num}_{next_play.starting_outs}_out_{next_play.on_base_code}_obc_{new_rd}_home_run_diff'].home_win_ex
new_win_ex = safe_wpa_lookup(next_play.inning_half, inning_num, next_play.starting_outs, next_play.on_base_code, new_rd)
# print(f'new_win_ex = {new_win_ex}')
inning_num = 9 if this_play.inning_num > 9 else this_play.inning_num
old_win_ex = WPA_DF.loc[f'{this_play.inning_half}_{inning_num}_{this_play.starting_outs}_out_{this_play.on_base_code}_obc_{old_rd}_home_run_diff'].home_win_ex
old_win_ex = safe_wpa_lookup(this_play.inning_half, inning_num, this_play.starting_outs, this_play.on_base_code, old_rd)
# print(f'old_win_ex = {old_win_ex}')
wpa = float(round(new_win_ex - old_win_ex, 3))

View File

@ -1,7 +1,7 @@
import pytest
from sqlmodel import Session, select, func
from command_logic.logic_gameplay import advance_runners, doubles, gb_result_1, get_obc, get_re24, get_wpa, complete_play, log_run_scored, strikeouts, steals, xchecks, walks, popouts, hit_by_pitch, homeruns, singles, triples, bunts, chaos
from command_logic.logic_gameplay import advance_runners, doubles, gb_result_1, get_obc, get_re24, get_wpa, complete_play, log_run_scored, strikeouts, steals, xchecks, walks, popouts, hit_by_pitch, homeruns, singles, triples, bunts, chaos, safe_wpa_lookup
from in_game.gameplay_models import Lineup, Play
from tests.factory import session_fixture, Game
@ -83,6 +83,32 @@ def test_get_wpa(session: Session):
assert get_wpa(this_play, next_play) == 0.153
def test_safe_wpa_lookup():
"""Test safe_wpa_lookup handles both valid and missing keys gracefully."""
# Test 1: Valid key should return actual value
valid_result = safe_wpa_lookup('bot', 1, 0, 0, 0)
assert isinstance(valid_result, float)
assert 0.0 <= valid_result <= 1.0
assert valid_result == 0.589 # Known value from wpa_data.csv
# Test 2: Missing key with bases loaded should fallback to bases empty
# This simulates the error case: top_1_0_out_6_obc_-5_home_run_diff doesn't exist
fallback_result = safe_wpa_lookup('top', 1, 0, 6, -5)
assert isinstance(fallback_result, float)
assert 0.0 <= fallback_result <= 1.0
# Should use fallback to bases empty state if available
# Test 3: If even bases empty fallback doesn't exist, should return 0.0
# (This is an edge case that shouldn't happen with complete data)
extreme_fallback = safe_wpa_lookup('top', 1, 0, 7, -10) # Invalid obc and extreme run diff
assert extreme_fallback == 0.0
# Test 4: Another valid key
valid_result_2 = safe_wpa_lookup('top', 9, 2, 3, 1)
assert isinstance(valid_result_2, float)
assert 0.0 <= valid_result_2 <= 1.0
def test_complete_play(session: Session):
game_1 = session.get(Game, 1)
this_play = game_1.current_play_or_none(session)