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

1"""RPI (Rating Percentage Index) — pure, framework-free computation. 

2 

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``: 

5 

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) 

11 

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. 

14 

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""" 

20 

21from collections import defaultdict 

22 

23from ..domain.models import MatchResult, TeamRPI 

24 

25# Outcome codes from the rated team's perspective. 

26_WIN, _LOSS, _TIE = "W", "L", "T" 

27 

28 

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 

32 

33 

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 

41 

42 

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. 

45 

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 

61 

62 

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 

69 

70 

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. 

73 

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

79 

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 

94 

95 

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) 

104 

105 

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``. 

113 

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. 

119 

120 Returns: 

121 TeamRPI rows sorted by RPI descending, with 1-based ranks assigned. 

122 

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) 

128 

129 owp_values = {team: _owp(team, record, games, owp_tie_weight) for team in teams} 

130 

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 ) 

150 

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