Spaces:
Running
Running
| from __future__ import annotations | |
| """ | |
| Batch 12E — Rolling Upcoming Form Layer | |
| Computes game-based (5g / 10g) rolling batter and pitcher form metrics and | |
| translates them into bounded additive probability adjustments for the UPCOMING | |
| game engine only. | |
| Design principles: | |
| - Returns absolute rolling values; deltas are computed in the adjustment function | |
| against stable batter_features / pitcher_row baselines (NOT recomputed from the | |
| same narrow window). | |
| - Sample-aware: weak windows (< 2 games) produce zero adjustment. | |
| - 10g window used as confirmation/dampening of 5g signal. | |
| - Pitcher-side adjustments scaled by pitcher_rolling_confidence (match quality | |
| × sample availability). | |
| - Hard-capped adjustments; no runaway boosts. | |
| - Pull/direction metrics SKIPPED (spray_angle not in normalized statcast). | |
| - Zone/heart-rate metrics SKIPPED (not in normalized statcast). | |
| """ | |
| import logging | |
| import re | |
| import unicodedata | |
| from datetime import date, datetime | |
| from typing import Any | |
| import pandas as pd | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Shared helpers (same barrel definition and utils as batter_trend_model) | |
| # --------------------------------------------------------------------------- | |
| def _parse_reference_date(reference_date: Any) -> date | None: | |
| if reference_date is None: | |
| return None | |
| if isinstance(reference_date, datetime): | |
| return reference_date.date() | |
| if isinstance(reference_date, date): | |
| return reference_date | |
| if isinstance(reference_date, str): | |
| for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S"): | |
| try: | |
| return datetime.strptime(reference_date[:19], fmt).date() | |
| except ValueError: | |
| continue | |
| return None | |
| def _percentile(series: pd.Series, q: float) -> float | None: | |
| numeric = pd.to_numeric(series, errors="coerce").dropna() | |
| if len(numeric) < 5: | |
| return None | |
| return float(numeric.quantile(q)) | |
| def _safe_mean(series: pd.Series) -> float | None: | |
| numeric = pd.to_numeric(series, errors="coerce").dropna() | |
| if len(numeric) < 5: | |
| return None | |
| return float(numeric.mean()) | |
| def _barrel_rate(launch_speed: pd.Series, launch_angle: pd.Series) -> float | None: | |
| valid = pd.DataFrame( | |
| { | |
| "ls": pd.to_numeric(launch_speed, errors="coerce"), | |
| "la": pd.to_numeric(launch_angle, errors="coerce"), | |
| } | |
| ).dropna() | |
| if len(valid) < 5: | |
| return None | |
| mask = ( | |
| ((valid["ls"] >= 98) & (valid["la"].between(26, 30))) | |
| | ((valid["ls"] >= 99) & (valid["la"].between(25, 31))) | |
| | ((valid["ls"] >= 100) & (valid["la"].between(23, 33))) | |
| | ((valid["ls"] >= 102) & (valid["la"].between(20, 35))) | |
| ) | |
| return float(mask.mean()) | |
| def _safe_rate_from_la( | |
| launch_angle: pd.Series, | |
| lo: float, | |
| hi: float | None = None, | |
| ) -> float | None: | |
| """Fraction of non-null LA rows where lo <= la < hi (or la >= lo if hi is None).""" | |
| la = pd.to_numeric(launch_angle, errors="coerce").dropna() | |
| if len(la) < 5: | |
| return None | |
| if hi is None: | |
| return float((la >= lo).mean()) | |
| return float(((la >= lo) & (la < hi)).mean()) | |
| def _n_games(df: pd.DataFrame) -> int: | |
| """Count unique game_pk values in a slice; fall back to row-count heuristic.""" | |
| if "game_pk" in df.columns: | |
| return int(df["game_pk"].nunique()) | |
| return len(df) | |
| # --------------------------------------------------------------------------- | |
| # Game-window helper | |
| # --------------------------------------------------------------------------- | |
| def _game_window_df(player_df: pd.DataFrame, ref: date, n_games: int) -> pd.DataFrame: | |
| """ | |
| Return rows for the last `n_games` unique games before `ref` (exclusive). | |
| Sorting is by `game_date` descending; unique `game_pk` values are taken in | |
| that order. Falls back to the last N×25 rows (rough PA estimate) if | |
| `game_pk` is unavailable. | |
| """ | |
| if player_df.empty: | |
| return player_df.iloc[0:0] | |
| if "game_date" not in player_df.columns: | |
| return player_df.iloc[0:0] | |
| game_dates = pd.to_datetime(player_df["game_date"], errors="coerce") | |
| cutoff = pd.Timestamp(ref) | |
| before_ref = player_df[game_dates < cutoff].copy() | |
| if before_ref.empty: | |
| return before_ref | |
| before_ref["_gd"] = pd.to_datetime(before_ref["game_date"], errors="coerce") | |
| if "game_pk" in before_ref.columns: | |
| before_ref["_gpk"] = pd.to_numeric(before_ref["game_pk"], errors="coerce") | |
| sorted_games = ( | |
| before_ref.groupby("_gpk")["_gd"] | |
| .max() | |
| .sort_values(ascending=False) | |
| .head(n_games) | |
| .index.tolist() | |
| ) | |
| result = before_ref[before_ref["_gpk"].isin(sorted_games)].drop( | |
| columns=["_gd", "_gpk"], errors="ignore" | |
| ) | |
| return result | |
| # Fallback: no game_pk — take last n_games*25 rows sorted by date | |
| fallback = before_ref.sort_values("_gd", ascending=False).head(n_games * 25) | |
| return fallback.drop(columns=["_gd"], errors="ignore") | |
| # --------------------------------------------------------------------------- | |
| # Pitcher name normalization (mirrors pitcher_adjustment.py) | |
| # --------------------------------------------------------------------------- | |
| def _normalize_name(name: str) -> str: | |
| text = str(name or "").strip().lower() | |
| text = unicodedata.normalize("NFKD", text) | |
| text = "".join(ch for ch in text if not unicodedata.combining(ch)) | |
| text = text.replace(",", " ") | |
| text = re.sub(r"\s+", " ", text).strip() | |
| return text | |
| def _name_variants(name: str) -> set[str]: | |
| normalized = _normalize_name(name) | |
| if not normalized: | |
| return set() | |
| parts = normalized.split() | |
| variants = {normalized} | |
| if len(parts) >= 2: | |
| first, last = parts[0], parts[-1] | |
| middle = " ".join(parts[1:-1]).strip() | |
| variants.add(f"{last} {first}".strip()) | |
| if middle: | |
| variants.add(f"{last} {first} {middle}".strip()) | |
| return variants | |
| # --------------------------------------------------------------------------- | |
| # Empty skeletons | |
| # --------------------------------------------------------------------------- | |
| _EMPTY_BATTER_ROLL: dict[str, Any] = { | |
| "batter_ev_5g": None, | |
| "batter_ev_10g": None, | |
| "batter_ev90_5g": None, | |
| "batter_ev90_10g": None, | |
| "batter_hard_hit_rate_5g": None, | |
| "batter_hard_hit_rate_10g": None, | |
| "batter_barrel_rate_5g": None, | |
| "batter_barrel_rate_10g": None, | |
| "batter_avg_launch_angle_5g": None, | |
| "batter_avg_launch_angle_10g": None, | |
| "batter_fb_rate_5g": None, | |
| "batter_fb_rate_10g": None, | |
| "batter_ld_rate_5g": None, | |
| "batter_gb_rate_5g": None, | |
| "batter_air_ball_rate_5g": None, | |
| "batter_hr_rate_5g": None, | |
| "batter_hr_rate_10g": None, | |
| # direction metrics deferred (spray_angle not in normalized statcast) | |
| "batter_pull_air_rate_5g": None, | |
| "batter_pulled_hard_air_rate_5g": None, | |
| "batter_pulled_barrel_rate_5g": None, | |
| "batter_games_in_window_5g": 0, | |
| "batter_games_in_window_10g": 0, | |
| "batter_recent_form_available": 0, | |
| } | |
| _EMPTY_PITCHER_ROLL: dict[str, Any] = { | |
| "pitcher_avg_release_speed_5g": None, | |
| "pitcher_avg_release_speed_10g": None, | |
| "pitcher_avg_release_spin_rate_5g": None, | |
| "pitcher_ev_allowed_5g": None, | |
| "pitcher_ev_allowed_10g": None, | |
| "pitcher_hard_hit_rate_allowed_5g": None, | |
| "pitcher_hard_hit_rate_allowed_10g": None, | |
| "pitcher_barrel_rate_allowed_5g": None, | |
| "pitcher_barrel_rate_allowed_10g": None, | |
| "pitcher_avg_launch_angle_allowed_5g": None, | |
| "pitcher_fb_rate_allowed_5g": None, | |
| "pitcher_ld_rate_allowed_5g": None, | |
| "pitcher_gb_rate_allowed_5g": None, | |
| "pitcher_hr_allowed_rate_5g": None, | |
| "pitcher_hr_allowed_rate_10g": None, | |
| "pitcher_games_in_window_5g": 0, | |
| "pitcher_games_in_window_10g": 0, | |
| "pitcher_recent_form_available": 0, | |
| "pitcher_rolling_confidence": 0.0, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Public API — batter rolling form | |
| # --------------------------------------------------------------------------- | |
| def build_batter_rolling_form_row( | |
| statcast_df: pd.DataFrame, | |
| player_name: str, | |
| reference_date: Any = None, | |
| ) -> dict[str, Any]: | |
| """ | |
| Compute game-based 5g / 10g rolling form metrics for *player_name*. | |
| Returns absolute rolling values only; delta vs. baseline is handled in | |
| compute_upcoming_rolling_adjustment() against stable batter_features values. | |
| """ | |
| if statcast_df is None or statcast_df.empty: | |
| return dict(_EMPTY_BATTER_ROLL) | |
| ref = _parse_reference_date(reference_date) | |
| if ref is None: | |
| return dict(_EMPTY_BATTER_ROLL) | |
| try: | |
| player_df = statcast_df[ | |
| statcast_df["player_name"].astype(str) == str(player_name) | |
| ].copy() | |
| except Exception: | |
| return dict(_EMPTY_BATTER_ROLL) | |
| if player_df.empty: | |
| return dict(_EMPTY_BATTER_ROLL) | |
| df5 = _game_window_df(player_df, ref, 5) | |
| df10 = _game_window_df(player_df, ref, 10) | |
| n5 = _n_games(df5) | |
| n10 = _n_games(df10) | |
| def _hr_rate(df: pd.DataFrame) -> float | None: | |
| if "events" not in df.columns or len(df) < 5: | |
| return None | |
| events = df["events"].dropna().astype(str) | |
| if events.empty: | |
| return None | |
| return float((events == "home_run").mean()) | |
| def _hh_rate(df: pd.DataFrame) -> float | None: | |
| ls = pd.to_numeric(df.get("launch_speed", pd.Series(dtype=float)), errors="coerce").dropna() | |
| if len(ls) < 5: | |
| return None | |
| return float((ls >= 95).mean()) | |
| ls5 = df5.get("launch_speed", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float) | |
| la5 = df5.get("launch_angle", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float) | |
| ls10 = df10.get("launch_speed", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float) | |
| la10 = df10.get("launch_angle", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float) | |
| return { | |
| "batter_ev_5g": _safe_mean(ls5), | |
| "batter_ev_10g": _safe_mean(ls10), | |
| "batter_ev90_5g": _percentile(ls5, 0.90), | |
| "batter_ev90_10g": _percentile(ls10, 0.90), | |
| "batter_hard_hit_rate_5g": _hh_rate(df5), | |
| "batter_hard_hit_rate_10g": _hh_rate(df10), | |
| "batter_barrel_rate_5g": _barrel_rate(ls5, la5), | |
| "batter_barrel_rate_10g": _barrel_rate(ls10, la10), | |
| "batter_avg_launch_angle_5g": _safe_mean(la5), | |
| "batter_avg_launch_angle_10g": _safe_mean(la10), | |
| "batter_fb_rate_5g": _safe_rate_from_la(la5, 25.0), | |
| "batter_fb_rate_10g": _safe_rate_from_la(la10, 25.0), | |
| "batter_ld_rate_5g": _safe_rate_from_la(la5, 10.0, 25.0), | |
| "batter_gb_rate_5g": _safe_rate_from_la(la5, -90.0, 10.0), | |
| "batter_air_ball_rate_5g": _safe_rate_from_la(la5, 10.0), | |
| "batter_hr_rate_5g": _hr_rate(df5), | |
| "batter_hr_rate_10g": _hr_rate(df10), | |
| # direction metrics deferred | |
| "batter_pull_air_rate_5g": None, | |
| "batter_pulled_hard_air_rate_5g": None, | |
| "batter_pulled_barrel_rate_5g": None, | |
| "batter_games_in_window_5g": n5, | |
| "batter_games_in_window_10g": n10, | |
| "batter_recent_form_available": 1 if n5 >= 4 else 0, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Public API — pitcher rolling form | |
| # --------------------------------------------------------------------------- | |
| def build_pitcher_rolling_form_row( | |
| statcast_df: pd.DataFrame, | |
| pitcher_name: str | None = None, | |
| pitcher_id: int | None = None, | |
| reference_date: Any = None, | |
| ) -> dict[str, Any]: | |
| """ | |
| Compute game-based 5g / 10g rolling form metrics for a pitcher. | |
| Follows the same fuzzy-name-match pattern as pitcher_adjustment.py. | |
| pitcher_rolling_confidence reflects match quality × sample availability. | |
| """ | |
| if statcast_df is None or statcast_df.empty: | |
| return dict(_EMPTY_PITCHER_ROLL) | |
| ref = _parse_reference_date(reference_date) | |
| if ref is None: | |
| return dict(_EMPTY_PITCHER_ROLL) | |
| pitcher_name = str(pitcher_name or "").strip() | |
| df = pd.DataFrame() | |
| match_quality = "none" | |
| # Attempt 1: pitcher ID column (present in some CSVs) | |
| if pitcher_id is not None and "pitcher" in statcast_df.columns: | |
| try: | |
| numeric_ids = pd.to_numeric(statcast_df["pitcher"], errors="coerce") | |
| df = statcast_df[numeric_ids == int(pitcher_id)].copy() | |
| if not df.empty: | |
| match_quality = "id" | |
| except Exception: | |
| df = pd.DataFrame() | |
| # Attempt 2: exact / variant name match on player_name | |
| if df.empty and pitcher_name and "player_name" in statcast_df.columns: | |
| variants = _name_variants(pitcher_name) | |
| normalized_series = statcast_df["player_name"].astype(str).map(_normalize_name) | |
| mask = normalized_series.isin(variants) | |
| df = statcast_df[mask].copy() | |
| if not df.empty: | |
| match_quality = "exact" | |
| # Attempt 3: loose contains-style match | |
| if df.empty and pitcher_name and "player_name" in statcast_df.columns: | |
| parts = _normalize_name(pitcher_name).split() | |
| if len(parts) >= 2: | |
| first, last = parts[0], parts[-1] | |
| normalized_series = statcast_df["player_name"].astype(str).map(_normalize_name) | |
| loose_mask = normalized_series.apply( | |
| lambda n: isinstance(n, str) and first in n and last in n | |
| ) | |
| df = statcast_df[loose_mask].copy() | |
| if not df.empty: | |
| match_quality = "loose" | |
| if df.empty: | |
| return dict(_EMPTY_PITCHER_ROLL) | |
| df5 = _game_window_df(df, ref, 5) | |
| df10 = _game_window_df(df, ref, 10) | |
| n5 = _n_games(df5) | |
| n10 = _n_games(df10) | |
| # pitcher_rolling_confidence: match quality × sample scale | |
| sample_scale_5g = ( | |
| 0.0 if n5 < 2 | |
| else 0.4 if n5 <= 3 | |
| else 0.7 if n5 == 4 | |
| else 1.0 | |
| ) | |
| match_scale = { | |
| "id": 1.0, | |
| "exact": 1.0, | |
| "loose": 0.4, | |
| "none": 0.0, | |
| }.get(match_quality, 0.0) | |
| confidence = round(match_scale * sample_scale_5g, 3) | |
| def _hh_rate(df: pd.DataFrame) -> float | None: | |
| ls = pd.to_numeric(df.get("launch_speed", pd.Series(dtype=float)), errors="coerce").dropna() | |
| if len(ls) < 5: | |
| return None | |
| return float((ls >= 95).mean()) | |
| def _hr_rate_allowed(df: pd.DataFrame) -> float | None: | |
| if "events" not in df.columns or len(df) < 5: | |
| return None | |
| events = df["events"].dropna().astype(str) | |
| if events.empty: | |
| return None | |
| return float((events == "home_run").mean()) | |
| ls5 = df5.get("launch_speed", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float) | |
| la5 = df5.get("launch_angle", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float) | |
| ls10 = df10.get("launch_speed", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float) | |
| la10 = df10.get("launch_angle", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float) | |
| rs5 = df5.get("release_speed", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float) | |
| rs10 = df10.get("release_speed", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float) | |
| spin5 = df5.get("release_spin_rate", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float) | |
| return { | |
| "pitcher_avg_release_speed_5g": _safe_mean(rs5), | |
| "pitcher_avg_release_speed_10g": _safe_mean(rs10), | |
| "pitcher_avg_release_spin_rate_5g": _safe_mean(spin5), | |
| "pitcher_ev_allowed_5g": _safe_mean(ls5), | |
| "pitcher_ev_allowed_10g": _safe_mean(ls10), | |
| "pitcher_hard_hit_rate_allowed_5g": _hh_rate(df5), | |
| "pitcher_hard_hit_rate_allowed_10g": _hh_rate(df10), | |
| "pitcher_barrel_rate_allowed_5g": _barrel_rate(ls5, la5), | |
| "pitcher_barrel_rate_allowed_10g": _barrel_rate(ls10, la10), | |
| "pitcher_avg_launch_angle_allowed_5g": _safe_mean(la5), | |
| "pitcher_fb_rate_allowed_5g": _safe_rate_from_la(la5, 25.0), | |
| "pitcher_ld_rate_allowed_5g": _safe_rate_from_la(la5, 10.0, 25.0), | |
| "pitcher_gb_rate_allowed_5g": _safe_rate_from_la(la5, -90.0, 10.0), | |
| "pitcher_hr_allowed_rate_5g": _hr_rate_allowed(df5), | |
| "pitcher_hr_allowed_rate_10g": _hr_rate_allowed(df10), | |
| "pitcher_games_in_window_5g": n5, | |
| "pitcher_games_in_window_10g": n10, | |
| "pitcher_recent_form_available": 1 if n5 >= 4 else 0, | |
| "pitcher_rolling_confidence": confidence, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Helpers for adjustment function | |
| # --------------------------------------------------------------------------- | |
| def _safe_delta(rolling_val: Any, baseline_val: Any) -> float | None: | |
| """rolling - baseline; returns None if either is None.""" | |
| if rolling_val is None or baseline_val is None: | |
| return None | |
| try: | |
| return float(rolling_val) - float(baseline_val) | |
| except (TypeError, ValueError): | |
| return None | |
| def _clamp(value: float, lo: float, hi: float) -> float: | |
| return max(lo, min(hi, value)) | |
| def _10g_confirmation_scale(delta_5g: float | None, delta_10g: float | None, threshold: float) -> float: | |
| """ | |
| 1.0 if 10g confirms 5g direction or is None (neutral). | |
| 0.5 if 10g conflicts with 5g direction. | |
| """ | |
| if delta_5g is None or delta_10g is None: | |
| return 1.0 | |
| aligned = (delta_5g > threshold) == (delta_10g > threshold) | |
| return 1.0 if aligned else 0.5 | |
| def _sample_scale(n_games: int) -> float: | |
| if n_games < 4: return 0.0 # raised gate to match recent_form_available (n >= 4) | |
| if n_games == 4: return 0.7 | |
| return 1.0 | |
| # --------------------------------------------------------------------------- | |
| # Public API — rolling adjustment | |
| # --------------------------------------------------------------------------- | |
| def compute_upcoming_rolling_adjustment( | |
| batter_roll: dict[str, Any], | |
| pitcher_roll: dict[str, Any], | |
| batter_features: dict[str, Any], | |
| pitcher_row: dict[str, Any], | |
| ) -> dict[str, Any]: | |
| """ | |
| Compute bounded additive probability adjustments from rolling form. | |
| Deltas are computed against the stable batter_features / pitcher_row | |
| baselines (not recomputed from the narrow rolling window). | |
| Returns a dict with rolling_hit_adjustment, rolling_hr_adjustment, | |
| rolling_tb2p_adjustment, scores, tags (pipe-delimited string), and | |
| pitcher_rolling_confidence. | |
| """ | |
| batter_n5 = int(batter_roll.get("batter_games_in_window_5g") or 0) | |
| pitcher_n5 = int(pitcher_roll.get("pitcher_games_in_window_5g") or 0) | |
| pitcher_confidence = float(pitcher_roll.get("pitcher_rolling_confidence") or 0.0) | |
| batter_scale = _sample_scale(batter_n5) | |
| pitcher_n5_scale = _sample_scale(pitcher_n5) | |
| pitcher_scale = pitcher_confidence * pitcher_n5_scale | |
| # ------------------------------------------------------------------ | |
| # Compute deltas vs stable engine baselines | |
| # ------------------------------------------------------------------ | |
| # Batter deltas | |
| ev90_delta_5g = _safe_delta(batter_roll.get("batter_ev90_5g"), batter_features.get("ev90")) | |
| ev90_delta_10g = _safe_delta(batter_roll.get("batter_ev90_10g"), batter_features.get("ev90")) | |
| barrel_delta_5g = _safe_delta(batter_roll.get("batter_barrel_rate_5g"), batter_features.get("barrel_rate")) | |
| barrel_delta_10g = _safe_delta(batter_roll.get("batter_barrel_rate_10g"), batter_features.get("barrel_rate")) | |
| hh_delta_5g = _safe_delta(batter_roll.get("batter_hard_hit_rate_5g"), batter_features.get("hard_hit_rate")) | |
| la_delta_5g = _safe_delta(batter_roll.get("batter_avg_launch_angle_5g"), batter_features.get("avg_launch_angle")) | |
| air_ball_5g = batter_roll.get("batter_air_ball_rate_5g") | |
| air_ball_baseline = batter_features.get("air_ball_rate") | |
| air_ball_delta_5g = _safe_delta(air_ball_5g, air_ball_baseline) | |
| # Pitcher deltas vs stable pitcher_row baselines | |
| velo_delta_5g = _safe_delta(pitcher_roll.get("pitcher_avg_release_speed_5g"), pitcher_row.get("avg_release_speed")) | |
| ev_allowed_delta_5g = _safe_delta(pitcher_roll.get("pitcher_ev_allowed_5g"), pitcher_row.get("ev_allowed")) | |
| ev_allowed_delta_10g = _safe_delta(pitcher_roll.get("pitcher_ev_allowed_10g"), pitcher_row.get("ev_allowed")) | |
| barrel_allowed_delta_5g = _safe_delta(pitcher_roll.get("pitcher_barrel_rate_allowed_5g"), pitcher_row.get("barrel_rate_allowed")) | |
| barrel_allowed_delta_10g = _safe_delta(pitcher_roll.get("pitcher_barrel_rate_allowed_10g"), pitcher_row.get("barrel_rate_allowed")) | |
| hh_allowed_delta_5g = _safe_delta(pitcher_roll.get("pitcher_hard_hit_rate_allowed_5g"), pitcher_row.get("hard_hit_rate_allowed")) | |
| # ------------------------------------------------------------------ | |
| # Batter form score | |
| # ------------------------------------------------------------------ | |
| batter_score = 0.0 | |
| active_batter_tags: list[str] = [] | |
| if ev90_delta_5g is not None: | |
| conf_10g = _10g_confirmation_scale(ev90_delta_5g, ev90_delta_10g, 2.0) | |
| if ev90_delta_5g > 2.0: | |
| batter_score += 0.25 * conf_10g | |
| active_batter_tags.append("batter_ev90_surge") | |
| elif ev90_delta_5g < -2.0: | |
| batter_score -= 0.25 * conf_10g | |
| active_batter_tags.append("batter_ev90_decline") | |
| if barrel_delta_5g is not None: | |
| conf_10g = _10g_confirmation_scale(barrel_delta_5g, barrel_delta_10g, 0.03) | |
| if barrel_delta_5g > 0.03: | |
| batter_score += 0.40 * conf_10g | |
| active_batter_tags.append("batter_barrel_spike") | |
| elif barrel_delta_5g < -0.03: | |
| batter_score -= 0.40 * conf_10g | |
| active_batter_tags.append("batter_barrel_drop") | |
| if hh_delta_5g is not None and hh_delta_5g > 0.05: | |
| batter_score += 0.20 | |
| active_batter_tags.append("batter_hard_hit_rising") | |
| if ( | |
| la_delta_5g is not None | |
| and batter_roll.get("batter_avg_launch_angle_5g") is not None | |
| and 20.0 < float(batter_roll["batter_avg_launch_angle_5g"]) < 30.0 | |
| and la_delta_5g > 3.0 | |
| ): | |
| batter_score += 0.20 | |
| active_batter_tags.append("batter_la_optimizing") | |
| if ( | |
| air_ball_5g is not None | |
| and air_ball_delta_5g is not None | |
| and float(air_ball_5g) > 0.45 | |
| and air_ball_delta_5g > 0.05 | |
| ): | |
| batter_score += 0.15 | |
| active_batter_tags.append("batter_air_ball_spike") | |
| batter_score = _clamp(batter_score * batter_scale, -1.0, 1.0) | |
| # ------------------------------------------------------------------ | |
| # Pitcher form score | |
| # ------------------------------------------------------------------ | |
| pitcher_score = 0.0 | |
| active_pitcher_tags: list[str] = [] | |
| if velo_delta_5g is not None: | |
| if velo_delta_5g < -3.0: | |
| pitcher_score += 0.50 # -1.5 and -3.0 contributions combined | |
| active_pitcher_tags.append("pitcher_velo_decline_hard") | |
| elif velo_delta_5g < -1.5: | |
| pitcher_score += 0.30 | |
| active_pitcher_tags.append("pitcher_velo_decline") | |
| if ev_allowed_delta_5g is not None: | |
| conf_10g = _10g_confirmation_scale(ev_allowed_delta_5g, ev_allowed_delta_10g, 2.0) | |
| if ev_allowed_delta_5g > 2.0: | |
| pitcher_score += 0.30 * conf_10g | |
| active_pitcher_tags.append("pitcher_ev_allowed_spiking") | |
| if barrel_allowed_delta_5g is not None: | |
| conf_10g = _10g_confirmation_scale(barrel_allowed_delta_5g, barrel_allowed_delta_10g, 0.03) | |
| if barrel_allowed_delta_5g > 0.03: | |
| pitcher_score += 0.40 * conf_10g | |
| active_pitcher_tags.append("pitcher_barrel_allowed_spiking") | |
| if hh_allowed_delta_5g is not None and hh_allowed_delta_5g > 0.05: | |
| pitcher_score += 0.20 | |
| active_pitcher_tags.append("pitcher_hard_hit_allowed_rising") | |
| # Pitcher sharp: velo up + EV allowed down + barrel allowed down | |
| pitcher_sharp = ( | |
| velo_delta_5g is not None and velo_delta_5g > 1.5 | |
| and ev_allowed_delta_5g is not None and ev_allowed_delta_5g < -2.0 | |
| and barrel_allowed_delta_5g is not None and barrel_allowed_delta_5g < -0.03 | |
| ) | |
| if pitcher_sharp: | |
| pitcher_score -= 0.35 | |
| active_pitcher_tags.append("pitcher_sharp_recently") | |
| pitcher_score = _clamp(pitcher_score * pitcher_scale, -1.0, 1.0) | |
| # ------------------------------------------------------------------ | |
| # Combined score and adjustments | |
| # ------------------------------------------------------------------ | |
| combined = _clamp(batter_score + pitcher_score, -1.0, 1.0) | |
| rolling_hr_adjustment = _clamp(combined * 0.012, -0.012, 0.012) | |
| rolling_hit_adjustment = _clamp(combined * 0.010, -0.010, 0.010) | |
| rolling_tb2p_adjustment = _clamp(combined * 0.011, -0.011, 0.011) | |
| adjustment_applied = abs(combined) > 0.05 | |
| # Compact pipe-delimited reason tags (up to 3 most active) | |
| all_tags = (active_batter_tags + active_pitcher_tags)[:3] | |
| reason_tags_str = "|".join(all_tags) | |
| return { | |
| "rolling_hit_adjustment": round(rolling_hit_adjustment, 5), | |
| "rolling_hr_adjustment": round(rolling_hr_adjustment, 5), | |
| "rolling_tb2p_adjustment": round(rolling_tb2p_adjustment, 5), | |
| "rolling_batter_form_score": round(batter_score, 4), | |
| "rolling_pitcher_form_score": round(pitcher_score, 4), | |
| "rolling_combined_form_score": round(combined, 4), | |
| "rolling_adjustment_applied": adjustment_applied, | |
| "rolling_adjustment_reason_tags": reason_tags_str, | |
| "pitcher_rolling_confidence": pitcher_confidence, | |
| } | |