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
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-28 08:19 +0000
1"""Domain models for the ECNL MCP server.
3Pure Python dataclasses with zero framework dependencies. Adapters translate
4to/from these types from the AthleteOne API wire format.
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"""
15from dataclasses import dataclass, field
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"
23@dataclass(slots=True)
24class Event:
25 """A single ECNL/ECRL competition: one league x gender x conference x season.
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 """
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
43@dataclass(slots=True)
44class Flight:
45 """A competition grouping within a division.
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 """
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
60@dataclass(slots=True)
61class Division:
62 """An age group within an event, with its flights."""
64 division_id: int
65 name: str
66 gender: Gender
67 flights: list[Flight] = field(default_factory=list)
70@dataclass(slots=True)
71class EventOverview:
72 """Full navigation tree for an event: the event plus its divisions/flights.
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 """
78 event: Event
79 divisions: list[Division] = field(default_factory=list)
82@dataclass(slots=True)
83class Team:
84 """A team competing in a flight or event."""
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
93@dataclass(slots=True)
94class Club:
95 """A club participating in an event."""
97 club_id: int
98 name: str
99 city: str | None = None
100 state_code: str | None = None
101 logo: str | None = None
104@dataclass(slots=True)
105class StandingRow:
106 """A single team's row in a flight standings table."""
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
122@dataclass(slots=True)
123class Standings:
124 """A flight's standings table, ordered as returned by the source."""
126 event_id: int
127 division_id: int
128 flight_id: int
129 rows: list[StandingRow] = field(default_factory=list)
132@dataclass(slots=True)
133class Match:
134 """A single scheduled or completed match within a flight.
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 """
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
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
161@dataclass(slots=True)
162class MatchResult:
163 """A completed match reduced to the fields RPI needs.
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 """
169 home_team: str
170 away_team: str
171 home_score: int
172 away_score: int
175@dataclass(slots=True)
176class TeamRPI:
177 """A team's RPI with its three weighted components and its raw record.
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 """
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