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()