Syntrex Claude Sonnet 4.6 commited on
Commit
b96cb2a
·
1 Parent(s): def0d64

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 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 features.park_factors import venue_hr_factor, weather_hr_adjustment
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
- # Phase C2: Park factor + weather HR/hit adjustment
355
- venue = str(game_row.get("venue", "") or "").strip().lower()
356
- temp_f = float(weather_row.get("temperature_f", 72.0) or 72.0) if weather_row else None
357
- wind_mph = float(weather_row.get("wind_speed_mph", 0.0) or 0.0) if weather_row else None
358
- park_hr_fac = venue_hr_factor(venue)
359
- weather_adj_factor = weather_hr_adjustment(temp_f, wind_mph)
360
- combined_park_factor = park_hr_fac * weather_adj_factor
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