Syntrex commited on
Commit
89808f7
·
verified ·
1 Parent(s): bddca07

Update engine/live_game_engine.py

Browse files
Files changed (1) hide show
  1. engine/live_game_engine.py +235 -87
engine/live_game_engine.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  from typing import Any
4
 
5
  from models.win_probability import estimate_win_probability
 
6
 
7
  def _safe_int(value: Any) -> int:
8
  try:
@@ -15,6 +16,120 @@ def _safe_int(value: Any) -> int:
15
  except Exception:
16
  return 0
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  def format_status(inning_half: str, current_inning: int | None, fallback: str = "") -> str:
19
  if inning_half and current_inning:
20
  half_map = {
@@ -77,6 +192,13 @@ def enrich_game_from_live_feed(game: dict[str, Any], feed: dict[str, Any]) -> di
77
  return out
78
 
79
  live_data = feed.get("liveData", {}) or {}
 
 
 
 
 
 
 
80
  linescore = live_data.get("linescore", {}) or {}
81
  plays = live_data.get("plays", {}) or {}
82
  current_play = plays.get("currentPlay", {}) or {}
@@ -134,93 +256,119 @@ def enrich_game_from_live_feed(game: dict[str, Any], feed: dict[str, Any]) -> di
134
  result = current_play.get("result", {}) or {}
135
  out["last_play"] = result.get("description", "")
136
 
137
- play_events = current_play.get("playEvents", []) or []
138
- out["play_events_debug"] = str(play_events[-5:])
139
-
140
- last_pitch_event = None
141
- last_pitch_with_data = None
142
-
143
- for event in reversed(play_events):
144
- details = event.get("details", {}) or {}
145
- if details.get("isPitch"):
146
- if last_pitch_event is None:
147
- last_pitch_event = event
148
-
149
- pitch_data = event.get("pitchData", {}) or {}
150
- has_velocity = False
151
- for key in ["startSpeed", "endSpeed"]:
152
- value = pitch_data.get(key)
153
- try:
154
- if value is not None and float(value) > 40:
155
- has_velocity = True
156
- break
157
- except Exception:
158
- pass
159
-
160
- if has_velocity:
161
- last_pitch_with_data = event
162
- break
163
-
164
- selected_pitch_event = last_pitch_with_data or last_pitch_event
165
-
166
- if selected_pitch_event:
167
- details = selected_pitch_event.get("details", {}) or {}
168
- pitch_data = selected_pitch_event.get("pitchData", {}) or {}
169
- breaks = pitch_data.get("breaks", {}) or {}
170
- coordinates = pitch_data.get("coordinates", {}) or {}
171
-
172
- out["pitch_data_debug"] = str(pitch_data)
173
-
174
- pitch_type = (details.get("type", {}) or {}).get("description", "")
175
- pitch_call = (details.get("call", {}) or {}).get("description", "")
176
- pitch_description = str(details.get("description", "") or "").strip()
177
-
178
- out["pitch_type"] = pitch_type
179
- out["last_pitch"] = pitch_description or pitch_call or pitch_type
180
-
181
- velocity = None
182
- for key in ["startSpeed", "endSpeed"]:
183
- try:
184
- alt = pitch_data.get(key)
185
- if alt is not None and float(alt) > 40:
186
- velocity = float(alt)
187
- break
188
- except Exception:
189
- pass
190
-
191
- out["pitch_velocity"] = round(velocity, 1) if velocity is not None else None
192
-
193
- # extra live pitch metrics for debug / future UI
194
- out["pitch_spin_rate"] = breaks.get("spinRate")
195
- out["pitch_spin_direction"] = breaks.get("spinDirection")
196
- out["pitch_break_angle"] = breaks.get("breakAngle")
197
- out["pitch_break_length"] = breaks.get("breakLength")
198
- out["pitch_break_y"] = breaks.get("breakY")
199
- out["pitch_extension"] = pitch_data.get("extension")
200
- out["pitch_plate_x"] = coordinates.get("pX")
201
- out["pitch_plate_z"] = coordinates.get("pZ")
202
- out["pitch_pfx_x"] = coordinates.get("pfxX")
203
- out["pitch_pfx_z"] = coordinates.get("pfxZ")
204
- out["pitch_ax"] = coordinates.get("aX")
205
- out["pitch_ay"] = coordinates.get("aY")
206
- out["pitch_az"] = coordinates.get("aZ")
207
- else:
208
- out["pitch_type"] = ""
209
- out["last_pitch"] = ""
210
- out["pitch_velocity"] = None
211
- out["pitch_spin_rate"] = None
212
- out["pitch_spin_direction"] = None
213
- out["pitch_break_angle"] = None
214
- out["pitch_break_length"] = None
215
- out["pitch_break_y"] = None
216
- out["pitch_extension"] = None
217
- out["pitch_plate_x"] = None
218
- out["pitch_plate_z"] = None
219
- out["pitch_pfx_x"] = None
220
- out["pitch_pfx_z"] = None
221
- out["pitch_ax"] = None
222
- out["pitch_ay"] = None
223
- out["pitch_az"] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
  try:
226
  score_diff = int(out["away_score"] or 0) - int(out["home_score"] or 0)
 
3
  from typing import Any
4
 
5
  from models.win_probability import estimate_win_probability
6
+ from data.live_statcast_feed import fetch_live_statcast_feed
7
 
8
  def _safe_int(value: Any) -> int:
9
  try:
 
16
  except Exception:
17
  return 0
18
 
19
+ def _safe_float(value: Any) -> float | None:
20
+ try:
21
+ if value is None:
22
+ return None
23
+ text = str(value).strip().lower()
24
+ if text in {"", "nan", "none"}:
25
+ return None
26
+ return float(value)
27
+ except Exception:
28
+ return None
29
+
30
+
31
+ def _extract_latest_savant_pitch_metrics(savant_feed: dict[str, Any]) -> dict[str, Any]:
32
+ """
33
+ Defensive parser for unofficial Baseball Savant live feed payloads.
34
+
35
+ Searches recursively for pitch-like dicts containing any of:
36
+ - velocity
37
+ - spin
38
+ - extension
39
+ - movement
40
+ - pitch type / call / description
41
+ """
42
+ if not savant_feed:
43
+ return {}
44
+
45
+ candidates: list[dict[str, Any]] = []
46
+
47
+ def walk(obj: Any) -> None:
48
+ if isinstance(obj, dict):
49
+ lowered = {str(k).lower(): v for k, v in obj.items()}
50
+
51
+ keys = set(lowered.keys())
52
+ interesting = any(
53
+ key in keys
54
+ for key in [
55
+ "startspeed",
56
+ "endspeed",
57
+ "pitchvelocity",
58
+ "velocity",
59
+ "spinrate",
60
+ "spin_rate",
61
+ "extension",
62
+ "pfxx",
63
+ "pfxz",
64
+ "breakangle",
65
+ "breaklength",
66
+ "pitchtype",
67
+ "pitch_type",
68
+ "pitchname",
69
+ "pitch_name",
70
+ "description",
71
+ "pitchresult",
72
+ "pitch_result",
73
+ ]
74
+ )
75
+
76
+ if interesting:
77
+ candidates.append(obj)
78
+
79
+ for value in obj.values():
80
+ walk(value)
81
+
82
+ elif isinstance(obj, list):
83
+ for item in obj:
84
+ walk(item)
85
+
86
+ walk(savant_feed)
87
+
88
+ if not candidates:
89
+ return {}
90
+
91
+ pitch = candidates[-1]
92
+ lowered = {str(k).lower(): v for k, v in pitch.items()}
93
+
94
+ def pick(*names: str) -> Any:
95
+ for name in names:
96
+ if name.lower() in lowered:
97
+ return lowered[name.lower()]
98
+ return None
99
+
100
+ pitch_velocity = _safe_float(
101
+ pick("startSpeed", "pitchVelocity", "velocity", "release_speed", "velo", "endSpeed")
102
+ )
103
+ pitch_spin_rate = _safe_float(pick("spinRate", "spin_rate"))
104
+ pitch_extension = _safe_float(pick("extension"))
105
+ pitch_pfx_x = _safe_float(pick("pfxX", "pfx_x", "horzBreak", "horizontalBreak"))
106
+ pitch_pfx_z = _safe_float(pick("pfxZ", "pfz_z", "vertBreak", "verticalBreak"))
107
+ pitch_break_angle = _safe_float(pick("breakAngle"))
108
+ pitch_break_length = _safe_float(pick("breakLength"))
109
+
110
+ pitch_type = str(
111
+ pick("pitchType", "pitch_type", "pitchName", "pitch_name", "type", "pitch")
112
+ or ""
113
+ ).strip()
114
+
115
+ last_pitch = str(
116
+ pick("description", "pitchResult", "pitch_result", "call", "result", "outcome")
117
+ or ""
118
+ ).strip()
119
+
120
+ return {
121
+ "last_pitch": last_pitch,
122
+ "pitch_type": pitch_type,
123
+ "pitch_velocity": round(pitch_velocity, 1) if pitch_velocity is not None and pitch_velocity > 40 else None,
124
+ "pitch_spin_rate": round(pitch_spin_rate, 0) if pitch_spin_rate is not None else None,
125
+ "pitch_extension": round(pitch_extension, 1) if pitch_extension is not None else None,
126
+ "pitch_pfx_x": round(pitch_pfx_x, 2) if pitch_pfx_x is not None else None,
127
+ "pitch_pfx_z": round(pitch_pfx_z, 2) if pitch_pfx_z is not None else None,
128
+ "pitch_break_angle": round(pitch_break_angle, 1) if pitch_break_angle is not None else None,
129
+ "pitch_break_length": round(pitch_break_length, 1) if pitch_break_length is not None else None,
130
+ "savant_pitch_debug": str(pitch)[:2000],
131
+ }
132
+
133
  def format_status(inning_half: str, current_inning: int | None, fallback: str = "") -> str:
134
  if inning_half and current_inning:
135
  half_map = {
 
192
  return out
193
 
194
  live_data = feed.get("liveData", {}) or {}
195
+ savant_feed = {}
196
+ try:
197
+ game_pk = str(out.get("game_pk", "") or "").strip()
198
+ if game_pk:
199
+ savant_feed = fetch_live_statcast_feed(game_pk)
200
+ except Exception:
201
+ savant_feed = {}
202
  linescore = live_data.get("linescore", {}) or {}
203
  plays = live_data.get("plays", {}) or {}
204
  current_play = plays.get("currentPlay", {}) or {}
 
256
  result = current_play.get("result", {}) or {}
257
  out["last_play"] = result.get("description", "")
258
 
259
+ def _safe_float(value: Any) -> float | None:
260
+ try:
261
+ if value is None:
262
+ return None
263
+ text = str(value).strip().lower()
264
+ if text in {"", "nan", "none"}:
265
+ return None
266
+ return float(value)
267
+ except Exception:
268
+ return None
269
+
270
+
271
+ def _extract_latest_savant_pitch_metrics(savant_feed: dict[str, Any]) -> dict[str, Any]:
272
+ """
273
+ Defensive parser for unofficial Baseball Savant live feed payloads.
274
+
275
+ Searches recursively for pitch-like dicts containing any of:
276
+ - velocity
277
+ - spin
278
+ - extension
279
+ - movement
280
+ - pitch type / call / description
281
+ """
282
+ if not savant_feed:
283
+ return {}
284
+
285
+ candidates: list[dict[str, Any]] = []
286
+
287
+ def walk(obj: Any) -> None:
288
+ if isinstance(obj, dict):
289
+ lowered = {str(k).lower(): v for k, v in obj.items()}
290
+
291
+ keys = set(lowered.keys())
292
+ interesting = any(
293
+ key in keys
294
+ for key in [
295
+ "startspeed",
296
+ "endspeed",
297
+ "pitchvelocity",
298
+ "velocity",
299
+ "spinrate",
300
+ "spin_rate",
301
+ "extension",
302
+ "pfxx",
303
+ "pfxz",
304
+ "breakangle",
305
+ "breaklength",
306
+ "pitchtype",
307
+ "pitch_type",
308
+ "pitchname",
309
+ "pitch_name",
310
+ "description",
311
+ "pitchresult",
312
+ "pitch_result",
313
+ ]
314
+ )
315
+
316
+ if interesting:
317
+ candidates.append(obj)
318
+
319
+ for value in obj.values():
320
+ walk(value)
321
+
322
+ elif isinstance(obj, list):
323
+ for item in obj:
324
+ walk(item)
325
+
326
+ walk(savant_feed)
327
+
328
+ if not candidates:
329
+ return {}
330
+
331
+ pitch = candidates[-1]
332
+ lowered = {str(k).lower(): v for k, v in pitch.items()}
333
+
334
+ def pick(*names: str) -> Any:
335
+ for name in names:
336
+ if name.lower() in lowered:
337
+ return lowered[name.lower()]
338
+ return None
339
+
340
+ pitch_velocity = _safe_float(
341
+ pick("startSpeed", "pitchVelocity", "velocity", "release_speed", "velo", "endSpeed")
342
+ )
343
+ pitch_spin_rate = _safe_float(pick("spinRate", "spin_rate"))
344
+ pitch_extension = _safe_float(pick("extension"))
345
+ pitch_pfx_x = _safe_float(pick("pfxX", "pfx_x", "horzBreak", "horizontalBreak"))
346
+ pitch_pfx_z = _safe_float(pick("pfxZ", "pfz_z", "vertBreak", "verticalBreak"))
347
+ pitch_break_angle = _safe_float(pick("breakAngle"))
348
+ pitch_break_length = _safe_float(pick("breakLength"))
349
+
350
+ pitch_type = str(
351
+ pick("pitchType", "pitch_type", "pitchName", "pitch_name", "type", "pitch")
352
+ or ""
353
+ ).strip()
354
+
355
+ last_pitch = str(
356
+ pick("description", "pitchResult", "pitch_result", "call", "result", "outcome")
357
+ or ""
358
+ ).strip()
359
+
360
+ return {
361
+ "last_pitch": last_pitch,
362
+ "pitch_type": pitch_type,
363
+ "pitch_velocity": round(pitch_velocity, 1) if pitch_velocity is not None and pitch_velocity > 40 else None,
364
+ "pitch_spin_rate": round(pitch_spin_rate, 0) if pitch_spin_rate is not None else None,
365
+ "pitch_extension": round(pitch_extension, 1) if pitch_extension is not None else None,
366
+ "pitch_pfx_x": round(pitch_pfx_x, 2) if pitch_pfx_x is not None else None,
367
+ "pitch_pfx_z": round(pitch_pfx_z, 2) if pitch_pfx_z is not None else None,
368
+ "pitch_break_angle": round(pitch_break_angle, 1) if pitch_break_angle is not None else None,
369
+ "pitch_break_length": round(pitch_break_length, 1) if pitch_break_length is not None else None,
370
+ "savant_pitch_debug": str(pitch)[:2000],
371
+ }
372
 
373
  try:
374
  score_diff = int(out["away_score"] or 0) - int(out["home_score"] or 0)