Coverage for src/ecnl/domain/models.py: 100%

102 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-28 08:19 +0000

1"""Domain models for the ECNL MCP server. 

2 

3Pure Python dataclasses with zero framework dependencies. Adapters translate 

4to/from these types from the AthleteOne API wire format. 

5 

6Vocabulary (see docs/decisions/0001-data-source-athleteone-api.md): 

7 - league: "ECNL" or "ECRL" (the API spells ECRL as "ECNL RL") 

8 - gender: "girls" or "boys" 

9 - event: one league x gender x conference x season (has an ``event_id``) 

10 - division: an age group within an event (e.g. "G2008/2007") 

11 - flight: a competition grouping within a division; standings and 

12 schedules are keyed by ``flight_id`` 

13""" 

14 

15from dataclasses import dataclass, field 

16 

17# League/gender are kept as plain strings (not enums) so they serialize cleanly 

18# through MCP tool arguments and the JSON cache key. Validated at the boundary. 

19type League = str # "ECNL" | "ECRL" 

20type Gender = str # "girls" | "boys" 

21 

22 

23@dataclass(slots=True) 

24class Event: 

25 """A single ECNL/ECRL competition: one league x gender x conference x season. 

26 

27 ``conference`` and ``season`` are parsed from the event name (e.g. 

28 "ECNL Girls Southeast 2025-26" -> league=ECNL, gender=girls, 

29 conference="Southeast", season="2025-26"). 

30 """ 

31 

32 event_id: int 

33 name: str 

34 league: League 

35 gender: Gender 

36 conference: str 

37 season: str 

38 location: str | None = None 

39 start_date: str | None = None 

40 end_date: str | None = None 

41 

42 

43@dataclass(slots=True) 

44class Flight: 

45 """A competition grouping within a division. 

46 

47 ``name`` is the flight label from the feed — typically "ECNL" or "ECRL" — 

48 which is how a single event can carry both league tiers. Standings and 

49 schedules are addressed by ``flight_id``. 

50 """ 

51 

52 flight_id: int 

53 division_id: int 

54 division_name: str 

55 name: str 

56 teams_count: int 

57 has_active_schedule: bool = False 

58 

59 

60@dataclass(slots=True) 

61class Division: 

62 """An age group within an event, with its flights.""" 

63 

64 division_id: int 

65 name: str 

66 gender: Gender 

67 flights: list[Flight] = field(default_factory=list) 

68 

69 

70@dataclass(slots=True) 

71class EventOverview: 

72 """Full navigation tree for an event: the event plus its divisions/flights. 

73 

74 Divisions are split by gender at the source; this model keeps a single 

75 flattened list and each division carries its own ``gender``. 

76 """ 

77 

78 event: Event 

79 divisions: list[Division] = field(default_factory=list) 

80 

81 

82@dataclass(slots=True) 

83class Team: 

84 """A team competing in a flight or event.""" 

85 

86 team_id: int 

87 name: str 

88 club_id: int | None = None 

89 club_logo: str | None = None 

90 head_coach: str | None = None 

91 

92 

93@dataclass(slots=True) 

94class Club: 

95 """A club participating in an event.""" 

96 

97 club_id: int 

98 name: str 

99 city: str | None = None 

100 state_code: str | None = None 

101 logo: str | None = None 

102 

103 

104@dataclass(slots=True) 

105class StandingRow: 

106 """A single team's row in a flight standings table.""" 

107 

108 team_id: int 

109 team_name: str 

110 wins: int 

111 losses: int 

112 draws: int 

113 points: int 

114 points_per_game: float 

115 goals_for: int | None = None 

116 goals_against: int | None = None 

117 goal_differential: int | None = None 

118 rank: int | None = None 

119 club_logo: str | None = None 

120 

121 

122@dataclass(slots=True) 

123class Standings: 

124 """A flight's standings table, ordered as returned by the source.""" 

125 

126 event_id: int 

127 division_id: int 

128 flight_id: int 

129 rows: list[StandingRow] = field(default_factory=list) 

130 

131 

132@dataclass(slots=True) 

133class Match: 

134 """A single scheduled or completed match within a flight. 

135 

136 ``home_score``/``away_score`` are None until the match is played. ``status`` 

137 carries the source's free-text status / game type (e.g. "Group Play"). 

138 """ 

139 

140 match_id: int 

141 date: str | None 

142 time: str | None 

143 home_team_id: int | None 

144 home_team: str 

145 away_team_id: int | None 

146 away_team: str 

147 home_score: int | None = None 

148 away_score: int | None = None 

149 venue: str | None = None 

150 complex: str | None = None 

151 flight_id: int | None = None 

152 division: str | None = None 

153 status: str | None = None 

154 

155 @property 

156 def is_played(self) -> bool: 

157 """True when both scores are present (the match has a final result).""" 

158 return self.home_score is not None and self.away_score is not None 

159 

160 

161@dataclass(slots=True) 

162class MatchResult: 

163 """A completed match reduced to the fields RPI needs. 

164 

165 Team identity is the team *name* because the RPI opponent graph is built by 

166 matching team names across a flight's full schedule. 

167 """ 

168 

169 home_team: str 

170 away_team: str 

171 home_score: int 

172 away_score: int 

173 

174 

175@dataclass(slots=True) 

176class TeamRPI: 

177 """A team's RPI with its three weighted components and its raw record. 

178 

179 ``rpi = wp_weight*wp + owp_weight*owp + oowp_weight*oowp`` (NCAA 25/50/25). 

180 ``wp``/``owp``/``oowp`` are the winning-percentage, opponents' winning- 

181 percentage, and opponents' opponents' winning-percentage elements. 

182 """ 

183 

184 team: str 

185 wins: int 

186 losses: int 

187 draws: int 

188 wp: float 

189 owp: float 

190 oowp: float 

191 rpi: float 

192 rank: int | None = None