diff --git a/command_logic/logic_gameplay.py b/command_logic/logic_gameplay.py index ed211e8..d254ee0 100644 --- a/command_logic/logic_gameplay.py +++ b/command_logic/logic_gameplay.py @@ -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)) diff --git a/tests/command_logic/test_logic_gameplay.py b/tests/command_logic/test_logic_gameplay.py index 6a6a97f..9a06fb7 100644 --- a/tests/command_logic/test_logic_gameplay.py +++ b/tests/command_logic/test_logic_gameplay.py @@ -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)