From cc1b2130d3b5d9987d387b446987be9ed5ac87e5 Mon Sep 17 00:00:00 2001 From: Dhairya Pathak <274224109+dhairyapathak2005-cmyk@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:36:16 +0000 Subject: [PATCH] Compute run rate from balls, not from the overs figure ESPN writes overs in cricket notation: 19.4 is 19 overs and 4 balls. The innings run rate divided runs by that figure, so any innings that ended mid-over (a completed chase, a side bowled out) got too high a rate: 159 off 19.4 overs came out at 8.2 instead of 8.08. Divide by the balls bowled instead. ESPN's innings record carries `balls` and `ballsPerOver`; when `balls` is missing, convert the overs figure. The recorded fixture shows the notation: its partnerships pair overs 13.4 with balls 82. Innings of whole overs are unchanged. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011R2whb7Ajr1ogzUchRBNVy --- CHANGELOG.md | 8 ++++++++ espncricinfo/match.py | 25 +++++++++++++++++++++---- tests/test_match.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe13046..885b95f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Fixed + +- `Match.team_1_run_rate`, `Match.team_2_run_rate` and each innings' + `run_rate` are right for an innings that ends mid-over. ESPN writes 19.4 for + 19 overs and 4 balls, and the rate divided runs by that figure, so 159 runs + came out at 8.2 an over instead of 8.08. The rate now counts balls, using + ESPN's `ballsPerOver` where overs are not six balls. + ## [1.1.0] - 2026-10-23 ### Fixed diff --git a/espncricinfo/match.py b/espncricinfo/match.py index 3253ef4..3fbe3a7 100644 --- a/espncricinfo/match.py +++ b/espncricinfo/match.py @@ -89,6 +89,26 @@ def _boundary_count(inn: dict[str, Any], key: str) -> int | None: return sum(counts) +def _run_rate(inn: dict[str, Any]) -> float: + """Runs per over for an innings; 0.0 before a ball is bowled. + + ESPN writes overs in cricket notation, where 19.4 is 19 overs and 4 balls, + so dividing runs by it overstates the rate of an innings that ends + mid-over. Count balls instead: ESPN's ``balls``, else the overs figure + converted with ``ballsPerOver`` (6 unless ESPN says otherwise). + """ + try: + runs = inn.get("runs") or 0 + per_over = inn.get("ballsPerOver") or 6 + balls = inn.get("balls") + if not balls: + overs = float(inn.get("overs") or 0) + balls = int(overs) * per_over + round(overs % 1 * 10) + return round(runs * per_over / balls, 2) if balls > 0 else 0.0 + except (TypeError, ValueError, ZeroDivisionError): + return 0.0 + + def _as_float(record: dict[str, Any] | None, key: str) -> float | None: """Return ``record[key]`` as a float, or None if absent or not numeric.""" if record is None: @@ -183,10 +203,7 @@ def build_team(t): for inn in innings_raw: overs = inn.get("overs") or 0 runs = inn.get("runs") or 0 - try: - run_rate = round(runs / overs, 2) if overs > 0 else 0.0 - except (ZeroDivisionError, TypeError): - run_rate = 0.0 + run_rate = _run_rate(inn) innings_normalized.append( { "batting_team_id": str(inn["team"]["objectId"]), diff --git a/tests/test_match.py b/tests/test_match.py index 5de9957..8bf2e12 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -494,6 +494,43 @@ def test_boundary_count_falls_back_to_batters_then_none(): assert _boundary_count({"inningBatsmen": []}, "fours") is None +def test_run_rate_counts_a_part_over_in_balls(): + """ESPN's 19.4 overs is 19 overs and 4 balls, not 19.4 overs.""" + from espncricinfo.match import _run_rate + + chase = {"runs": 159, "overs": 19.4, "balls": 118, "ballsPerOver": 6} + assert _run_rate(chase) == 8.08 # 159 / 19.4 would give 8.2 + assert _run_rate({"runs": 176, "overs": 20, "balls": 120}) == 8.8 + + +def test_run_rate_converts_overs_when_espn_gives_no_ball_count(): + from espncricinfo.match import _run_rate + + assert _run_rate({"runs": 159, "overs": 19.4}) == 8.08 + assert _run_rate({"runs": 159, "overs": 19.4, "balls": None}) == 8.08 + # Eight-ball overs, as in older Tests: 12.4 overs is 100 balls. + assert _run_rate({"runs": 100, "overs": 12.4, "ballsPerOver": 8}) == 8.0 + + +def test_run_rate_is_zero_before_a_ball_is_bowled(): + from espncricinfo.match import _run_rate + + assert _run_rate({"runs": 0, "overs": 0, "balls": 0}) == 0.0 + assert _run_rate({"runs": None, "overs": None}) == 0.0 + assert _run_rate({}) == 0.0 + + +def test_team_run_rate_for_an_innings_that_ends_mid_over(): + next_data = _next_data_with() + app = next_data["props"]["appPageProps"]["data"] + data = app if "match" in app else app["data"] + data["content"]["innings"][1].update(overs=19.4, balls=118) + match = _match_from(next_data) + assert match.team_2_overs_batted == 19.4 + assert match.team_2_run_rate == 8.08 + assert match.team_1_run_rate == 8.8 + + def test_over_by_over_adds_up_to_innings_totals(): match = _make_match() assert [len(overs) for overs in match.over_by_over] == [20, 20]