from __future__ import annotations from typing import Any def _safe_float(value: Any, default: float | None = None) -> float | None: try: if value is None: return default text = str(value).strip().lower() if text in {"", "nan", "none"}: return default return float(value) except Exception: return default def _clamp(value: float, lo: float, hi: float) -> float: return max(lo, min(hi, value)) def _reliability(sample_size: Any, k: float = 120.0) -> float: sample = max(0.0, float(_safe_float(sample_size, 0.0) or 0.0)) return _clamp(sample / (sample + max(1.0, k)), 0.0, 1.0) def compute_zone_matchup_adjustment( batter_zone_row: dict[str, Any], pitcher_zone_row: dict[str, Any], *, pitch_zone_weights: dict[str, float] | None = None, handedness_context: dict[str, Any] | None = None, ) -> dict[str, float]: adjustment = { "hr_zone_boost": 0.0, "hit_zone_boost": 0.0, "tb2p_zone_boost": 0.0, "damage_zone_boost": 0.0, "whiff_zone_risk": 0.0, } pitch_families = ["fastball", "breaking", "offspeed"] zones = ["heart", "shadow", "chase", "waste"] total_weight = 0.0 weighted_samples = 0.0 context_weight_used = False handedness_context = handedness_context or {} handedness_bonus = 1.0 if str(handedness_context.get("batter_stand") or "").strip().upper() == str(handedness_context.get("pitcher_hand") or "").strip().upper(): handedness_bonus = 0.98 elif handedness_context: handedness_bonus = 1.02 for family in pitch_families: for zone in zones: base_attack_rate = _safe_float(pitcher_zone_row.get(f"{family}_{zone}_rate")) context_attack_rate = _safe_float((pitch_zone_weights or {}).get(f"{family}_{zone}")) sample_size = _safe_float(pitcher_zone_row.get(f"sample_size_{family}_{zone}"), 0.0) or 0.0 sample_reliability = _reliability(sample_size, k=80.0) if context_attack_rate is not None: context_weight_used = True if base_attack_rate is None: attack_rate = context_attack_rate else: attack_rate = (context_attack_rate * 0.72) + (base_attack_rate * 0.28 * sample_reliability) else: attack_rate = base_attack_rate if attack_rate is None or attack_rate <= 0: continue attack_rate *= handedness_bonus hr_prob = _safe_float(batter_zone_row.get(f"hr_prob_{family}_{zone}")) hit_prob = _safe_float(batter_zone_row.get(f"hit_prob_{family}_{zone}")) tb2p_prob = _safe_float(batter_zone_row.get(f"tb2p_prob_{family}_{zone}")) whiff_prob = _safe_float(batter_zone_row.get(f"whiff_prob_{family}_{zone}")) damage_prob = _safe_float(batter_zone_row.get(f"damage_prob_{family}_{zone}")) batter_sample = _safe_float(batter_zone_row.get(f"sample_size_{family}_{zone}"), 0.0) or 0.0 batter_reliability = _reliability(batter_sample, k=70.0) combined_reliability = sample_reliability * batter_reliability if hr_prob is not None: adjustment["hr_zone_boost"] += attack_rate * hr_prob * combined_reliability if hit_prob is not None: adjustment["hit_zone_boost"] += attack_rate * hit_prob * combined_reliability if tb2p_prob is not None: adjustment["tb2p_zone_boost"] += attack_rate * tb2p_prob * combined_reliability if damage_prob is not None: adjustment["damage_zone_boost"] += attack_rate * damage_prob * combined_reliability if whiff_prob is not None: adjustment["whiff_zone_risk"] += attack_rate * whiff_prob * combined_reliability total_weight += attack_rate weighted_samples += sample_size if total_weight > 0: for k in adjustment: adjustment[k] = adjustment[k] / total_weight adjustment["sample_size"] = weighted_samples if weighted_samples > 0 else total_weight adjustment["reliability"] = round(_reliability(weighted_samples, k=320.0), 4) adjustment["context_weight_used"] = context_weight_used return adjustment