Spaces:
Running
Running
Batch 11: Environment Model — park + game-time weather + wind-direction overlay
Browse files- models/stadium_lookup.py: 30-park registry (lat/lon/cf_bearing) + resolve_stadium()
- models/weather_provider.py: Open-Meteo hourly fetch aligned to scheduled game time
- models/environment_model.py: park adj + weather adj + wind bucket → env_hr/hit/tb2p boosts
- data/schedule.py: populate venue + game_datetime_utc from MLB Stats API; WBC schema parity
- live_fair_simulator_v3.py: replace Phase C2 with additive env overlay; env_adj hoisted outside batter loop; 15 new output fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- data/schedule.py +3 -1
- models/environment_model.py +272 -0
- models/live_fair_simulator_v3.py +32 -24
- models/stadium_lookup.py +303 -0
- models/weather_provider.py +100 -0
data/schedule.py
CHANGED
|
@@ -182,6 +182,7 @@ def _fetch_wbc_schedule_for_date(date_str: str) -> pd.DataFrame:
|
|
| 182 |
"away_errors": None,
|
| 183 |
"home_errors": None,
|
| 184 |
"venue": "",
|
|
|
|
| 185 |
"tv": tv,
|
| 186 |
"start_time_et": start_time_et,
|
| 187 |
"sport_id": 51,
|
|
@@ -269,7 +270,8 @@ def _fetch_mlb_schedule_for_date(date_str: str) -> pd.DataFrame:
|
|
| 269 |
"home_hits": None,
|
| 270 |
"away_errors": None,
|
| 271 |
"home_errors": None,
|
| 272 |
-
"venue": "",
|
|
|
|
| 273 |
"tv": tv,
|
| 274 |
"start_time_et": start_time_et,
|
| 275 |
"sport_id": 1,
|
|
|
|
| 182 |
"away_errors": None,
|
| 183 |
"home_errors": None,
|
| 184 |
"venue": "",
|
| 185 |
+
"game_datetime_utc": "",
|
| 186 |
"tv": tv,
|
| 187 |
"start_time_et": start_time_et,
|
| 188 |
"sport_id": 51,
|
|
|
|
| 270 |
"home_hits": None,
|
| 271 |
"away_errors": None,
|
| 272 |
"home_errors": None,
|
| 273 |
+
"venue": str((game.get("venue", {}) or {}).get("name", "") or "").strip(),
|
| 274 |
+
"game_datetime_utc": str(game.get("gameDate", "") or "").strip(),
|
| 275 |
"tv": tv,
|
| 276 |
"start_time_et": start_time_et,
|
| 277 |
"sport_id": 1,
|
models/environment_model.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from features.park_factors import venue_hr_factor, venue_run_factor
|
| 6 |
+
from models.stadium_lookup import resolve_stadium
|
| 7 |
+
from models.weather_provider import fetch_game_time_weather
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _angular_diff(a: float, b: float) -> float:
|
| 13 |
+
"""Return the absolute angular difference in [0, 180]."""
|
| 14 |
+
diff = abs((a - b) % 360)
|
| 15 |
+
if diff > 180:
|
| 16 |
+
diff = 360 - diff
|
| 17 |
+
return diff
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def compute_wind_direction_bucket(wind_deg: float | None, cf_bearing: float | None) -> str:
|
| 21 |
+
"""
|
| 22 |
+
Classify wind direction relative to center field.
|
| 23 |
+
|
| 24 |
+
Returns one of: "out", "in", "cross", "neutral"
|
| 25 |
+
|
| 26 |
+
Meteorological convention: wind_deg = direction FROM which wind blows.
|
| 27 |
+
- Wind blowing OUT (toward CF): wind_deg ≈ (cf_bearing + 180) % 360
|
| 28 |
+
- Wind blowing IN (from CF): wind_deg ≈ cf_bearing
|
| 29 |
+
"""
|
| 30 |
+
if wind_deg is None or cf_bearing is None:
|
| 31 |
+
return "neutral"
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
wind_deg = float(wind_deg)
|
| 35 |
+
cf_bearing = float(cf_bearing)
|
| 36 |
+
except (TypeError, ValueError):
|
| 37 |
+
return "neutral"
|
| 38 |
+
|
| 39 |
+
out_direction = (cf_bearing + 180.0) % 360.0
|
| 40 |
+
diff_out = _angular_diff(wind_deg, out_direction)
|
| 41 |
+
diff_in = _angular_diff(wind_deg, cf_bearing)
|
| 42 |
+
|
| 43 |
+
if diff_out < 45.0:
|
| 44 |
+
return "out"
|
| 45 |
+
elif diff_in < 45.0:
|
| 46 |
+
return "in"
|
| 47 |
+
elif diff_out < 120.0 or diff_in < 120.0:
|
| 48 |
+
return "cross"
|
| 49 |
+
else:
|
| 50 |
+
return "neutral"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def compute_park_adjustment(stadium: dict | None) -> dict:
|
| 54 |
+
"""
|
| 55 |
+
Compute additive park-factor boosts using features/park_factors.py.
|
| 56 |
+
|
| 57 |
+
Conversion from multiplicative factors:
|
| 58 |
+
park_hr_boost = hr_fac - 1.0
|
| 59 |
+
park_hit_boost = (run_fac - 1.0) * 0.40
|
| 60 |
+
park_tb2p_boost = (hr_fac - 1.0) * 0.60 + (run_fac - 1.0) * 0.30
|
| 61 |
+
"""
|
| 62 |
+
neutral = {
|
| 63 |
+
"park_hr_boost": 0.0,
|
| 64 |
+
"park_hit_boost": 0.0,
|
| 65 |
+
"park_tb2p_boost": 0.0,
|
| 66 |
+
"park_run_env_score": 0.0,
|
| 67 |
+
"park_reason_tags": [],
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
if stadium is None:
|
| 71 |
+
return neutral
|
| 72 |
+
|
| 73 |
+
canonical = stadium.get("canonical_name", "")
|
| 74 |
+
if not canonical:
|
| 75 |
+
return neutral
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
hr_fac = venue_hr_factor(canonical)
|
| 79 |
+
run_fac = venue_run_factor(canonical)
|
| 80 |
+
|
| 81 |
+
park_hr_boost = hr_fac - 1.0
|
| 82 |
+
park_hit_boost = (run_fac - 1.0) * 0.40
|
| 83 |
+
park_tb2p_boost = (hr_fac - 1.0) * 0.60 + (run_fac - 1.0) * 0.30
|
| 84 |
+
park_run_env_score = (park_hr_boost + park_hit_boost) / 2.0
|
| 85 |
+
|
| 86 |
+
tags = []
|
| 87 |
+
if abs(park_hr_boost) >= 0.05:
|
| 88 |
+
direction = "HR++" if park_hr_boost > 0 else "HR--"
|
| 89 |
+
tags.append(f"Park {direction} ({canonical})")
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
"park_hr_boost": park_hr_boost,
|
| 93 |
+
"park_hit_boost": park_hit_boost,
|
| 94 |
+
"park_tb2p_boost": park_tb2p_boost,
|
| 95 |
+
"park_run_env_score": park_run_env_score,
|
| 96 |
+
"park_reason_tags": tags,
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.debug(f"[environment_model] compute_park_adjustment error: {e}")
|
| 101 |
+
return neutral
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def compute_weather_adjustment(
|
| 105 |
+
temp_f: float | None,
|
| 106 |
+
wind_mph: float | None,
|
| 107 |
+
wind_bucket: str,
|
| 108 |
+
) -> dict:
|
| 109 |
+
"""
|
| 110 |
+
Compute additive weather boosts from temperature + wind speed × wind direction.
|
| 111 |
+
|
| 112 |
+
Temperature component:
|
| 113 |
+
HR: (temp - 70) × 0.0015, clamp ±0.030
|
| 114 |
+
Hit: (temp - 70) × 0.0004, clamp ±0.010
|
| 115 |
+
TB2P: (temp - 70) × 0.0008, clamp ±0.020
|
| 116 |
+
|
| 117 |
+
Wind speed base (neutral-direction component):
|
| 118 |
+
HR: wind_mph × 0.0005, clamp ±0.015
|
| 119 |
+
Hit: wind_mph × 0.0001, clamp ±0.005
|
| 120 |
+
TB2P: wind_mph × 0.0002, clamp ±0.008
|
| 121 |
+
|
| 122 |
+
Wind direction multipliers applied to wind speed component:
|
| 123 |
+
out → HR×2.0, Hit×0.5, TB2P×1.5
|
| 124 |
+
in → HR×-2.0, Hit×-0.3, TB2P×-1.2
|
| 125 |
+
cross → HR×0.2, Hit×0.1, TB2P×0.2
|
| 126 |
+
neutral→ HR×1.0, Hit×0.2, TB2P×0.7
|
| 127 |
+
"""
|
| 128 |
+
# Temperature adjustments
|
| 129 |
+
if temp_f is not None:
|
| 130 |
+
try:
|
| 131 |
+
t = float(temp_f)
|
| 132 |
+
temp_hr = max(-0.030, min(0.030, (t - 70.0) * 0.0015))
|
| 133 |
+
temp_hit = max(-0.010, min(0.010, (t - 70.0) * 0.0004))
|
| 134 |
+
temp_tb2p = max(-0.020, min(0.020, (t - 70.0) * 0.0008))
|
| 135 |
+
except (TypeError, ValueError):
|
| 136 |
+
temp_hr = temp_hit = temp_tb2p = 0.0
|
| 137 |
+
else:
|
| 138 |
+
temp_hr = temp_hit = temp_tb2p = 0.0
|
| 139 |
+
|
| 140 |
+
# Wind speed base
|
| 141 |
+
if wind_mph is not None:
|
| 142 |
+
try:
|
| 143 |
+
w = float(wind_mph)
|
| 144 |
+
wind_base_hr = max(-0.015, min(0.015, w * 0.0005))
|
| 145 |
+
wind_base_hit = max(-0.005, min(0.005, w * 0.0001))
|
| 146 |
+
wind_base_tb2p = max(-0.008, min(0.008, w * 0.0002))
|
| 147 |
+
except (TypeError, ValueError):
|
| 148 |
+
wind_base_hr = wind_base_hit = wind_base_tb2p = 0.0
|
| 149 |
+
else:
|
| 150 |
+
wind_base_hr = wind_base_hit = wind_base_tb2p = 0.0
|
| 151 |
+
|
| 152 |
+
_multipliers = {
|
| 153 |
+
"out": {"hr": 2.0, "hit": 0.5, "tb2p": 1.5},
|
| 154 |
+
"in": {"hr": -2.0, "hit": -0.3, "tb2p": -1.2},
|
| 155 |
+
"cross": {"hr": 0.2, "hit": 0.1, "tb2p": 0.2},
|
| 156 |
+
"neutral": {"hr": 1.0, "hit": 0.2, "tb2p": 0.7},
|
| 157 |
+
}
|
| 158 |
+
mult = _multipliers.get(wind_bucket, _multipliers["neutral"])
|
| 159 |
+
|
| 160 |
+
weather_hr = temp_hr + wind_base_hr * mult["hr"]
|
| 161 |
+
weather_hit = temp_hit + wind_base_hit * mult["hit"]
|
| 162 |
+
weather_tb2p = temp_tb2p + wind_base_tb2p * mult["tb2p"]
|
| 163 |
+
weather_run_env_score = (weather_hr + weather_hit) / 2.0
|
| 164 |
+
|
| 165 |
+
tags = []
|
| 166 |
+
w_val = float(wind_mph or 0)
|
| 167 |
+
if wind_bucket == "out" and w_val >= 10:
|
| 168 |
+
tags.append(f"Wind out {w_val:.0f}mph")
|
| 169 |
+
elif wind_bucket == "in" and w_val >= 10:
|
| 170 |
+
tags.append(f"Wind in {w_val:.0f}mph")
|
| 171 |
+
t_val = float(temp_f or 72)
|
| 172 |
+
if t_val >= 85:
|
| 173 |
+
tags.append(f"Hot {t_val:.0f}F")
|
| 174 |
+
elif t_val <= 50:
|
| 175 |
+
tags.append(f"Cold {t_val:.0f}F")
|
| 176 |
+
|
| 177 |
+
return {
|
| 178 |
+
"weather_hr_boost": weather_hr,
|
| 179 |
+
"weather_hit_boost": weather_hit,
|
| 180 |
+
"weather_tb2p_boost": weather_tb2p,
|
| 181 |
+
"weather_run_env_score": weather_run_env_score,
|
| 182 |
+
"weather_reason_tags": tags,
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def compute_environment_adjustment(game_row: dict, weather_row: dict | None = None) -> dict:
|
| 187 |
+
"""
|
| 188 |
+
Full environment overlay: venue lookup → stadium coordinates →
|
| 189 |
+
game-time weather (Open-Meteo) → wind-direction-aware adjustments →
|
| 190 |
+
combined env dict.
|
| 191 |
+
|
| 192 |
+
Safe failure: all boosts default to 0.0 on any lookup/fetch error.
|
| 193 |
+
"""
|
| 194 |
+
venue = str(game_row.get("venue", "") or "").strip()
|
| 195 |
+
game_datetime_utc = str(game_row.get("game_datetime_utc", "") or "").strip()
|
| 196 |
+
|
| 197 |
+
# Resolve stadium from venue name
|
| 198 |
+
stadium = resolve_stadium(venue) if venue else None
|
| 199 |
+
|
| 200 |
+
# Attempt live game-time weather fetch from Open-Meteo
|
| 201 |
+
live_weather = None
|
| 202 |
+
if stadium and game_datetime_utc:
|
| 203 |
+
try:
|
| 204 |
+
live_weather = fetch_game_time_weather(
|
| 205 |
+
lat=stadium["lat"],
|
| 206 |
+
lon=stadium["lon"],
|
| 207 |
+
game_datetime_utc=game_datetime_utc,
|
| 208 |
+
)
|
| 209 |
+
except Exception as e:
|
| 210 |
+
logger.debug(f"[environment_model] live weather fetch failed: {e}")
|
| 211 |
+
|
| 212 |
+
# Fall back to legacy weather_row if live fetch failed/skipped
|
| 213 |
+
wx = live_weather if live_weather is not None else (weather_row or None)
|
| 214 |
+
weather_game_time_used = live_weather is not None
|
| 215 |
+
|
| 216 |
+
# Extract weather values (with safe casting)
|
| 217 |
+
temp_f = wind_mph = wind_deg = None
|
| 218 |
+
if wx:
|
| 219 |
+
def _safe_float(val):
|
| 220 |
+
if val is None:
|
| 221 |
+
return None
|
| 222 |
+
try:
|
| 223 |
+
return float(val)
|
| 224 |
+
except (TypeError, ValueError):
|
| 225 |
+
return None
|
| 226 |
+
|
| 227 |
+
temp_f = _safe_float(wx.get("temperature_f"))
|
| 228 |
+
wind_mph = _safe_float(wx.get("wind_speed_mph"))
|
| 229 |
+
wind_deg = _safe_float(wx.get("wind_direction_deg"))
|
| 230 |
+
|
| 231 |
+
# Wind direction classification
|
| 232 |
+
cf_bearing = stadium.get("cf_bearing") if stadium else None
|
| 233 |
+
wind_bucket = compute_wind_direction_bucket(wind_deg, cf_bearing)
|
| 234 |
+
|
| 235 |
+
# Park and weather adjustments
|
| 236 |
+
park_adj = compute_park_adjustment(stadium)
|
| 237 |
+
weather_adj = compute_weather_adjustment(temp_f, wind_mph, wind_bucket)
|
| 238 |
+
|
| 239 |
+
# Combine
|
| 240 |
+
env_hr_boost = park_adj["park_hr_boost"] + weather_adj["weather_hr_boost"]
|
| 241 |
+
env_hit_boost = park_adj["park_hit_boost"] + weather_adj["weather_hit_boost"]
|
| 242 |
+
env_tb2p_boost = park_adj["park_tb2p_boost"] + weather_adj["weather_tb2p_boost"]
|
| 243 |
+
env_run_env_score = (park_adj["park_run_env_score"] + weather_adj["weather_run_env_score"]) / 2.0
|
| 244 |
+
reason_tags = park_adj["park_reason_tags"] + weather_adj["weather_reason_tags"]
|
| 245 |
+
|
| 246 |
+
# Hard clamps
|
| 247 |
+
env_hr_boost = max(-0.10, min(0.15, env_hr_boost))
|
| 248 |
+
env_hit_boost = max(-0.05, min(0.08, env_hit_boost))
|
| 249 |
+
env_tb2p_boost = max(-0.08, min(0.12, env_tb2p_boost))
|
| 250 |
+
|
| 251 |
+
return {
|
| 252 |
+
# Combined outputs
|
| 253 |
+
"env_hr_boost": env_hr_boost,
|
| 254 |
+
"env_hit_boost": env_hit_boost,
|
| 255 |
+
"env_tb2p_boost": env_tb2p_boost,
|
| 256 |
+
"env_run_env_score": env_run_env_score,
|
| 257 |
+
"env_reason_tags": reason_tags,
|
| 258 |
+
# Park sub-fields
|
| 259 |
+
"park_hr_boost": park_adj["park_hr_boost"],
|
| 260 |
+
"park_hit_boost": park_adj["park_hit_boost"],
|
| 261 |
+
"park_tb2p_boost": park_adj["park_tb2p_boost"],
|
| 262 |
+
# Weather sub-fields
|
| 263 |
+
"weather_hr_boost": weather_adj["weather_hr_boost"],
|
| 264 |
+
"weather_hit_boost": weather_adj["weather_hit_boost"],
|
| 265 |
+
"weather_tb2p_boost": weather_adj["weather_tb2p_boost"],
|
| 266 |
+
# Debug / metadata
|
| 267 |
+
"stadium_name_used": stadium.get("canonical_name") if stadium else None,
|
| 268 |
+
"stadium_lat": stadium.get("lat") if stadium else None,
|
| 269 |
+
"stadium_lon": stadium.get("lon") if stadium else None,
|
| 270 |
+
"weather_game_time_used": weather_game_time_used,
|
| 271 |
+
"wind_direction_bucket": wind_bucket,
|
| 272 |
+
}
|
models/live_fair_simulator_v3.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import logging
|
| 4 |
|
| 5 |
import pandas as pd
|
| 6 |
-
from
|
| 7 |
from models.family_zone_profile_store import (
|
| 8 |
build_batter_family_zone_feature_row,
|
| 9 |
build_pitcher_family_zone_feature_row,
|
|
@@ -62,6 +62,12 @@ def build_upcoming_simulated_rows(
|
|
| 62 |
bullpen_context = build_bullpen_context(game_row, pitcher_row)
|
| 63 |
bullpen_adj = compute_bullpen_adjustment(bullpen_context)
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
rows: list[dict] = []
|
| 66 |
|
| 67 |
for slot, batter_name in slots:
|
|
@@ -351,29 +357,14 @@ def build_upcoming_simulated_rows(
|
|
| 351 |
except Exception as e:
|
| 352 |
logger.debug(f"[simulator] pull_air_rate adjustment skipped: {e}")
|
| 353 |
|
| 354 |
-
#
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
batter_baseline["hr_prob_base"] = min(
|
| 363 |
-
0.30,
|
| 364 |
-
max(
|
| 365 |
-
0.005,
|
| 366 |
-
float(batter_baseline.get("hr_prob_base", 0.03) or 0.03) * combined_park_factor,
|
| 367 |
-
),
|
| 368 |
-
)
|
| 369 |
-
tb2p_fac = 1.0 + (combined_park_factor - 1.0) * 0.60
|
| 370 |
-
batter_baseline["tb2p_prob_base"] = min(
|
| 371 |
-
0.45,
|
| 372 |
-
max(
|
| 373 |
-
0.03,
|
| 374 |
-
float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10) * tb2p_fac,
|
| 375 |
-
),
|
| 376 |
-
)
|
| 377 |
|
| 378 |
# Phase E4: Platoon (handedness) adjustment
|
| 379 |
batter_stand = batter_features.get("batter_stand", "R")
|
|
@@ -461,6 +452,7 @@ def build_upcoming_simulated_rows(
|
|
| 461 |
+ context_adj["reason_tags"][:2]
|
| 462 |
+ bullpen_adj["reason_tags"][:2]
|
| 463 |
+ traj_adj.get("reason_tags", [])[:1]
|
|
|
|
| 464 |
)
|
| 465 |
|
| 466 |
if batter_features.get("ev90") is not None:
|
|
@@ -543,6 +535,22 @@ def build_upcoming_simulated_rows(
|
|
| 543 |
"traj_hit_boost": traj_adj.get("hit_adj"),
|
| 544 |
"traj_hr_boost": traj_adj.get("hr_adj"),
|
| 545 |
"traj_tb2p_boost": traj_adj.get("tb2p_adj"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
}
|
| 547 |
)
|
| 548 |
|
|
|
|
| 3 |
import logging
|
| 4 |
|
| 5 |
import pandas as pd
|
| 6 |
+
from models.environment_model import compute_environment_adjustment
|
| 7 |
from models.family_zone_profile_store import (
|
| 8 |
build_batter_family_zone_feature_row,
|
| 9 |
build_pitcher_family_zone_feature_row,
|
|
|
|
| 62 |
bullpen_context = build_bullpen_context(game_row, pitcher_row)
|
| 63 |
bullpen_adj = compute_bullpen_adjustment(bullpen_context)
|
| 64 |
|
| 65 |
+
# Batch 11: Environment overlay — computed once per game (venue/datetime don't change per batter)
|
| 66 |
+
env_adj = compute_environment_adjustment(game_row=game_row, weather_row=weather_row)
|
| 67 |
+
env_hr_boost = float(env_adj.get("env_hr_boost", 0.0) or 0.0)
|
| 68 |
+
env_hit_boost = float(env_adj.get("env_hit_boost", 0.0) or 0.0)
|
| 69 |
+
env_tb2p_boost = float(env_adj.get("env_tb2p_boost", 0.0) or 0.0)
|
| 70 |
+
|
| 71 |
rows: list[dict] = []
|
| 72 |
|
| 73 |
for slot, batter_name in slots:
|
|
|
|
| 357 |
except Exception as e:
|
| 358 |
logger.debug(f"[simulator] pull_air_rate adjustment skipped: {e}")
|
| 359 |
|
| 360 |
+
# Batch 11: Apply environment overlay (env_adj computed once per game before loop)
|
| 361 |
+
batter_baseline["hit_prob_base"] = min(0.55, max(0.05,
|
| 362 |
+
float(batter_baseline.get("hit_prob_base", 0.15) or 0.15) + env_hit_boost))
|
| 363 |
+
batter_baseline["hr_prob_base"] = min(0.30, max(0.005,
|
| 364 |
+
float(batter_baseline.get("hr_prob_base", 0.03) or 0.03) + env_hr_boost))
|
| 365 |
+
tb2p_fac = 1.0 + env_tb2p_boost
|
| 366 |
+
batter_baseline["tb2p_prob_base"] = min(0.45, max(0.03,
|
| 367 |
+
float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10) * tb2p_fac))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
|
| 369 |
# Phase E4: Platoon (handedness) adjustment
|
| 370 |
batter_stand = batter_features.get("batter_stand", "R")
|
|
|
|
| 452 |
+ context_adj["reason_tags"][:2]
|
| 453 |
+ bullpen_adj["reason_tags"][:2]
|
| 454 |
+ traj_adj.get("reason_tags", [])[:1]
|
| 455 |
+
+ env_adj.get("env_reason_tags", [])[:1]
|
| 456 |
)
|
| 457 |
|
| 458 |
if batter_features.get("ev90") is not None:
|
|
|
|
| 535 |
"traj_hit_boost": traj_adj.get("hit_adj"),
|
| 536 |
"traj_hr_boost": traj_adj.get("hr_adj"),
|
| 537 |
"traj_tb2p_boost": traj_adj.get("tb2p_adj"),
|
| 538 |
+
|
| 539 |
+
"weather_hr_boost": env_adj.get("weather_hr_boost"),
|
| 540 |
+
"weather_hit_boost": env_adj.get("weather_hit_boost"),
|
| 541 |
+
"weather_tb2p_boost": env_adj.get("weather_tb2p_boost"),
|
| 542 |
+
"park_hr_boost": env_adj.get("park_hr_boost"),
|
| 543 |
+
"park_hit_boost": env_adj.get("park_hit_boost"),
|
| 544 |
+
"park_tb2p_boost": env_adj.get("park_tb2p_boost"),
|
| 545 |
+
"env_hr_boost": env_adj.get("env_hr_boost"),
|
| 546 |
+
"env_hit_boost": env_adj.get("env_hit_boost"),
|
| 547 |
+
"env_tb2p_boost": env_adj.get("env_tb2p_boost"),
|
| 548 |
+
"env_run_env_score": env_adj.get("env_run_env_score"),
|
| 549 |
+
"stadium_name_used": env_adj.get("stadium_name_used"),
|
| 550 |
+
"stadium_lat": env_adj.get("stadium_lat"),
|
| 551 |
+
"stadium_lon": env_adj.get("stadium_lon"),
|
| 552 |
+
"weather_game_time_used": env_adj.get("weather_game_time_used"),
|
| 553 |
+
"wind_direction_bucket": env_adj.get("wind_direction_bucket"),
|
| 554 |
}
|
| 555 |
)
|
| 556 |
|
models/stadium_lookup.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
# All 30 MLB stadiums + special international venues.
|
| 4 |
+
# Keys are canonical lowercase names matching features/park_factors.py.
|
| 5 |
+
# cf_bearing: compass degrees from home plate → center field (0=N, 90=E, 180=S, 270=W).
|
| 6 |
+
# Meteorological note: wind_direction_deg = direction FROM which wind blows.
|
| 7 |
+
|
| 8 |
+
STADIUM_REGISTRY: dict[str, dict] = {
|
| 9 |
+
# ── AL East ──────────────────────────────────────────────────────────────
|
| 10 |
+
"fenway park": {
|
| 11 |
+
"canonical_name": "fenway park",
|
| 12 |
+
"lat": 42.3467,
|
| 13 |
+
"lon": -71.0972,
|
| 14 |
+
"cf_bearing": 95.0,
|
| 15 |
+
"aliases": ["fenway", "fenway park"],
|
| 16 |
+
},
|
| 17 |
+
"yankee stadium": {
|
| 18 |
+
"canonical_name": "yankee stadium",
|
| 19 |
+
"lat": 40.8296,
|
| 20 |
+
"lon": -73.9262,
|
| 21 |
+
"cf_bearing": 230.0,
|
| 22 |
+
"aliases": ["yankee", "new yankee stadium"],
|
| 23 |
+
},
|
| 24 |
+
"oriole park at camden yards": {
|
| 25 |
+
"canonical_name": "oriole park at camden yards",
|
| 26 |
+
"lat": 39.2838,
|
| 27 |
+
"lon": -76.6218,
|
| 28 |
+
"cf_bearing": 350.0,
|
| 29 |
+
"aliases": ["camden yards", "oriole park", "camden"],
|
| 30 |
+
},
|
| 31 |
+
"rogers centre": {
|
| 32 |
+
"canonical_name": "rogers centre",
|
| 33 |
+
"lat": 43.6414,
|
| 34 |
+
"lon": -79.3894,
|
| 35 |
+
"cf_bearing": 255.0,
|
| 36 |
+
"aliases": ["rogers center", "rogers", "skydome"],
|
| 37 |
+
},
|
| 38 |
+
"tropicana field": {
|
| 39 |
+
"canonical_name": "tropicana field",
|
| 40 |
+
"lat": 27.7683,
|
| 41 |
+
"lon": -82.6534,
|
| 42 |
+
"cf_bearing": 325.0,
|
| 43 |
+
"aliases": ["tropicana", "the trop", "trop"],
|
| 44 |
+
},
|
| 45 |
+
# ── AL Central ────────────────────────────────────────────────────────────
|
| 46 |
+
"guaranteed rate field": {
|
| 47 |
+
"canonical_name": "guaranteed rate field",
|
| 48 |
+
"lat": 41.8299,
|
| 49 |
+
"lon": -87.6338,
|
| 50 |
+
"cf_bearing": 355.0,
|
| 51 |
+
"aliases": ["guaranteed rate", "comiskey park", "us cellular field", "rate field"],
|
| 52 |
+
},
|
| 53 |
+
"progressive field": {
|
| 54 |
+
"canonical_name": "progressive field",
|
| 55 |
+
"lat": 41.4955,
|
| 56 |
+
"lon": -81.6850,
|
| 57 |
+
"cf_bearing": 10.0,
|
| 58 |
+
"aliases": ["progressive", "jacobs field", "the jake"],
|
| 59 |
+
},
|
| 60 |
+
"comerica park": {
|
| 61 |
+
"canonical_name": "comerica park",
|
| 62 |
+
"lat": 42.3390,
|
| 63 |
+
"lon": -83.0485,
|
| 64 |
+
"cf_bearing": 340.0,
|
| 65 |
+
"aliases": ["comerica", "tiger stadium"],
|
| 66 |
+
},
|
| 67 |
+
"kauffman stadium": {
|
| 68 |
+
"canonical_name": "kauffman stadium",
|
| 69 |
+
"lat": 39.0517,
|
| 70 |
+
"lon": -94.4803,
|
| 71 |
+
"cf_bearing": 0.0,
|
| 72 |
+
"aliases": ["kauffman", "royals stadium", "ewing kauffman"],
|
| 73 |
+
},
|
| 74 |
+
"target field": {
|
| 75 |
+
"canonical_name": "target field",
|
| 76 |
+
"lat": 44.9817,
|
| 77 |
+
"lon": -93.2781,
|
| 78 |
+
"cf_bearing": 340.0,
|
| 79 |
+
"aliases": ["target", "twins field"],
|
| 80 |
+
},
|
| 81 |
+
# ── AL West ───────────────────────────────────────────────────────────────
|
| 82 |
+
"minute maid park": {
|
| 83 |
+
"canonical_name": "minute maid park",
|
| 84 |
+
"lat": 29.7572,
|
| 85 |
+
"lon": -95.3555,
|
| 86 |
+
"cf_bearing": 330.0,
|
| 87 |
+
"aliases": ["minute maid", "enron field", "astros park"],
|
| 88 |
+
},
|
| 89 |
+
"angel stadium": {
|
| 90 |
+
"canonical_name": "angel stadium",
|
| 91 |
+
"lat": 33.8003,
|
| 92 |
+
"lon": -117.8827,
|
| 93 |
+
"cf_bearing": 0.0,
|
| 94 |
+
"aliases": ["angel stadium of anaheim", "angels stadium", "big a"],
|
| 95 |
+
},
|
| 96 |
+
"t-mobile park": {
|
| 97 |
+
"canonical_name": "t-mobile park",
|
| 98 |
+
"lat": 47.5912,
|
| 99 |
+
"lon": -122.3321,
|
| 100 |
+
"cf_bearing": 335.0,
|
| 101 |
+
"aliases": ["t mobile park", "tmobile park", "safeco field", "t-mobile"],
|
| 102 |
+
},
|
| 103 |
+
"athletics ballpark": {
|
| 104 |
+
"canonical_name": "athletics ballpark",
|
| 105 |
+
"lat": 38.5802,
|
| 106 |
+
"lon": -121.5168,
|
| 107 |
+
"cf_bearing": 10.0,
|
| 108 |
+
"aliases": [
|
| 109 |
+
"sutter health park",
|
| 110 |
+
"oakland coliseum",
|
| 111 |
+
"oakland-alameda county coliseum",
|
| 112 |
+
"the coliseum",
|
| 113 |
+
"ringcentral coliseum",
|
| 114 |
+
"athletics",
|
| 115 |
+
],
|
| 116 |
+
},
|
| 117 |
+
"globe life field": {
|
| 118 |
+
"canonical_name": "globe life field",
|
| 119 |
+
"lat": 32.7473,
|
| 120 |
+
"lon": -97.0824,
|
| 121 |
+
"cf_bearing": 330.0,
|
| 122 |
+
"aliases": ["globe life", "rangers ballpark", "globe life park"],
|
| 123 |
+
},
|
| 124 |
+
# ── NL East ───────────────────────────────────────────────────────────────
|
| 125 |
+
"truist park": {
|
| 126 |
+
"canonical_name": "truist park",
|
| 127 |
+
"lat": 33.8906,
|
| 128 |
+
"lon": -84.4677,
|
| 129 |
+
"cf_bearing": 315.0,
|
| 130 |
+
"aliases": ["truist", "suntrust park", "braves park"],
|
| 131 |
+
},
|
| 132 |
+
"citi field": {
|
| 133 |
+
"canonical_name": "citi field",
|
| 134 |
+
"lat": 40.7571,
|
| 135 |
+
"lon": -73.8458,
|
| 136 |
+
"cf_bearing": 355.0,
|
| 137 |
+
"aliases": ["citi", "shea stadium"],
|
| 138 |
+
},
|
| 139 |
+
"citizens bank park": {
|
| 140 |
+
"canonical_name": "citizens bank park",
|
| 141 |
+
"lat": 39.9057,
|
| 142 |
+
"lon": -75.1665,
|
| 143 |
+
"cf_bearing": 340.0,
|
| 144 |
+
"aliases": ["citizens bank", "phillies park", "cbp"],
|
| 145 |
+
},
|
| 146 |
+
"nationals park": {
|
| 147 |
+
"canonical_name": "nationals park",
|
| 148 |
+
"lat": 38.8730,
|
| 149 |
+
"lon": -77.0074,
|
| 150 |
+
"cf_bearing": 345.0,
|
| 151 |
+
"aliases": ["nationals", "nats park"],
|
| 152 |
+
},
|
| 153 |
+
"loandepot park": {
|
| 154 |
+
"canonical_name": "loandepot park",
|
| 155 |
+
"lat": 25.7781,
|
| 156 |
+
"lon": -80.2197,
|
| 157 |
+
"cf_bearing": 330.0,
|
| 158 |
+
"aliases": ["loan depot park", "loandepot", "marlins park", "miami marlins stadium"],
|
| 159 |
+
},
|
| 160 |
+
# ── NL Central ────────────────────────────────────────────────────────────
|
| 161 |
+
"wrigley field": {
|
| 162 |
+
"canonical_name": "wrigley field",
|
| 163 |
+
"lat": 41.9484,
|
| 164 |
+
"lon": -87.6553,
|
| 165 |
+
"cf_bearing": 330.0,
|
| 166 |
+
"aliases": ["wrigley", "cubs park"],
|
| 167 |
+
},
|
| 168 |
+
"great american ball park": {
|
| 169 |
+
"canonical_name": "great american ball park",
|
| 170 |
+
"lat": 39.0975,
|
| 171 |
+
"lon": -84.5068,
|
| 172 |
+
"cf_bearing": 330.0,
|
| 173 |
+
"aliases": ["great american ballpark", "great american", "gabp", "reds park"],
|
| 174 |
+
},
|
| 175 |
+
"american family field": {
|
| 176 |
+
"canonical_name": "american family field",
|
| 177 |
+
"lat": 43.0280,
|
| 178 |
+
"lon": -87.9712,
|
| 179 |
+
"cf_bearing": 340.0,
|
| 180 |
+
"aliases": ["american family", "miller park", "brewers park"],
|
| 181 |
+
},
|
| 182 |
+
"pnc park": {
|
| 183 |
+
"canonical_name": "pnc park",
|
| 184 |
+
"lat": 40.4469,
|
| 185 |
+
"lon": -80.0057,
|
| 186 |
+
"cf_bearing": 340.0,
|
| 187 |
+
"aliases": ["pnc", "pirates park"],
|
| 188 |
+
},
|
| 189 |
+
"busch stadium": {
|
| 190 |
+
"canonical_name": "busch stadium",
|
| 191 |
+
"lat": 38.6226,
|
| 192 |
+
"lon": -90.1928,
|
| 193 |
+
"cf_bearing": 330.0,
|
| 194 |
+
"aliases": ["busch", "cardinals park", "new busch"],
|
| 195 |
+
},
|
| 196 |
+
# ── NL West ───────────────────────────────────────────────────────────────
|
| 197 |
+
"chase field": {
|
| 198 |
+
"canonical_name": "chase field",
|
| 199 |
+
"lat": 33.4453,
|
| 200 |
+
"lon": -112.0667,
|
| 201 |
+
"cf_bearing": 330.0,
|
| 202 |
+
"aliases": ["chase", "bank one ballpark", "boa ballpark", "diamondbacks park"],
|
| 203 |
+
},
|
| 204 |
+
"coors field": {
|
| 205 |
+
"canonical_name": "coors field",
|
| 206 |
+
"lat": 39.7559,
|
| 207 |
+
"lon": -104.9942,
|
| 208 |
+
"cf_bearing": 335.0,
|
| 209 |
+
"aliases": ["coors", "rockies park"],
|
| 210 |
+
},
|
| 211 |
+
"dodger stadium": {
|
| 212 |
+
"canonical_name": "dodger stadium",
|
| 213 |
+
"lat": 34.0739,
|
| 214 |
+
"lon": -118.2400,
|
| 215 |
+
"cf_bearing": 335.0,
|
| 216 |
+
"aliases": ["dodger", "chavez ravine", "dodgers stadium"],
|
| 217 |
+
},
|
| 218 |
+
"oracle park": {
|
| 219 |
+
"canonical_name": "oracle park",
|
| 220 |
+
"lat": 37.7786,
|
| 221 |
+
"lon": -122.3893,
|
| 222 |
+
"cf_bearing": 25.0,
|
| 223 |
+
"aliases": ["oracle", "at&t park", "att park", "pac bell park", "giants park"],
|
| 224 |
+
},
|
| 225 |
+
"petco park": {
|
| 226 |
+
"canonical_name": "petco park",
|
| 227 |
+
"lat": 32.7076,
|
| 228 |
+
"lon": -117.1570,
|
| 229 |
+
"cf_bearing": 320.0,
|
| 230 |
+
"aliases": ["petco", "padres park"],
|
| 231 |
+
},
|
| 232 |
+
# ── Special / International venues ───────────────────────────────────────
|
| 233 |
+
"rickwood field": {
|
| 234 |
+
"canonical_name": "rickwood field",
|
| 235 |
+
"lat": 33.5186,
|
| 236 |
+
"lon": -86.8355,
|
| 237 |
+
"cf_bearing": 340.0,
|
| 238 |
+
"aliases": ["rickwood"],
|
| 239 |
+
},
|
| 240 |
+
"estadio alfredo harp helu": {
|
| 241 |
+
"canonical_name": "estadio alfredo harp helu",
|
| 242 |
+
"lat": 19.4810,
|
| 243 |
+
"lon": -99.0892,
|
| 244 |
+
"cf_bearing": 0.0,
|
| 245 |
+
"aliases": ["estadio alfredo harp helú", "alfredo harp helu", "mexico city stadium"],
|
| 246 |
+
},
|
| 247 |
+
"london stadium": {
|
| 248 |
+
"canonical_name": "london stadium",
|
| 249 |
+
"lat": 51.5386,
|
| 250 |
+
"lon": -0.0163,
|
| 251 |
+
"cf_bearing": 0.0,
|
| 252 |
+
"aliases": ["london", "olympic stadium london"],
|
| 253 |
+
},
|
| 254 |
+
"olympic stadium": {
|
| 255 |
+
"canonical_name": "olympic stadium",
|
| 256 |
+
"lat": 45.5598,
|
| 257 |
+
"lon": -73.5515,
|
| 258 |
+
"cf_bearing": 0.0,
|
| 259 |
+
"aliases": ["stade olympique", "montreal olympic", "montreal stadium"],
|
| 260 |
+
},
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def resolve_stadium(venue_name: str) -> dict | None:
|
| 265 |
+
"""
|
| 266 |
+
Resolve a venue name string to a STADIUM_REGISTRY entry.
|
| 267 |
+
|
| 268 |
+
Resolution order:
|
| 269 |
+
1. Direct match on lowercase key
|
| 270 |
+
2. Alias scan across all entries
|
| 271 |
+
3. Partial-match fallback (substring either direction)
|
| 272 |
+
|
| 273 |
+
Returns the registry dict on match, or None if unresolvable.
|
| 274 |
+
"""
|
| 275 |
+
if not venue_name:
|
| 276 |
+
return None
|
| 277 |
+
|
| 278 |
+
normalized = venue_name.strip().lower()
|
| 279 |
+
if not normalized:
|
| 280 |
+
return None
|
| 281 |
+
|
| 282 |
+
# 1. Direct key match
|
| 283 |
+
if normalized in STADIUM_REGISTRY:
|
| 284 |
+
return STADIUM_REGISTRY[normalized]
|
| 285 |
+
|
| 286 |
+
# 2. Alias scan
|
| 287 |
+
for entry in STADIUM_REGISTRY.values():
|
| 288 |
+
for alias in entry.get("aliases", []):
|
| 289 |
+
if normalized == alias.strip().lower():
|
| 290 |
+
return entry
|
| 291 |
+
|
| 292 |
+
# 3. Partial-match fallback
|
| 293 |
+
for key, entry in STADIUM_REGISTRY.items():
|
| 294 |
+
if normalized in key or key in normalized:
|
| 295 |
+
return entry
|
| 296 |
+
# Also check aliases for partial match
|
| 297 |
+
for entry in STADIUM_REGISTRY.values():
|
| 298 |
+
for alias in entry.get("aliases", []):
|
| 299 |
+
alias_lower = alias.strip().lower()
|
| 300 |
+
if normalized in alias_lower or alias_lower in normalized:
|
| 301 |
+
return entry
|
| 302 |
+
|
| 303 |
+
return None
|
models/weather_provider.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from datetime import datetime, timedelta, timezone
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def fetch_game_time_weather(lat: float, lon: float, game_datetime_utc: str) -> dict | None:
|
| 14 |
+
"""
|
| 15 |
+
Fetch Open-Meteo hourly weather for the given coordinates at game time.
|
| 16 |
+
|
| 17 |
+
Parses game_datetime_utc (ISO 8601 UTC, e.g. "2026-04-01T23:10:00Z"),
|
| 18 |
+
rounds to the nearest hour, and extracts temp/wind/humidity for that hour.
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
dict with keys: temperature_f, wind_speed_mph, wind_direction_deg, humidity_pct
|
| 22 |
+
or None on any failure (network error, parse error, missing data).
|
| 23 |
+
|
| 24 |
+
No API key required — Open-Meteo is free and key-free.
|
| 25 |
+
"""
|
| 26 |
+
try:
|
| 27 |
+
dt_str = str(game_datetime_utc or "").strip()
|
| 28 |
+
if not dt_str:
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
# Normalize "Z" suffix for fromisoformat compatibility
|
| 32 |
+
dt_str_clean = dt_str.replace("Z", "+00:00")
|
| 33 |
+
game_dt = datetime.fromisoformat(dt_str_clean)
|
| 34 |
+
if game_dt.tzinfo is None:
|
| 35 |
+
game_dt = game_dt.replace(tzinfo=timezone.utc)
|
| 36 |
+
|
| 37 |
+
# Round to nearest hour
|
| 38 |
+
if game_dt.minute >= 30:
|
| 39 |
+
target_dt = game_dt.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
| 40 |
+
else:
|
| 41 |
+
target_dt = game_dt.replace(minute=0, second=0, microsecond=0)
|
| 42 |
+
|
| 43 |
+
date_str = target_dt.strftime("%Y-%m-%d")
|
| 44 |
+
target_hour = target_dt.hour
|
| 45 |
+
|
| 46 |
+
params = {
|
| 47 |
+
"latitude": lat,
|
| 48 |
+
"longitude": lon,
|
| 49 |
+
"hourly": "temperature_2m,wind_speed_10m,wind_direction_10m,relative_humidity_2m",
|
| 50 |
+
"start_date": date_str,
|
| 51 |
+
"end_date": date_str,
|
| 52 |
+
"temperature_unit": "fahrenheit",
|
| 53 |
+
"wind_speed_unit": "mph",
|
| 54 |
+
"timezone": "UTC",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
response = requests.get(OPEN_METEO_URL, params=params, timeout=10)
|
| 58 |
+
response.raise_for_status()
|
| 59 |
+
data = response.json()
|
| 60 |
+
|
| 61 |
+
hourly = data.get("hourly", {}) or {}
|
| 62 |
+
times = hourly.get("time", []) or []
|
| 63 |
+
temps = hourly.get("temperature_2m", []) or []
|
| 64 |
+
winds = hourly.get("wind_speed_10m", []) or []
|
| 65 |
+
wind_dirs = hourly.get("wind_direction_10m", []) or []
|
| 66 |
+
humidities = hourly.get("relative_humidity_2m", []) or []
|
| 67 |
+
|
| 68 |
+
# Find index for target hour
|
| 69 |
+
target_time_str = f"{date_str}T{target_hour:02d}:00"
|
| 70 |
+
hour_idx = None
|
| 71 |
+
for i, t in enumerate(times):
|
| 72 |
+
if str(t).startswith(target_time_str):
|
| 73 |
+
hour_idx = i
|
| 74 |
+
break
|
| 75 |
+
|
| 76 |
+
# Fallback: use hour index directly
|
| 77 |
+
if hour_idx is None and temps:
|
| 78 |
+
hour_idx = min(target_hour, len(temps) - 1)
|
| 79 |
+
|
| 80 |
+
if hour_idx is None:
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
def _safe(lst: list, idx: int):
|
| 84 |
+
return lst[idx] if idx < len(lst) else None
|
| 85 |
+
|
| 86 |
+
temp_f = _safe(temps, hour_idx)
|
| 87 |
+
wind_mph = _safe(winds, hour_idx)
|
| 88 |
+
wind_dir = _safe(wind_dirs, hour_idx)
|
| 89 |
+
humidity = _safe(humidities, hour_idx)
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
"temperature_f": float(temp_f) if temp_f is not None else None,
|
| 93 |
+
"wind_speed_mph": float(wind_mph) if wind_mph is not None else None,
|
| 94 |
+
"wind_direction_deg": float(wind_dir) if wind_dir is not None else None,
|
| 95 |
+
"humidity_pct": float(humidity) if humidity is not None else None,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger.debug(f"[weather_provider] fetch_game_time_weather failed: {e}")
|
| 100 |
+
return None
|