Coverage for src/ecnl/application/_rpi.py: 98%
57 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-28 08:19 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-28 08:19 +0000
1"""RPI (Rating Percentage Index) — pure, framework-free computation.
3Formula (sites.google.com/site/rpifordivisioniwomenssoccer/rpi-formula),
4standard NCAA structure ``(E1 + 2*E2 + E3) / 4`` == ``0.25*WP + 0.50*OWP + 0.25*OOWP``:
6 - E1 WP = (W + wp_tie_weight*T) / (W + L + T)
7 - E2 OWP = mean over each game's opponent of that opponent's winning pct with
8 the game(s) against the rated team removed, ties scored at
9 ``owp_tie_weight``
10 - E3 OOWP = mean over each game's opponent of that opponent's OWP (no exclusion)
12``wp_tie_weight`` defaults to 1/3 (2024 convention); pass 1/2 for the pre-2024
13convention. ``owp_tie_weight`` defaults to 1/2 per the source.
15Design lineage: modeled on the ``ratings`` repo's match-based stats, corrected
16to handle ties in WP per the formula above and rebuilt to compute every team in
17a single O(V + E) pass (V teams, E game-sides) instead of re-deriving each
18opponent's record per lookup.
19"""
21from collections import defaultdict
23from ..domain.models import MatchResult, TeamRPI
25# Outcome codes from the rated team's perspective.
26_WIN, _LOSS, _TIE = "W", "L", "T"
29def _mean(values: list[float]) -> float:
30 """Return the arithmetic mean, or 0.0 for an empty list."""
31 return sum(values) / len(values) if values else 0.0
34def _outcomes(home_score: int, away_score: int) -> tuple[str, str]:
35 """Return (home_outcome, away_outcome) codes for a final score."""
36 if home_score > away_score:
37 return _WIN, _LOSS
38 if home_score < away_score:
39 return _LOSS, _WIN
40 return _TIE, _TIE
43def build_graph(results: list[MatchResult]) -> tuple[dict[str, list[int]], dict[str, list[tuple[str, str]]]]:
44 """Build the opponent graph from completed match results.
46 Returns:
47 A ``(record, games)`` pair where ``record[team] == [wins, losses, ties]``
48 over all games and ``games[team]`` is the list of ``(opponent, outcome)``
49 tuples — one per game played, so repeat opponents appear multiple times.
50 """
51 record: dict[str, list[int]] = defaultdict(lambda: [0, 0, 0])
52 games: dict[str, list[tuple[str, str]]] = defaultdict(list)
53 index = {_WIN: 0, _LOSS: 1, _TIE: 2}
54 for match in results:
55 home_outcome, away_outcome = _outcomes(match.home_score, match.away_score)
56 record[match.home_team][index[home_outcome]] += 1
57 record[match.away_team][index[away_outcome]] += 1
58 games[match.home_team].append((match.away_team, home_outcome))
59 games[match.away_team].append((match.home_team, away_outcome))
60 return record, games
63def winning_pct(wins: int, losses: int, ties: int, tie_weight: float) -> float:
64 """Return (W + tie_weight*T) / (W + L + T), or 0.0 when no games played."""
65 played = wins + losses + ties
66 if played == 0:
67 return 0.0
68 return (wins + tie_weight * ties) / played
71def _opponent_wp_excluding(record: list[int], outcome: str, tie_weight: float) -> float | None:
72 """Return an opponent's WP with one game (vs the rated team) removed.
74 Args:
75 record: The opponent's full ``[wins, losses, ties]``.
76 outcome: The rated team's outcome in the game being excluded; the
77 opponent's mirror result is subtracted.
78 tie_weight: Tie weight for the opponent WP (OWP convention).
80 Returns:
81 The adjusted winning percentage, or None if the opponent has no other games.
82 """
83 wins, losses, ties = record
84 if outcome == _WIN: # rated team won -> opponent lost this game
85 losses -= 1
86 elif outcome == _LOSS: # rated team lost -> opponent won this game
87 wins -= 1
88 else:
89 ties -= 1
90 played = wins + losses + ties
91 if played <= 0:
92 return None
93 return (wins + tie_weight * ties) / played
96def _owp(team: str, record: dict[str, list[int]], games: dict[str, list[tuple[str, str]]], tie_weight: float) -> float:
97 """Compute a team's opponents' winning percentage (E2)."""
98 values = [
99 wp
100 for opponent, outcome in games[team]
101 if (wp := _opponent_wp_excluding(record[opponent], outcome, tie_weight)) is not None
102 ]
103 return _mean(values)
106def compute_rpi(
107 results: list[MatchResult],
108 wp_tie_weight: float = 1 / 3,
109 owp_tie_weight: float = 0.5,
110 digits: int = 4,
111) -> list[TeamRPI]:
112 """Compute RPI for every team appearing in ``results``.
114 Args:
115 results: Completed matches (team names + integer scores).
116 wp_tie_weight: Tie weight for the team's own WP (E1). 1/3 (2024) or 1/2 (pre-2024).
117 owp_tie_weight: Tie weight for opponent WP in E2/E3. 1/2 per the source.
118 digits: Rounding precision for the reported component and RPI values.
120 Returns:
121 TeamRPI rows sorted by RPI descending, with 1-based ranks assigned.
123 Complexity:
124 O(V + E) — single graph build, OWP cached per team before OOWP averages it.
125 """
126 record, games = build_graph(results)
127 teams = list(record)
129 owp_values = {team: _owp(team, record, games, owp_tie_weight) for team in teams}
131 rows: list[TeamRPI] = []
132 for team in teams:
133 wins, losses, ties = record[team]
134 wp = winning_pct(wins, losses, ties, wp_tie_weight)
135 owp = owp_values[team]
136 oowp = _mean([owp_values[opponent] for opponent, _ in games[team]])
137 rpi = 0.25 * wp + 0.50 * owp + 0.25 * oowp
138 rows.append(
139 TeamRPI(
140 team=team,
141 wins=wins,
142 losses=losses,
143 draws=ties,
144 wp=round(wp, digits),
145 owp=round(owp, digits),
146 oowp=round(oowp, digits),
147 rpi=round(rpi, digits),
148 )
149 )
151 rows.sort(key=lambda r: r.rpi, reverse=True)
152 for rank, row in enumerate(rows, start=1):
153 row.rank = rank
154 return rows