Coverage for src/ecnl/application/service.py: 100%
69 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"""Application service — the core of the hexagonal architecture.
3Orchestrates work by delegating to outbound ports. It knows nothing about MCP,
4HTTP, or JSON — those are adapter concerns. RPI math lives in the pure ``_rpi``
5module; this service only extracts results and hands them off.
6"""
8import time
9from collections import OrderedDict
10from collections.abc import Callable
12from ..domain.exceptions import ECNLNotFoundError
13from ..domain.models import (
14 Club,
15 Event,
16 EventOverview,
17 Match,
18 MatchResult,
19 Standings,
20 Team,
21 TeamRPI,
22)
23from ..ports.outbound import DiscoveryPort, ECNLAPIPort
24from ._rpi import compute_rpi
26_VALID_LEAGUES = ("ECNL", "ECRL")
27_VALID_GENDERS = ("girls", "boys")
30def _validate_enum(value: str | None, valid: tuple[str, ...], label: str) -> str | None:
31 """Normalize and validate an optional enum-like argument.
33 Returns the normalized value, or None when ``value`` is None.
35 Raises:
36 ValueError: If a non-None value is not in ``valid``.
37 """
38 if value is None:
39 return None
40 normalized = value.strip().upper() if label == "league" else value.strip().lower()
41 if normalized not in valid:
42 raise ValueError(f"Invalid {label}={value!r}. Must be one of: {', '.join(valid)}")
43 return normalized
46class ECNLService:
47 """Coordinates ECNL/ECRL data lookups through the outbound ports.
49 Two driven ports are injected: ``repo`` (the AthleteOne data feed) and
50 ``discovery`` (resolves league/gender/season queries to events).
51 """
53 def __init__(
54 self,
55 repo: ECNLAPIPort,
56 discovery: DiscoveryPort,
57 now: Callable[[], float] = time.monotonic,
58 rpi_ttl_seconds: float = 60.0,
59 rpi_cache_size: int = 32,
60 ) -> None:
61 """Initialize the service.
63 Args:
64 repo: The AthleteOne data port.
65 discovery: The event-discovery port.
66 now: Monotonic clock for the RPI table memo. Injectable for testing.
67 rpi_ttl_seconds: TTL of a memoized RPI table — matches the schedule
68 cache lifetime, since the table is a pure transform of the schedule.
69 rpi_cache_size: Max distinct (event, flight, tie_weight) tables retained.
70 """
71 self._repo = repo
72 self._discovery = discovery
73 self._now = now
74 self._rpi_ttl = rpi_ttl_seconds
75 self._rpi_cache_size = rpi_cache_size
76 # LRU + TTL memo so get_rpi and get_team_rpi share one computation per
77 # (event, flight, tie_weight) instead of rebuilding the table per call.
78 self._rpi_cache: OrderedDict[tuple[int, int, float], tuple[float, list[TeamRPI]]] = OrderedDict()
80 # -- discovery & navigation --------------------------------------------
82 async def find_events(
83 self,
84 league: str | None = None,
85 gender: str | None = None,
86 season: str | None = None,
87 ) -> list[Event]:
88 """Find ECNL/ECRL events by league, gender, and/or season."""
89 league_n = _validate_enum(league, _VALID_LEAGUES, "league")
90 gender_n = _validate_enum(gender, _VALID_GENDERS, "gender")
91 return await self._discovery.find_events(league_n, gender_n, season)
93 async def get_event_overview(self, event_id: int) -> EventOverview:
94 """Return the division/flight tree and metadata for an event."""
95 return await self._repo.get_event_overview(event_id)
97 # -- standings & schedule ----------------------------------------------
99 async def get_standings(self, event_id: int, division_id: int, flight_id: int) -> Standings:
100 """Return the standings table for a flight."""
101 return await self._repo.get_standings(event_id, division_id, flight_id)
103 async def get_schedule(self, event_id: int, flight_id: int) -> list[Match]:
104 """Return all matches for a flight."""
105 return await self._repo.get_schedule(event_id, flight_id)
107 async def get_team_schedule(self, event_id: int, team_id: int) -> list[Match]:
108 """Return all matches for a single team within an event."""
109 return await self._repo.get_team_schedule(event_id, team_id)
111 # -- teams, clubs, matches, brackets -----------------------------------
113 async def get_teams(self, flight_id: int) -> list[Team]:
114 """Return the teams competing in a flight."""
115 return await self._repo.get_flight_teams(flight_id)
117 async def get_clubs(self, event_id: int) -> list[Club]:
118 """Return the clubs participating in an event."""
119 return await self._repo.get_clubs(event_id)
121 async def get_match(self, match_token: str) -> dict:
122 """Return raw match-detail data for a match token."""
123 return await self._repo.get_match(match_token)
125 async def get_brackets(self, event_id: int, flight_id: int) -> dict:
126 """Return raw playoff-bracket data for a flight."""
127 return await self._repo.get_brackets(event_id, flight_id)
129 # -- analytics: results & RPI ------------------------------------------
131 async def get_results(self, event_id: int, flight_id: int) -> list[MatchResult]:
132 """Return completed match results for a flight (played games only).
134 This is the raw feed RPI builds on, exposed on its own because "what are
135 the final scores so far?" is a common question.
136 """
137 schedule = await self._repo.get_schedule(event_id, flight_id)
138 return [
139 MatchResult(
140 home_team=m.home_team,
141 away_team=m.away_team,
142 home_score=m.home_score, # type: ignore[arg-type]
143 away_score=m.away_score, # type: ignore[arg-type]
144 )
145 for m in schedule
146 if m.is_played
147 ]
149 async def get_rpi(self, event_id: int, flight_id: int, tie_weight: float = 1 / 3) -> list[TeamRPI]:
150 """Return the RPI ranking for every team in a flight.
152 Args:
153 event_id: The event the flight belongs to.
154 flight_id: The flight (conference tier) to rate.
155 tie_weight: WP tie weight — 1/3 (2024 convention) or 1/2 (pre-2024).
157 The rating pool is the flight: opponents and their opponents are taken
158 from the flight's own completed games. The computed table is memoized
159 (see ``_rpi_table``), so repeat calls within the TTL are free.
160 """
161 return await self._rpi_table(event_id, flight_id, tie_weight)
163 async def get_team_rpi(self, event_id: int, flight_id: int, team: str, tie_weight: float = 1 / 3) -> TeamRPI:
164 """Return one team's RPI with its component breakdown.
166 Reuses the memoized flight table — OWP/OOWP need the whole graph, so the
167 table is computed once and shared rather than rebuilt per team.
169 Args:
170 team: Team name (case-insensitive substring match against the flight's teams).
172 Raises:
173 ECNLNotFoundError: If no team in the flight matches ``team``.
174 """
175 table = await self._rpi_table(event_id, flight_id, tie_weight)
176 needle = team.strip().lower()
177 for row in table:
178 if needle in row.team.lower():
179 return row
180 raise ECNLNotFoundError(f"No team matching {team!r} in flight {flight_id}")
182 async def _rpi_table(self, event_id: int, flight_id: int, tie_weight: float) -> list[TeamRPI]:
183 """Return the (memoized) RPI table for a flight.
185 The table is a pure transform of the flight schedule, which the outbound
186 cache already keeps for the same lifetime; memoizing the transform avoids
187 recomputing it across get_rpi/get_team_rpi calls. Bounded by an LRU cap
188 and a TTL, so the cache cannot grow without limit.
189 """
190 key = (event_id, flight_id, tie_weight)
191 cached = self._rpi_cache.get(key)
192 if cached is not None and self._now() < cached[0]:
193 self._rpi_cache.move_to_end(key)
194 return cached[1]
196 results = await self.get_results(event_id, flight_id)
197 table = compute_rpi(results, wp_tie_weight=tie_weight)
198 self._rpi_cache[key] = (self._now() + self._rpi_ttl, table)
199 self._rpi_cache.move_to_end(key)
200 if len(self._rpi_cache) > self._rpi_cache_size:
201 self._rpi_cache.popitem(last=False)
202 return table