2026_MLB_Model / models /environment_model.py
Syntrex's picture
Batch 11: Environment Model — park + game-time weather + wind-direction overlay
b96cb2a
raw
history blame
9.66 kB
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,
}