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""" + + + + + + + + +
+
+ +
+
Hold and releaseto swing
+
Choose a shot, pick a lane, hold, then release near impact.
+ +
+
+ RunRate Lab + Play. Analyze. Improve. + Choose shot intent, pick a lane, then judge the process before the result. +
+
+
Target0
+
Score0/0
+
Overs0.0
+
Need0
+
RRR0.0
+
Process0
+
+
+ + + + +
+
+

Analytics Coach

+
+ + + +
+
+
+

Select a shot and lane. Hold to charge as the bowler releases, then let go near the impact band.

+ + +
+ +
+
+

Shot Intent

+
+
1 Defend, 2 Drive, 3 Cut or Pull, 4 Loft.
+
+
+

Aim Lane

+
+
+
Hold to start. Release near the impact band.
+
+
+ +
+0ReadyReady
+ +
+ +
+ +
+ + + + +""" + + +def build_game_html(target_score: int) -> str: + """Inject the Python model constants into the browser game.""" + delivery_json = json.dumps(PITCH_MODEL.to_dict("records")) + shot_json = json.dumps(SHOT_MODEL.to_dict("records")) + aim_json = json.dumps(AIM_MODEL.to_dict("records")) + threshold_json = json.dumps(DECISION_THRESHOLDS) + return ( + GAME_HTML.replace("__INITIAL_TARGET__", str(target_score)) + .replace("__DELIVERIES_JSON__", delivery_json) + .replace("__SHOTS_JSON__", shot_json) + .replace("__AIMS_JSON__", aim_json) + .replace("__DECISION_THRESHOLDS__", threshold_json) + .replace("__TIMING_ASSIST_MULTIPLIER__", str(TIMING_ASSIST_MULTIPLIER)) + .replace("__POWER_TOLERANCE__", str(POWER_TOLERANCE)) + ) + + +def render_arcade_game() -> None: + """Embed the browser-native game loop in the Streamlit page.""" + game_html = build_game_html(st.session_state.target_score) + encoded_game = base64.b64encode(game_html.encode("utf-8")).decode("ascii") + st.iframe(f"data:text/html;base64,{encoded_game}", height=690, width="stretch", tab_index=0) + + +def render_analytics_lab() -> None: + """Let players experiment with the model behind each shot.""" + st.subheader("Interactive Analytics Lab") + st.write( + "Change one input at a time to see how the model reacts. This is the same idea analysts use " + "when they isolate timing, power, pressure, and risk in a game situation." + ) + + control_col, chart_col = st.columns([0.95, 1.35], vertical_alignment="top") + with control_col: + delivery = st.selectbox("Delivery type", PITCH_MODEL["Delivery"].tolist(), index=1) + shot_intent = st.selectbox("Shot intent", SHOT_MODEL["Shot"].tolist(), index=1) + aim_lane = st.selectbox("Aim lane", AIM_MODEL["Aim"].tolist(), index=1) + timing_error = st.slider( + "Release timing error in milliseconds", + min_value=-220, + max_value=220, + value=0, + step=5, + help="Negative means early. Positive means late.", + ) + power = st.slider("Shot power", min_value=0, max_value=100, value=70, step=1) + required_rate = st.slider("Required run rate", min_value=3.0, max_value=18.0, value=8.5, step=0.1) + risk_budget = st.slider("Wicket risk budget", min_value=5, max_value=50, value=20, step=1) + samples = st.slider("Simulation sample size", min_value=1000, max_value=10000, value=DEFAULT_SIMULATION_SAMPLES, step=1000) + + probability_df, metrics = simulate_outcome_distribution( + delivery, + timing_error, + power, + required_rate, + samples, + shot_intent, + aim_lane, + ) + best_outcome = probability_df.sort_values("Probability", ascending=False).iloc[0] + + with chart_col: + metric_cols = st.columns(5) + metric_cols[0].metric("Contact Quality", f"{metrics['contact']:.0f}%") + metric_cols[1].metric("Expected Runs", f"{metrics['expected_runs']:.2f}") + metric_cols[2].metric("Wicket Risk", f"{metrics['wicket_risk']:.0f}%") + metric_cols[3].metric("Most Likely", str(best_outcome["Outcome"])) + metric_cols[4].metric("Decision", metrics["decision_quality"]) + + probability_fig = px.bar( + probability_df, + x="Outcome", + y="Probability", + color="Outcome", + color_discrete_map={ + "0": "#94a3b8", + "1": "#64748b", + "2": "#22c55e", + "3": "#f59e0b", + "4": "#38bdf8", + "6": "#facc15", + "W": "#ef4444", + }, + title="Monte Carlo Outcome Estimate", + ) + probability_fig.update_layout( + height=330, + showlegend=False, + margin={"l": 20, "r": 20, "t": 50, "b": 20}, + yaxis_title="Probability %", + ) + st.plotly_chart(probability_fig, width="stretch") + + interpretation = build_interpretation(delivery, shot_intent, aim_lane, timing_error, power, required_rate, risk_budget, metrics) + st.markdown("#### What The Model Is Teaching") + interp_cols = st.columns(4) + for column, (title, body) in zip(interp_cols, interpretation.items()): + with column: + st.markdown(f"**{title}**") + st.write(body) + + timing_values = np.arange(-220, 221, 10) + sensitivity_rows = [] + for timing in timing_values: + _, timing_metrics = simulate_outcome_distribution( + delivery, + int(timing), + power, + required_rate, + samples=2200, + shot_intent=shot_intent, + aim_lane=aim_lane, + ) + sensitivity_rows.append( + { + "Timing Error": timing, + "Expected Runs": timing_metrics["expected_runs"], + "Wicket Risk": timing_metrics["wicket_risk"], + } + ) + + sensitivity_df = pd.DataFrame(sensitivity_rows) + sensitivity_fig = go.Figure() + sensitivity_fig.add_trace( + go.Scatter( + x=sensitivity_df["Timing Error"], + y=sensitivity_df["Expected Runs"], + mode="lines", + name="Expected Runs", + line={"color": "#22c55e", "width": 3}, + ) + ) + sensitivity_fig.add_trace( + go.Scatter( + x=sensitivity_df["Timing Error"], + y=sensitivity_df["Wicket Risk"] / 10, + mode="lines", + name="Wicket Risk divided by 10", + line={"color": "#ef4444", "width": 3}, + yaxis="y2", + ) + ) + sensitivity_fig.add_vline(x=timing_error, line_dash="dot", line_color="#facc15") + sensitivity_fig.update_layout( + title="Timing Sensitivity Curve", + height=330, + margin={"l": 20, "r": 20, "t": 50, "b": 25}, + xaxis_title="Release Timing Error, ms", + yaxis={"title": "Expected Runs"}, + yaxis2={"title": "Scaled Wicket Risk", "overlaying": "y", "side": "right", "showgrid": False}, + ) + + frontier_rows = [] + for test_power in range(0, 101, 5): + _, frontier_metrics = simulate_outcome_distribution( + delivery, + timing_error, + test_power, + required_rate, + samples=1800, + shot_intent=shot_intent, + aim_lane=aim_lane, + ) + frontier_rows.append( + { + "Power": test_power, + "Expected Runs": frontier_metrics["expected_runs"], + "Wicket Risk": frontier_metrics["wicket_risk"], + "Inside Budget": frontier_metrics["wicket_risk"] <= risk_budget, + } + ) + frontier_df = pd.DataFrame(frontier_rows) + frontier_fig = px.scatter( + frontier_df, + x="Wicket Risk", + y="Expected Runs", + color="Inside Budget", + size="Power", + hover_data=["Power"], + title="Risk-Reward Frontier", + color_discrete_map={True: "#22c55e", False: "#ef4444"}, + ) + frontier_fig.add_vline(x=risk_budget, line_dash="dot", line_color="#facc15", annotation_text="Risk budget") + frontier_fig.update_layout(height=330, margin={"l": 20, "r": 20, "t": 50, "b": 25}) + + curve_col, frontier_col = st.columns(2) + with curve_col: + st.plotly_chart(sensitivity_fig, width="stretch") + with frontier_col: + st.plotly_chart(frontier_fig, width="stretch") + + st.markdown("#### Ten Possible Results From The Same Shot") + st.dataframe( + simulate_sample_table(delivery, timing_error, power, required_rate, shot_intent=shot_intent, aim_lane=aim_lane), + hide_index=True, + width="stretch", + ) + + +def render_learning_path() -> None: + """Render guided analytics modules.""" + st.subheader("Learning Path") + modules = [ + { + "title": "Expected Runs", + "goal": "Understand expected value.", + "mission": "Compare Defend and Drive against the same delivery in the game and lab.", + "lesson": "The scoreboard shows one result. Expected runs shows the long-run value of each decision.", + }, + { + "title": "Wicket Risk", + "goal": "Learn risk-reward tradeoffs.", + "mission": "Complete an over using only shots the coach rates under 20% wicket risk.", + "lesson": "High scoring decisions are not always good if dismissal risk is too high.", + }, + { + "title": "Variance And Luck", + "goal": "Separate process from outcome.", + "mission": "Identify one lucky result and one unlucky result from the coach panel or ball log.", + "lesson": "Analytics judges decisions by probabilities, not only scoreboard results.", + }, + { + "title": "Timing Sensitivity", + "goal": "Learn how one input changes a model.", + "mission": "Replay the same shot intent and aim lane with early, good, and late release timing.", + "lesson": "Controlled experiments isolate cause and effect.", + }, + { + "title": "Match Pressure", + "goal": "Learn context-aware decision-making.", + "mission": "Compare Drive and Loft at required run rates of 6, 10, and 14 in the lab.", + "lesson": "Optimal strategy changes when the chase context changes.", + }, + ] + for index, module in enumerate(modules, start=1): + with st.expander(f"{index}. {module['title']}", expanded=index == 1): + st.markdown(f"**Goal:** {module['goal']}") + st.markdown(f"**Mission:** {module['mission']}") + st.markdown(f"**Completion lesson:** {module['lesson']}") + + +def render_glossary() -> None: + """Explain core analytics terms.""" + st.subheader("Glossary") + terms = [ + ("xRuns", "Expected runs. The long-run run value of the shot before the random result appears."), + ("Expected Value", "The average outcome you would expect if the same decision were repeated many times."), + ("Variance", "The gap between what the model expects and what one random trial actually produces."), + ("Monte Carlo Simulation", "A method that repeats the same scenario many times to estimate outcome probabilities."), + ("Wicket Risk", "The probability that a shot leads to dismissal."), + ("Decision Quality", "A label for whether the shot created enough value for the risk accepted."), + ("Process vs Outcome", "The idea that a good decision can fail and a bad decision can succeed because of randomness."), + ] + cols = st.columns(2) + for index, (term, definition) in enumerate(terms): + with cols[index % 2]: + st.markdown(f"**{term}**") + st.write(definition) + + +def render_how_to_use() -> None: + """Offer a practical workflow for learners.""" + st.subheader("How To Use This App") + st.write( + "Start with one over. After every ball, read the Analytics Coach panel before playing again. " + "Then recreate the same situation in the Analytics Lab, change one input, and compare the chart. " + "Finish by completing one Learning Path mission." + ) + st.info( + "Best learning loop: play one ball, inspect decision quality, adjust timing or power in the lab, " + "then play again with a clearer hypothesis." + ) + + +def render_page() -> None: + """Render the full Streamlit app shell.""" + initialize_session_state() + render_sidebar_model() + + st.title(APP_TITLE) + st.caption(f"{APP_SUBTITLE} {SHORT_DESCRIPTION}") + + render_arcade_game() + render_analytics_lab() + render_learning_path() + render_glossary() + render_how_to_use() + + +if __name__ == "__main__": + render_page()