diff --git "a/app.py" "b/app.py" new file mode 100644--- /dev/null +++ "b/app.py" @@ -0,0 +1,2705 @@ +import base64 +import json +import random +from typing import Any + +import numpy as np +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go +import streamlit as st + + +APP_TITLE = "🏏 RunRate Lab" +APP_SUBTITLE = "Play. Analyze. Improve." +SHORT_DESCRIPTION = "Learn cricket analytics by playing a T20 chase and studying every shot's expected runs and risk." + +TOTAL_BALLS = 120 +TOTAL_WICKETS = 10 +BALLS_PER_OVER = 6 +DEFAULT_SIMULATION_SAMPLES = 7000 +TIMING_ASSIST_MULTIPLIER = 2.35 +POWER_TOLERANCE = 60 + +DECISION_THRESHOLDS = { + "excellent_xruns": 4.0, + "excellent_risk": 0.18, + "good_xruns": 2.5, + "good_risk": 0.25, + "risky_xruns": 3.5, + "low_value_xruns": 2.0, + "poor_xruns": 2.5, + "luck_run_gap": 2.0, + "low_risk_wicket": 0.18, + "low_xruns_boundary": 2.5, +} + +PITCH_MODEL = pd.DataFrame( + [ + {"Delivery": "Half Volley", "Speed": 1260, "Window": 130, "Risk": 0.06, "Ideal Power": 78}, + {"Delivery": "Length Ball", "Speed": 1120, "Window": 112, "Risk": 0.10, "Ideal Power": 66}, + {"Delivery": "Cutter", "Speed": 1040, "Window": 92, "Risk": 0.15, "Ideal Power": 58}, + {"Delivery": "Bouncer", "Speed": 900, "Window": 82, "Risk": 0.20, "Ideal Power": 72}, + {"Delivery": "Yorker", "Speed": 820, "Window": 70, "Risk": 0.25, "Ideal Power": 47}, + ] +) + +SHOT_MODEL = pd.DataFrame( + [ + { + "Shot": "Defend", + "Contact Bonus": 0.14, + "Risk Multiplier": 0.58, + "Upside": 0.54, + "Power Bias": -18, + "Best Deliveries": "Yorker,Cutter", + "Risky Deliveries": "Bouncer", + "Keyboard": "1", + }, + { + "Shot": "Drive", + "Contact Bonus": 0.05, + "Risk Multiplier": 1.00, + "Upside": 1.00, + "Power Bias": 0, + "Best Deliveries": "Half Volley,Length Ball", + "Risky Deliveries": "Bouncer", + "Keyboard": "2", + }, + { + "Shot": "Cut / Pull", + "Contact Bonus": -0.02, + "Risk Multiplier": 1.12, + "Upside": 1.10, + "Power Bias": 8, + "Best Deliveries": "Bouncer,Cutter", + "Risky Deliveries": "Yorker", + "Keyboard": "3", + }, + { + "Shot": "Loft", + "Contact Bonus": -0.10, + "Risk Multiplier": 1.58, + "Upside": 1.38, + "Power Bias": 16, + "Best Deliveries": "Half Volley,Length Ball", + "Risky Deliveries": "Yorker,Cutter", + "Keyboard": "4", + }, + ] +) + +AIM_MODEL = pd.DataFrame( + [ + {"Aim": "Leg Side", "Contact Bonus": -0.02, "Risk Multiplier": 1.08, "Run Multiplier": 1.05, "Angle": -36}, + {"Aim": "Straight", "Contact Bonus": 0.04, "Risk Multiplier": 0.95, "Run Multiplier": 1.00, "Angle": 0}, + {"Aim": "Off Side", "Contact Bonus": -0.01, "Risk Multiplier": 1.05, "Run Multiplier": 1.04, "Angle": 36}, + ] +) + + +st.set_page_config(page_title=APP_TITLE, layout="wide", initial_sidebar_state="collapsed") + + +def initialize_session_state() -> None: + """Create stable values for the Streamlit shell around the browser game.""" + if "target_score" not in st.session_state: + st.session_state.target_score = random.randint(150, 220) + + +def get_pitch(delivery: str) -> pd.Series: + """Return one delivery row from the pitch model.""" + matches = PITCH_MODEL[PITCH_MODEL["Delivery"] == delivery] + if matches.empty: + raise ValueError(f"Unknown delivery type: {delivery}") + return matches.iloc[0] + + +def get_shot(shot_intent: str) -> pd.Series: + """Return one shot row from the shot model.""" + matches = SHOT_MODEL[SHOT_MODEL["Shot"] == shot_intent] + if matches.empty: + raise ValueError(f"Unknown shot intent: {shot_intent}") + return matches.iloc[0] + + +def get_aim(aim_lane: str) -> pd.Series: + """Return one aim row from the aim model.""" + matches = AIM_MODEL[AIM_MODEL["Aim"] == aim_lane] + if matches.empty: + raise ValueError(f"Unknown aim lane: {aim_lane}") + return matches.iloc[0] + + +def csv_contains(csv_value: str, value: str) -> bool: + """Check whether a comma-separated model field contains a value.""" + return value in {item.strip() for item in str(csv_value).split(",") if item.strip()} + + +def calculate_contact( + timing_error_ms: float, + power: float, + pitch: pd.Series, + shot_intent: str = "Drive", + aim_lane: str = "Straight", +) -> float: + """Calculate deterministic contact quality before random variation.""" + shot = get_shot(shot_intent) + aim = get_aim(aim_lane) + assisted_window = float(pitch["Window"]) * TIMING_ASSIST_MULTIPLIER + timing_score = np.clip(1 - abs(timing_error_ms) / assisted_window, 0, 1) + ideal_power = float(pitch["Ideal Power"]) + float(shot["Power Bias"]) + power_score = np.clip(1 - abs(power - ideal_power) / POWER_TOLERANCE, 0, 1) + matchup = 0.0 + if csv_contains(str(shot["Best Deliveries"]), str(pitch["Delivery"])): + matchup += 0.08 + if csv_contains(str(shot["Risky Deliveries"]), str(pitch["Delivery"])): + matchup -= 0.10 + contact = timing_score * 0.58 + power_score * 0.34 + float(shot["Contact Bonus"]) + float(aim["Contact Bonus"]) + matchup + return float(np.clip(contact, 0, 1)) + + +def calculate_wicket_risk( + contact: float | np.ndarray, + power: float, + timing_error_ms: float, + required_rate: float, + pitch: pd.Series, + shot_intent: str = "Drive", + aim_lane: str = "Straight", +) -> float | np.ndarray: + """Estimate wicket probability from delivery, contact, power, timing, and chase pressure.""" + shot = get_shot(shot_intent) + aim = get_aim(aim_lane) + pressure_risk = 0.05 if required_rate > 10 else 0 + overhit_risk = 0.07 if power > 92 else 0 + mistimed_risk = 0.10 if abs(timing_error_ms) > float(pitch["Window"]) * 2.1 else 0 + matchup_risk = 0.05 if csv_contains(str(shot["Risky Deliveries"]), str(pitch["Delivery"])) else 0 + risk = ( + float(pitch["Risk"]) + * float(shot["Risk Multiplier"]) + * float(aim["Risk Multiplier"]) + + pressure_risk + + overhit_risk + + mistimed_risk + + matchup_risk + + np.where(np.asarray(contact) < 0.22, 0.18, 0) + ) + return np.clip(risk, 0.02, 0.62) + + +def calculate_expected_runs( + contact: float | np.ndarray, + power: float, + wicket_risk: float | np.ndarray, + shot_intent: str = "Drive", + aim_lane: str = "Straight", +) -> float | np.ndarray: + """Estimate expected runs for one ball before the stochastic outcome is sampled.""" + shot = get_shot(shot_intent) + aim = get_aim(aim_lane) + value = (np.asarray(contact) * 4.6 + (power / 100) * 1.25) * float(shot["Upside"]) * float(aim["Run Multiplier"]) + return np.clip(value - np.asarray(wicket_risk) * 2.15, 0, 6) + + +def classify_decision_quality(expected_runs: float, wicket_risk: float) -> str: + """Label the process quality of a batting decision.""" + if expected_runs >= DECISION_THRESHOLDS["excellent_xruns"] and wicket_risk <= DECISION_THRESHOLDS["excellent_risk"]: + return "Excellent Process" + if expected_runs >= DECISION_THRESHOLDS["good_xruns"] and wicket_risk <= DECISION_THRESHOLDS["good_risk"]: + return "Good Process" + if expected_runs >= DECISION_THRESHOLDS["risky_xruns"] and wicket_risk > DECISION_THRESHOLDS["good_risk"]: + return "Risky But Rational" + if expected_runs < DECISION_THRESHOLDS["low_value_xruns"] and wicket_risk <= DECISION_THRESHOLDS["good_risk"]: + return "Low-Value Shot" + if expected_runs < DECISION_THRESHOLDS["poor_xruns"] and wicket_risk > DECISION_THRESHOLDS["good_risk"]: + return "Poor Risk Tradeoff" + return "Balanced Tradeoff" + + +def classify_luck(actual_runs: int, expected_runs: float, wicket: bool, wicket_risk: float) -> str: + """Separate outcome luck from decision process.""" + if wicket and wicket_risk < DECISION_THRESHOLDS["low_risk_wicket"]: + return "Unlucky Result" + if (not wicket) and actual_runs - expected_runs >= DECISION_THRESHOLDS["luck_run_gap"]: + return "Lucky Result" + if (not wicket) and actual_runs >= 4 and expected_runs < DECISION_THRESHOLDS["low_xruns_boundary"]: + return "Lucky Result" + if wicket and wicket_risk >= DECISION_THRESHOLDS["good_risk"]: + return "Expected Result" + if (not wicket) and expected_runs - actual_runs >= DECISION_THRESHOLDS["luck_run_gap"]: + return "Unlucky Result" + return "Expected Result" + + +def build_lesson(decision_quality: str, luck_label: str, actual_runs: int, expected_runs: float, wicket_risk: float, wicket: bool) -> str: + """Create one concise coaching sentence.""" + result = "a wicket" if wicket else f"{actual_runs} run{'s' if actual_runs != 1 else ''}" + risk = f"{wicket_risk * 100:.0f}%" + if luck_label == "Lucky Result": + return f"{decision_quality}, lucky result. The shot was worth {expected_runs:.2f} expected runs with {risk} wicket risk, but the roll produced {result}." + if luck_label == "Unlucky Result": + return f"{decision_quality}, unlucky result. The process created {expected_runs:.2f} expected runs with {risk} wicket risk, but variance produced {result}." + return f"{decision_quality}. The result, {result}, broadly matched {expected_runs:.2f} expected runs and {risk} wicket risk." + + +def simulate_outcome_distribution( + delivery: str, + timing_error_ms: int, + power: int, + required_rate: float, + samples: int = DEFAULT_SIMULATION_SAMPLES, + shot_intent: str = "Drive", + aim_lane: str = "Straight", +) -> tuple[pd.DataFrame, dict[str, float]]: + """Monte Carlo estimate of the same outcome logic used by the browser game.""" + pitch = get_pitch(delivery) + shot = get_shot(shot_intent) + rng = np.random.default_rng(23) + base_contact = calculate_contact(timing_error_ms, power, pitch, shot_intent, aim_lane) + contact = np.clip(base_contact + rng.uniform(-0.07, 0.07, samples), 0, 1) + wicket_chance = calculate_wicket_risk(contact, power, timing_error_ms, required_rate, pitch, shot_intent, aim_lane) + expected_runs = calculate_expected_runs(contact, power, wicket_chance, shot_intent, aim_lane) + + wicket_roll = rng.random(samples) + outcome_roll = rng.random(samples) + wicket_modifier = np.clip(1.08 - contact * 0.55, 0.34, 1.05) + wickets = wicket_roll < wicket_chance * wicket_modifier + runs = np.zeros(samples, dtype=int) + + if shot_intent == "Defend": + mask = (~wickets) & (contact > 0.76) + runs[mask] = np.where(outcome_roll[mask] < 0.62, 1, np.where(power > 62, 2, 0)) + mask = (~wickets) & (runs == 0) & (contact > 0.52) + runs[mask] = np.where(outcome_roll[mask] < 0.55, 1, 0) + elif shot_intent == "Loft": + mask = (~wickets) & (contact > 0.82) & (power > 70) + runs[mask] = np.where(outcome_roll[mask] < 0.70, 6, 4) + mask = (~wickets) & (runs == 0) & (contact > 0.64) + runs[mask] = np.where(outcome_roll[mask] < 0.52, 4, np.where(power > 82, 6, 2)) + mask = (~wickets) & (runs == 0) & (contact > 0.35) + runs[mask] = np.where(outcome_roll[mask] < 0.62, 1, 2) + elif shot_intent == "Cut / Pull": + mask = (~wickets) & (contact > 0.78) & (power > 64) + runs[mask] = np.where(outcome_roll[mask] < 0.56, 4, 6) + mask = (~wickets) & (runs == 0) & (contact > 0.58) + runs[mask] = np.where(outcome_roll[mask] < 0.58, 2, 4) + mask = (~wickets) & (runs == 0) & (contact > 0.32) + runs[mask] = np.where(outcome_roll[mask] < 0.66, 1, 2) + else: + mask = (~wickets) & (contact > 0.88) & (power > 68) + runs[mask] = np.where(outcome_roll[mask] < 0.72, 6, 4) + mask = (~wickets) & (runs == 0) & (contact > 0.74) + runs[mask] = np.where(power > 70, np.where(outcome_roll[mask] < 0.58, 4, 6), 3) + mask = (~wickets) & (runs == 0) & (contact > 0.52) + runs[mask] = np.where(power > 66, 4, np.where(outcome_roll[mask] < 0.55, 2, 3)) + mask = (~wickets) & (runs == 0) & (contact > 0.30) + runs[mask] = np.where(outcome_roll[mask] < 0.70, 1, 2) + + if float(shot["Upside"]) > 1.2: + runs = np.where((~wickets) & (runs == 4) & (outcome_roll > 0.86) & (power > 86), 6, runs) + + outcomes = np.where(wickets, "W", runs.astype(str)) + order = ["0", "1", "2", "3", "4", "6", "W"] + probability_df = ( + pd.Series(outcomes) + .value_counts(normalize=True) + .reindex(order, fill_value=0) + .rename_axis("Outcome") + .reset_index(name="Probability") + ) + probability_df["Probability"] = probability_df["Probability"] * 100 + + wicket_risk_mean = float(np.mean(wicket_chance)) + expected_runs_mean = float(np.mean(expected_runs)) + metrics = { + "contact": float(np.mean(contact) * 100), + "expected_runs": expected_runs_mean, + "wicket_risk": wicket_risk_mean * 100, + "shot_intent": shot_intent, + "aim_lane": aim_lane, + "timing_score": float( + np.clip(1 - abs(timing_error_ms) / (float(pitch["Window"]) * TIMING_ASSIST_MULTIPLIER), 0, 1) * 100 + ), + "power_score": float( + np.clip(1 - abs(power - (float(pitch["Ideal Power"]) + float(shot["Power Bias"]))) / POWER_TOLERANCE, 0, 1) + * 100 + ), + "decision_quality": classify_decision_quality(expected_runs_mean, wicket_risk_mean), + } + return probability_df, metrics + + +def simulate_sample_table( + delivery: str, + timing_error_ms: int, + power: int, + required_rate: float, + samples: int = 10, + shot_intent: str = "Drive", + aim_lane: str = "Straight", +) -> pd.DataFrame: + """Show a small set of possible outcomes from the same shot setup.""" + pitch = get_pitch(delivery) + rng = np.random.default_rng(41) + rows: list[dict[str, Any]] = [] + for trial in range(1, samples + 1): + contact = float( + np.clip(calculate_contact(timing_error_ms, power, pitch, shot_intent, aim_lane) + rng.uniform(-0.07, 0.07), 0, 1) + ) + wicket_risk = float(calculate_wicket_risk(contact, power, timing_error_ms, required_rate, pitch, shot_intent, aim_lane)) + expected_runs = float(calculate_expected_runs(contact, power, wicket_risk, shot_intent, aim_lane)) + wicket_roll = float(rng.random()) + outcome_roll = float(rng.random()) + wicket = wicket_roll < wicket_risk * float(np.clip(1.08 - contact * 0.55, 0.34, 1.05)) + runs = 0 + if not wicket: + if shot_intent == "Defend": + if contact > 0.76: + runs = 1 if outcome_roll < 0.62 else 2 if power > 62 else 0 + elif contact > 0.52: + runs = 1 if outcome_roll < 0.55 else 0 + elif shot_intent == "Loft": + if contact > 0.82 and power > 70: + runs = 6 if outcome_roll < 0.70 else 4 + elif contact > 0.64: + runs = 4 if outcome_roll < 0.52 else 6 if power > 82 else 2 + elif contact > 0.35: + runs = 1 if outcome_roll < 0.62 else 2 + elif shot_intent == "Cut / Pull": + if contact > 0.78 and power > 64: + runs = 4 if outcome_roll < 0.56 else 6 + elif contact > 0.58: + runs = 2 if outcome_roll < 0.58 else 4 + elif contact > 0.32: + runs = 1 if outcome_roll < 0.66 else 2 + else: + if contact > 0.88 and power > 68: + runs = 6 if outcome_roll < 0.72 else 4 + elif contact > 0.74: + runs = 4 if power > 70 and outcome_roll < 0.58 else 6 if power > 70 else 3 + elif contact > 0.52: + runs = 4 if power > 66 else 2 if outcome_roll < 0.55 else 3 + elif contact > 0.30: + runs = 1 if outcome_roll < 0.70 else 2 + rows.append( + { + "Trial": trial, + "Shot": shot_intent, + "Aim": aim_lane, + "Outcome": "W" if wicket else str(runs), + "Contact": f"{contact * 100:.0f}%", + "xRuns": round(expected_runs, 2), + "Wicket Risk": f"{wicket_risk * 100:.0f}%", + "Decision": classify_decision_quality(expected_runs, wicket_risk), + "Luck": classify_luck(runs, expected_runs, wicket, wicket_risk), + } + ) + return pd.DataFrame(rows) + + +def build_learning_summary(ball_log: list[dict[str, Any]]) -> dict[str, float]: + """Summarize a list of ball records for learning modules.""" + if not ball_log: + return {"balls": 0, "avg_actual_runs": 0.0, "avg_xruns": 0.0, "avg_wicket_risk": 0.0} + frame = pd.DataFrame(ball_log) + + def safe_mean(column: str) -> float: + if column not in frame: + return 0.0 + mean_value = pd.to_numeric(frame[column], errors="coerce").mean() + if pd.isna(mean_value): + return 0.0 + return float(mean_value) + + return { + "balls": float(len(frame)), + "avg_actual_runs": safe_mean("actual_runs"), + "avg_xruns": safe_mean("expected_runs"), + "avg_wicket_risk": safe_mean("wicket_risk"), + } + + +def build_pitch_analytics() -> pd.DataFrame: + """Return model hints used by the sidebar chart.""" + model_df = PITCH_MODEL.copy() + model_df["Timing Window"] = (model_df["Window"] * TIMING_ASSIST_MULTIPLIER / 1.48).round(0).astype(int) + model_df["Wicket Risk"] = (model_df["Risk"] * 100).round(0).astype(int) + model_df["Boundary Upside"] = np.clip( + 105 - model_df["Risk"] * 170 - np.abs(model_df["Ideal Power"] - 76) * 1.2, + 40, + 96, + ).round(0).astype(int) + return model_df[["Delivery", "Timing Window", "Wicket Risk", "Boundary Upside"]] + + +def build_interpretation( + delivery: str, + shot_intent: str, + aim_lane: str, + timing_error_ms: int, + power: int, + required_rate: float, + risk_budget: int, + metrics: dict[str, float], +) -> dict[str, str]: + """Explain the analytics lab setup in plain language.""" + timing_text = "well-timed" if abs(timing_error_ms) <= 40 else "early" if timing_error_ms < 0 else "late" + risk_text = "inside" if metrics["wicket_risk"] <= risk_budget else "above" + best_use = "Use this when you need boundaries." if metrics["expected_runs"] >= 3.5 else "Use this when protecting wickets matters." + if required_rate >= 12 and metrics["expected_runs"] < 3: + best_use = "This is too conservative for the current chase pressure." + if shot_intent == "Defend" and required_rate >= 10: + best_use = "Defend is mainly for survival. At this required rate, compare it with Drive or Loft." + if shot_intent == "Loft" and metrics["wicket_risk"] <= risk_budget: + best_use = "This is a rational attacking option because the upside is high and risk stays within budget." + return { + "What changed?": ( + f"{shot_intent} to {aim_lane} against {delivery} is {timing_text} at {power}% power, " + f"creating {metrics['expected_runs']:.2f} expected runs." + ), + "Best use case": best_use, + "Risk warning": f"Wicket risk is {metrics['wicket_risk']:.0f}%, which is {risk_text} your {risk_budget}% risk budget.", + "Analytics lesson": ( + "Shot choice changes the model. A Drive, Loft, or Defend with the same timing can be a different decision " + "because upside and dismissal risk move together." + ), + } + + +def render_sidebar_model() -> None: + """Show lightweight analytics without turning the game into a spreadsheet.""" + model_df = build_pitch_analytics() + + with st.sidebar: + st.header("Model Guide") + st.write( + "RunRate Lab treats every ball as a decision under uncertainty. " + "Timing, power, delivery type, and match pressure become measurable inputs." + ) + + radar_fig = go.Figure() + radar_fig.add_trace( + go.Scatterpolar( + r=[82, 74, 69, 58, 77], + theta=["Timing", "Power", "Contact", "Risk Control", "Pressure"], + fill="toself", + name="Analytics Skill", + line_color="#facc15", + fillcolor="rgba(250, 204, 21, 0.22)", + ) + ) + radar_fig.update_layout( + height=270, + margin={"l": 20, "r": 20, "t": 35, "b": 20}, + paper_bgcolor="rgba(0,0,0,0)", + polar={"bgcolor": "rgba(0,0,0,0)", "radialaxis": {"visible": True, "range": [0, 100]}}, + showlegend=False, + title="Learning Skills", + ) + st.plotly_chart(radar_fig, width="stretch") + + risk_fig = px.bar( + model_df, + x="Delivery", + y=["Boundary Upside", "Wicket Risk"], + barmode="group", + title="Delivery Tradeoffs", + color_discrete_map={"Boundary Upside": "#22c55e", "Wicket Risk": "#ef4444"}, + ) + risk_fig.update_layout( + height=290, + margin={"l": 15, "r": 15, "t": 45, "b": 20}, + paper_bgcolor="rgba(0,0,0,0)", + legend_title_text="", + ) + st.plotly_chart(risk_fig, width="stretch") + st.dataframe(model_df, hide_index=True) + + +GAME_HTML = r""" + + +
+ + + + + +Select a shot and lane. Hold to charge as the bowler releases, then let go near the impact band.
+ + +