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, }