Spaces:
Running
Running
| from __future__ import annotations | |
| import logging | |
| from features.park_factors import venue_hr_factor, venue_run_factor | |
| from models.stadium_lookup import resolve_stadium | |
| from models.weather_provider import fetch_game_time_weather | |
| logger = logging.getLogger(__name__) | |
| def _angular_diff(a: float, b: float) -> float: | |
| """Return the absolute angular difference in [0, 180].""" | |
| diff = abs((a - b) % 360) | |
| if diff > 180: | |
| diff = 360 - diff | |
| return diff | |
| def compute_wind_direction_bucket(wind_deg: float | None, cf_bearing: float | None) -> str: | |
| """ | |
| Classify wind direction relative to center field. | |
| Returns one of: "out", "in", "cross", "neutral" | |
| Meteorological convention: wind_deg = direction FROM which wind blows. | |
| - Wind blowing OUT (toward CF): wind_deg ≈ (cf_bearing + 180) % 360 | |
| - Wind blowing IN (from CF): wind_deg ≈ cf_bearing | |
| """ | |
| if wind_deg is None or cf_bearing is None: | |
| return "neutral" | |
| try: | |
| wind_deg = float(wind_deg) | |
| cf_bearing = float(cf_bearing) | |
| except (TypeError, ValueError): | |
| return "neutral" | |
| out_direction = (cf_bearing + 180.0) % 360.0 | |
| diff_out = _angular_diff(wind_deg, out_direction) | |
| diff_in = _angular_diff(wind_deg, cf_bearing) | |
| if diff_out < 45.0: | |
| return "out" | |
| elif diff_in < 45.0: | |
| return "in" | |
| elif diff_out < 120.0 or diff_in < 120.0: | |
| return "cross" | |
| else: | |
| return "neutral" | |
| def compute_park_adjustment(stadium: dict | None) -> dict: | |
| """ | |
| Compute additive park-factor boosts using features/park_factors.py. | |
| Conversion from multiplicative factors: | |
| park_hr_boost = hr_fac - 1.0 | |
| park_hit_boost = (run_fac - 1.0) * 0.40 | |
| park_tb2p_boost = (hr_fac - 1.0) * 0.60 + (run_fac - 1.0) * 0.30 | |
| """ | |
| neutral = { | |
| "park_hr_boost": 0.0, | |
| "park_hit_boost": 0.0, | |
| "park_tb2p_boost": 0.0, | |
| "park_run_env_score": 0.0, | |
| "park_reason_tags": [], | |
| } | |
| if stadium is None: | |
| return neutral | |
| canonical = stadium.get("canonical_name", "") | |
| if not canonical: | |
| return neutral | |
| try: | |
| hr_fac = venue_hr_factor(canonical) | |
| run_fac = venue_run_factor(canonical) | |
| park_hr_boost = hr_fac - 1.0 | |
| park_hit_boost = (run_fac - 1.0) * 0.40 | |
| park_tb2p_boost = (hr_fac - 1.0) * 0.60 + (run_fac - 1.0) * 0.30 | |
| park_run_env_score = (park_hr_boost + park_hit_boost) / 2.0 | |
| tags = [] | |
| if abs(park_hr_boost) >= 0.05: | |
| direction = "HR++" if park_hr_boost > 0 else "HR--" | |
| tags.append(f"Park {direction} ({canonical})") | |
| return { | |
| "park_hr_boost": park_hr_boost, | |
| "park_hit_boost": park_hit_boost, | |
| "park_tb2p_boost": park_tb2p_boost, | |
| "park_run_env_score": park_run_env_score, | |
| "park_reason_tags": tags, | |
| } | |
| except Exception as e: | |
| logger.debug(f"[environment_model] compute_park_adjustment error: {e}") | |
| return neutral | |
| def compute_weather_adjustment( | |
| temp_f: float | None, | |
| wind_mph: float | None, | |
| wind_bucket: str, | |
| ) -> dict: | |
| """ | |
| Compute additive weather boosts from temperature + wind speed × wind direction. | |
| Temperature component: | |
| HR: (temp - 70) × 0.0015, clamp ±0.030 | |
| Hit: (temp - 70) × 0.0004, clamp ±0.010 | |
| TB2P: (temp - 70) × 0.0008, clamp ±0.020 | |
| Wind speed base (neutral-direction component): | |
| HR: wind_mph × 0.0005, clamp ±0.015 | |
| Hit: wind_mph × 0.0001, clamp ±0.005 | |
| TB2P: wind_mph × 0.0002, clamp ±0.008 | |
| Wind direction multipliers applied to wind speed component: | |
| out → HR×2.0, Hit×0.5, TB2P×1.5 | |
| in → HR×-2.0, Hit×-0.3, TB2P×-1.2 | |
| cross → HR×0.2, Hit×0.1, TB2P×0.2 | |
| neutral→ HR×1.0, Hit×0.2, TB2P×0.7 | |
| """ | |
| # Temperature adjustments | |
| if temp_f is not None: | |
| try: | |
| t = float(temp_f) | |
| temp_hr = max(-0.030, min(0.030, (t - 70.0) * 0.0015)) | |
| temp_hit = max(-0.010, min(0.010, (t - 70.0) * 0.0004)) | |
| temp_tb2p = max(-0.020, min(0.020, (t - 70.0) * 0.0008)) | |
| except (TypeError, ValueError): | |
| temp_hr = temp_hit = temp_tb2p = 0.0 | |
| else: | |
| temp_hr = temp_hit = temp_tb2p = 0.0 | |
| # Wind speed base | |
| if wind_mph is not None: | |
| try: | |
| w = float(wind_mph) | |
| wind_base_hr = max(-0.015, min(0.015, w * 0.0005)) | |
| wind_base_hit = max(-0.005, min(0.005, w * 0.0001)) | |
| wind_base_tb2p = max(-0.008, min(0.008, w * 0.0002)) | |
| except (TypeError, ValueError): | |
| wind_base_hr = wind_base_hit = wind_base_tb2p = 0.0 | |
| else: | |
| wind_base_hr = wind_base_hit = wind_base_tb2p = 0.0 | |
| _multipliers = { | |
| "out": {"hr": 2.0, "hit": 0.5, "tb2p": 1.5}, | |
| "in": {"hr": -2.0, "hit": -0.3, "tb2p": -1.2}, | |
| "cross": {"hr": 0.2, "hit": 0.1, "tb2p": 0.2}, | |
| "neutral": {"hr": 1.0, "hit": 0.2, "tb2p": 0.7}, | |
| } | |
| mult = _multipliers.get(wind_bucket, _multipliers["neutral"]) | |
| weather_hr = temp_hr + wind_base_hr * mult["hr"] | |
| weather_hit = temp_hit + wind_base_hit * mult["hit"] | |
| weather_tb2p = temp_tb2p + wind_base_tb2p * mult["tb2p"] | |
| weather_run_env_score = (weather_hr + weather_hit) / 2.0 | |
| tags = [] | |
| w_val = float(wind_mph or 0) | |
| if wind_bucket == "out" and w_val >= 10: | |
| tags.append(f"Wind out {w_val:.0f}mph") | |
| elif wind_bucket == "in" and w_val >= 10: | |
| tags.append(f"Wind in {w_val:.0f}mph") | |
| t_val = float(temp_f or 72) | |
| if t_val >= 85: | |
| tags.append(f"Hot {t_val:.0f}F") | |
| elif t_val <= 50: | |
| tags.append(f"Cold {t_val:.0f}F") | |
| return { | |
| "weather_hr_boost": weather_hr, | |
| "weather_hit_boost": weather_hit, | |
| "weather_tb2p_boost": weather_tb2p, | |
| "weather_run_env_score": weather_run_env_score, | |
| "weather_reason_tags": tags, | |
| } | |
| def compute_environment_adjustment(game_row: dict, weather_row: dict | None = None) -> dict: | |
| """ | |
| Full environment overlay: venue lookup → stadium coordinates → | |
| game-time weather (Open-Meteo) → wind-direction-aware adjustments → | |
| combined env dict. | |
| Safe failure: all boosts default to 0.0 on any lookup/fetch error. | |
| """ | |
| venue = str(game_row.get("venue", "") or "").strip() | |
| game_datetime_utc = str(game_row.get("game_datetime_utc", "") or "").strip() | |
| # Resolve stadium from venue name | |
| stadium = resolve_stadium(venue) if venue else None | |
| # Attempt live game-time weather fetch from Open-Meteo | |
| live_weather = None | |
| if stadium and game_datetime_utc: | |
| try: | |
| live_weather = fetch_game_time_weather( | |
| lat=stadium["lat"], | |
| lon=stadium["lon"], | |
| game_datetime_utc=game_datetime_utc, | |
| ) | |
| except Exception as e: | |
| logger.debug(f"[environment_model] live weather fetch failed: {e}") | |
| # Fall back to legacy weather_row if live fetch failed/skipped | |
| wx = live_weather if live_weather is not None else (weather_row or None) | |
| weather_game_time_used = live_weather is not None | |
| # Extract weather values (with safe casting) | |
| temp_f = wind_mph = wind_deg = None | |
| if wx: | |
| def _safe_float(val): | |
| if val is None: | |
| return None | |
| try: | |
| return float(val) | |
| except (TypeError, ValueError): | |
| return None | |
| temp_f = _safe_float(wx.get("temperature_f")) | |
| wind_mph = _safe_float(wx.get("wind_speed_mph")) | |
| wind_deg = _safe_float(wx.get("wind_direction_deg")) | |
| # Wind direction classification | |
| cf_bearing = stadium.get("cf_bearing") if stadium else None | |
| wind_bucket = compute_wind_direction_bucket(wind_deg, cf_bearing) | |
| # Park and weather adjustments | |
| park_adj = compute_park_adjustment(stadium) | |
| weather_adj = compute_weather_adjustment(temp_f, wind_mph, wind_bucket) | |
| # Combine | |
| env_hr_boost = park_adj["park_hr_boost"] + weather_adj["weather_hr_boost"] | |
| env_hit_boost = park_adj["park_hit_boost"] + weather_adj["weather_hit_boost"] | |
| env_tb2p_boost = park_adj["park_tb2p_boost"] + weather_adj["weather_tb2p_boost"] | |
| env_run_env_score = (park_adj["park_run_env_score"] + weather_adj["weather_run_env_score"]) / 2.0 | |
| reason_tags = park_adj["park_reason_tags"] + weather_adj["weather_reason_tags"] | |
| # Hard clamps | |
| env_hr_boost = max(-0.10, min(0.15, env_hr_boost)) | |
| env_hit_boost = max(-0.05, min(0.08, env_hit_boost)) | |
| env_tb2p_boost = max(-0.08, min(0.12, env_tb2p_boost)) | |
| return { | |
| # Combined outputs | |
| "env_hr_boost": env_hr_boost, | |
| "env_hit_boost": env_hit_boost, | |
| "env_tb2p_boost": env_tb2p_boost, | |
| "env_run_env_score": env_run_env_score, | |
| "env_reason_tags": reason_tags, | |
| # Park sub-fields | |
| "park_hr_boost": park_adj["park_hr_boost"], | |
| "park_hit_boost": park_adj["park_hit_boost"], | |
| "park_tb2p_boost": park_adj["park_tb2p_boost"], | |
| # Weather sub-fields | |
| "weather_hr_boost": weather_adj["weather_hr_boost"], | |
| "weather_hit_boost": weather_adj["weather_hit_boost"], | |
| "weather_tb2p_boost": weather_adj["weather_tb2p_boost"], | |
| # Debug / metadata | |
| "stadium_name_used": stadium.get("canonical_name") if stadium else None, | |
| "stadium_lat": stadium.get("lat") if stadium else None, | |
| "stadium_lon": stadium.get("lon") if stadium else None, | |
| "weather_game_time_used": weather_game_time_used, | |
| "wind_direction_bucket": wind_bucket, | |
| } | |