mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 02:26:37 +00:00
* fix(sports_rankings): skip a malformed poll entry; rank each side by its own id The two review points deferred on #789 until sports_rankings was the only copy (the nine scoreboards adopted it in ledmatrix-plugins #648): - A poll entry whose "current" is None or a string raised TypeError at `current_rank <= 0`; the broad except returned {} without setting the cache timestamp, so every team lost its rank and the standings were re-fetched on every update. The entry is now skipped. - _ranked_positions() fell back to abbreviations for BOTH sides when only one side's id was unparseable, so a colliding abbreviation could rank the wrong school. It now decides id-or-abbreviation per side. Well-formed ESPN data (ids on both sides, int ranks) ranks as before. SPORTS_UNIFICATION.md: family 8 marked done and adopted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): a numeric-string rank is still accepted; say which values are skipped Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
419 lines
20 KiB
Python
419 lines
20 KiB
Python
"""Which teams are ranked, and in which poll (sports family 8).
|
|
|
|
The scoreboards' rankings code, reconciled in ledmatrix-plugins (family 8)
|
|
from three, three and two bodies into one each, and copied here under the
|
|
existing names. All of it lives on the plugins' ``SportsCore``, so there is
|
|
one mixin, ``SportsRankingsMixin``:
|
|
|
|
- ``_fetch_team_rankings()``: the top-division poll from the data source,
|
|
hourly, as ``_team_rankings_cache`` ({abbreviation: rank}, what the rank
|
|
badge draws) and ``_ranked_team_ids`` ({ESPN team id: rank}). Only for a
|
|
league ``_league_has_rankings()`` says publishes a poll.
|
|
- ``_choose_poll(rankings_data)``: the first block ESPN lists that is a
|
|
top-division poll; seedings and the polls of lower divisions are stepped
|
|
over, ESPN's order is otherwise kept (so the CFP rankings take over from AP
|
|
when ESPN fronts them). ``choose_top_division_poll`` below is the same rule
|
|
for code with no scoreboard to hand (the dynamic team resolvers).
|
|
- ``_poll_season_ended(poll, now)``: whether the poll's season is over.
|
|
- ``_load_division_team_ids()``: team ids per college football division
|
|
(FBS, FCS) from ESPN's group rosters, for the season year.
|
|
- ``_ranked_positions(game)``, ``_is_ranked_game(game)``, ``_best_rank(game)``:
|
|
the poll positions either side holds, by team id first, abbreviation second.
|
|
- ``_passes_other_filters(game)``: whether a non-favourite game is worth a
|
|
slot under ``other_games_min_quality`` and ``other_games_divisions``.
|
|
|
|
THE RULES (decided 2026-10-12)
|
|
------------------------------
|
|
Ranks are kept by ESPN team id as well as by abbreviation, and a game with ids
|
|
is ranked by id: abbreviations are not unique (ESPN lists Ohio State and Ohio
|
|
State Newark both as OSU) and an id cannot rank the wrong school. The badge
|
|
still draws from the abbreviation table.
|
|
|
|
A poll whose season has ended loads nothing. ESPN keeps serving the last poll
|
|
it published until the next one (on 2026-10-12 college basketball's /rankings
|
|
was still April's final poll, college lacrosse's May's), and every block names
|
|
its season with an ``endDate``. Past it the table stays empty, so no badge
|
|
shows and the "ranked" filter fails open, until the new season's first poll
|
|
arrives. Read from the payload, not a calendar, so no league needs its own
|
|
season dates; a block without a readable ``endDate`` counts as current.
|
|
|
|
The division rosters are asked for the season year (July onward is this
|
|
year's season): college football's season runs into January, and the next
|
|
season's groups do not exist then. Only college football has division
|
|
rosters (``_DIVISION_GROUPS_BY_LEAGUE``), so this is football's rule as is,
|
|
not an override point.
|
|
|
|
Everything fails open: no poll, an empty roster or a failed request allows
|
|
every game rather than blanking the board.
|
|
|
|
The data source decides the endpoint (``data_source.fetch_standings``, still
|
|
per plugin); every scoreboard asks /rankings first for a college or NCAA
|
|
league. A standings table is never turned into ranks.
|
|
|
|
A new module rather than more methods on ``sports_shared``, for the reason
|
|
``sports_helpers`` gives: a missing module fails at load, where the version
|
|
checks see it; a missing method fails mid-update.
|
|
|
|
WHAT A HOST MUST PROVIDE
|
|
------------------------
|
|
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
|
test in ``test/test_sports_rankings.py`` fails if a read is added without being
|
|
listed here. Every scoreboard ``SportsCore`` supplies all of them.
|
|
|
|
- ``logger``, ``sport``, ``league``.
|
|
- ``data_source`` -- ``fetch_standings(sport, league)`` returns the payload
|
|
(``{}`` on failure).
|
|
- ``session`` -- ``get(url, params=..., timeout=...)`` for the division
|
|
rosters; ``cache_manager`` (may be ``None``) -- ``get(key)`` and
|
|
``set(key, value, ttl=...)`` keep them a day.
|
|
- ``_team_rankings_cache``, ``_ranked_team_ids`` and
|
|
``_rankings_cache_timestamp`` -- the tables and when they were fetched
|
|
(0: never). Written here. ``_rankings_cache_duration`` -- seconds between
|
|
fetches.
|
|
- ``_division_team_ids`` (``None``: never loaded) and ``_division_loaded_at``
|
|
-- the rosters and the monotonic time they were loaded. Written here.
|
|
- ``other_games_min_quality`` and ``other_games_divisions`` -- the filters.
|
|
- ``_league_has_rankings`` and ``_game_divisions`` -- from
|
|
``SportsCoreSharedMixin``.
|
|
- ``_rankings_loaded`` -- from ``SportsRotationMixin`` (football-scoreboard
|
|
overrides it to count the id table too).
|
|
|
|
The methods read the game dict's ``home_id``, ``away_id``, ``home_abbr`` and
|
|
``away_abbr``; any may be missing.
|
|
|
|
BASE ORDER
|
|
----------
|
|
No other mixin defines these names, so the position in the bases does not
|
|
change which body runs; a method on the plugin's own class still wins. The
|
|
mixin has no ``__init__``; the state it writes is the host's.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class SportsRankingsMixin:
|
|
"""``SportsCore``'s rankings. See module docstring."""
|
|
|
|
# The host contract, declared for type checking only.
|
|
logger: Any
|
|
sport: str
|
|
league: str
|
|
data_source: Any
|
|
session: Any
|
|
cache_manager: Any
|
|
_team_rankings_cache: Dict[str, int]
|
|
_ranked_team_ids: Dict[int, int]
|
|
_rankings_cache_timestamp: float
|
|
_rankings_cache_duration: float
|
|
_division_team_ids: Optional[Dict[str, set]]
|
|
_division_loaded_at: float
|
|
other_games_min_quality: str
|
|
other_games_divisions: Any
|
|
_league_has_rankings: Callable[[], bool]
|
|
_game_divisions: Callable[[Dict], Optional[set]]
|
|
_rankings_loaded: Callable[[], bool]
|
|
|
|
# Which ranking block the badge reads. ESPN answers /rankings with more
|
|
# than one block for several leagues, and the FIRST is not always a poll:
|
|
# men's and women's college hockey front "NCAA Men's/Women's Hockey
|
|
# Tournament Seedings", and college lacrosse publishes seedings beside its
|
|
# Inside Lacrosse poll. College football fronts the AP Top 25 but also
|
|
# carries the FCS and Division II polls and gains the CFP rankings in
|
|
# November. An EXCLUDE list, not an allow list, so a poll ESPN invents
|
|
# still counts; type first and name second, because the Division II block
|
|
# types as "afca" while the FBS coaches poll -- also an AFCA poll -- types
|
|
# as "usa".
|
|
_NON_TOP_POLL_TYPES: ClassVar[frozenset] = frozenset({"tournament", "fcs"})
|
|
_NON_TOP_POLL_NAMES: ClassVar[Tuple[str, ...]] = (
|
|
"tournament", "seedings", "fcs",
|
|
"division ii", "division iii", "div ii", "div iii",
|
|
)
|
|
|
|
# ESPN group ids for the college football divisions, from its own group
|
|
# rosters (disjoint: 148 FBS team ids, 130 FCS). Keyed by league because
|
|
# no other league publishes usable rosters: college baseball and lacrosse
|
|
# answer HTTP 500, college basketball and hockey an empty list.
|
|
_DIVISION_GROUPS_BY_LEAGUE: ClassVar[Dict[str, Dict[str, int]]] = {
|
|
"college-football": {"fbs": 80, "fcs": 81},
|
|
}
|
|
_DIVISION_CACHE_TTL: ClassVar[int] = 24 * 60 * 60
|
|
# A lookup that came back empty is retried on this shorter clock.
|
|
_DIVISION_RETRY_SECONDS: ClassVar[int] = 10 * 60
|
|
|
|
def _choose_poll(self, rankings_data: Optional[List[Dict]]) -> Dict:
|
|
"""The first block ESPN lists that is an actual top-division poll.
|
|
|
|
ESPN's own order is otherwise kept, so whichever poll it fronts is the
|
|
one that drives the badge.
|
|
"""
|
|
for block in rankings_data or []:
|
|
name = str(block.get("name") or "").lower()
|
|
kind = str(block.get("type") or "").lower()
|
|
if kind in self._NON_TOP_POLL_TYPES or any(
|
|
marker in name for marker in self._NON_TOP_POLL_NAMES
|
|
):
|
|
self.logger.debug(
|
|
"%s: skipping %s -- not a top-division poll",
|
|
getattr(self, "league", "?"), block.get("name") or kind)
|
|
continue
|
|
return block
|
|
return {}
|
|
|
|
def _fetch_team_rankings(self) -> Dict[str, int]:
|
|
"""Fetch team rankings using the new architecture components."""
|
|
current_time = time.time()
|
|
|
|
# No poll, no fetch. _league_has_rankings already gated the
|
|
# quality-filter call site; the show_ranking call sites did not, so
|
|
# ticking "Show Ranking" on a league that publishes no poll sent two
|
|
# requests an hour to endpoints that cannot answer. Gating here rather
|
|
# than at each call site covers every caller at once and cannot drift
|
|
# apart again.
|
|
#
|
|
# It is also the setting's only honest answer on those leagues: the
|
|
# rank badge replaces the record, so an empty table meant "Show
|
|
# Ranking" quietly erased the records "Show Records" was drawing. The
|
|
# schema now hides the toggle wherever the league has no poll, but a
|
|
# config saved before that still carries it.
|
|
if not self._league_has_rankings():
|
|
return self._team_rankings_cache
|
|
|
|
# Check if we have cached rankings that are still valid
|
|
# Gate on when the last look happened, not on whether it found
|
|
# anything. Professional leagues publish no poll, so `rankings`
|
|
# comes back empty -- and an empty dict is falsy, so this guard
|
|
# never short-circuited and the standings endpoint was re-fetched
|
|
# on every update instead of hourly. Measured 2026-09-19 on an
|
|
# MLB-only rig: 453 standings requests in a day against the 24
|
|
# the one-hour duration intends. An empty result is a result.
|
|
if (
|
|
self._rankings_cache_timestamp
|
|
and current_time - self._rankings_cache_timestamp
|
|
< self._rankings_cache_duration
|
|
):
|
|
return self._team_rankings_cache
|
|
|
|
try:
|
|
data = self.data_source.fetch_standings(self.sport, self.league)
|
|
|
|
rankings: Dict[str, int] = {}
|
|
ranked_ids: Dict[int, int] = {}
|
|
poll = self._choose_poll(data.get("rankings", []))
|
|
if self._poll_season_ended(poll, current_time):
|
|
self.logger.info(
|
|
"%s: %s is from a season that has ended; no rank badges "
|
|
"until the new season's first poll", self.league,
|
|
poll.get("name") or "the poll",
|
|
)
|
|
poll = {}
|
|
|
|
for team_data in poll.get("ranks", []):
|
|
team_info = team_data.get("team", {})
|
|
team_abbr = team_info.get("abbreviation", "")
|
|
# One malformed entry (a None or non-numeric "current") is
|
|
# skipped, not allowed to throw away the whole poll.
|
|
try:
|
|
current_rank = int(team_data.get("current"))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if current_rank <= 0:
|
|
continue
|
|
if team_abbr:
|
|
rankings[team_abbr] = current_rank
|
|
# Kept by id as well as by abbreviation. The badge draws the
|
|
# abbreviation, but abbreviations are not unique across
|
|
# divisions and the FBS and FCS schedules arrive in the SAME
|
|
# scoreboard payload -- ESPN has SDSU for San Diego State and
|
|
# SDST for South Dakota State today, and nothing promises it
|
|
# stays that way. An id cannot rank the wrong school.
|
|
try:
|
|
ranked_ids[int(team_info.get("id"))] = current_rank
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
# Cache the results
|
|
self._team_rankings_cache = rankings
|
|
self._ranked_team_ids = ranked_ids
|
|
self._rankings_cache_timestamp = current_time
|
|
|
|
if rankings:
|
|
self.logger.info(
|
|
"%s: %d ranked teams loaded from %s", self.league,
|
|
len(rankings), poll.get("name") or "an unnamed poll",
|
|
)
|
|
else:
|
|
self.logger.debug("Fetched rankings for 0 teams")
|
|
return rankings
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error fetching team rankings: {e}")
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _poll_season_ended(poll: Dict, now: float) -> bool:
|
|
"""Is this poll from a season that is already over?
|
|
|
|
ESPN keeps serving the last poll it published until the next one
|
|
appears: in October, college basketball's /rankings is still April's
|
|
final poll and college lacrosse's is May's. Every block names its
|
|
season with an ``endDate``; once that has passed, the badge would rank
|
|
teams on a finished season, so the table stays empty until the new
|
|
season's first poll arrives. Asked of the data rather than a calendar,
|
|
so no league needs its own season dates. A block without a readable
|
|
``endDate`` counts as current, as before.
|
|
"""
|
|
season = poll.get("season")
|
|
end = str(season.get("endDate") or "") if isinstance(season, dict) else ""
|
|
try:
|
|
ended = datetime.strptime(end[:16], "%Y-%m-%dT%H:%M")
|
|
except ValueError:
|
|
return False
|
|
return now > ended.replace(tzinfo=timezone.utc).timestamp()
|
|
|
|
def _load_division_team_ids(self) -> Dict[str, set]:
|
|
"""Team ids per college division. Two requests a day, one league.
|
|
|
|
Returns empty sets on any failure -- the caller treats "unknown" as
|
|
"allowed", because a division lookup that fails must not blank the
|
|
board.
|
|
|
|
The in-memory copy expires like the stored one. Holding it for the life
|
|
of the process meant two things, both silent: a board that happened to
|
|
be offline for the first lookup had division filtering disabled until
|
|
someone restarted the service, which on a display running for weeks is
|
|
indefinitely; and a roster that changed between seasons was never
|
|
picked up. A failed lookup is retried sooner than a good one, so a
|
|
blip costs minutes rather than a day, without retrying per frame.
|
|
"""
|
|
now = time.monotonic()
|
|
if self._division_team_ids is not None:
|
|
resolved = any(self._division_team_ids.values())
|
|
age_limit = self._DIVISION_CACHE_TTL if resolved else self._DIVISION_RETRY_SECONDS
|
|
if now - self._division_loaded_at < age_limit:
|
|
return self._division_team_ids
|
|
self._division_team_ids = {}
|
|
self._division_loaded_at = now
|
|
groups = self._DIVISION_GROUPS_BY_LEAGUE.get((self.league or "").lower())
|
|
if not groups:
|
|
return self._division_team_ids # no divisions to speak of
|
|
for name, group in groups.items():
|
|
ids = set()
|
|
key = f"{self.league}_division_teams_{group}"
|
|
try:
|
|
cached = self.cache_manager.get(key) if self.cache_manager else None
|
|
if cached:
|
|
ids = {int(i) for i in cached}
|
|
else:
|
|
# The SEASON year, not the calendar year. College
|
|
# football's 2026 season runs into January 2027, and asking
|
|
# for season 2027 in January returns groups that do not
|
|
# exist yet: the roster comes back empty, division
|
|
# filtering fails open, and it does so through the bowls
|
|
# and the playoff -- the weeks anyone is watching.
|
|
today = datetime.now()
|
|
season = today.year if today.month >= 7 else today.year - 1
|
|
url = (
|
|
"https://sports.core.api.espn.com/v2/sports/"
|
|
f"{self.sport}/leagues/{self.league}/seasons/"
|
|
f"{season}/types/2/groups/{group}/teams"
|
|
)
|
|
resp = self.session.get(url, params={"limit": 300}, timeout=15)
|
|
resp.raise_for_status()
|
|
for item in resp.json().get("items", []):
|
|
found = re.search(r"/teams/(\d+)", item.get("$ref", ""))
|
|
if found:
|
|
ids.add(int(found.group(1)))
|
|
if ids and self.cache_manager:
|
|
self.cache_manager.set(
|
|
key, sorted(ids), ttl=self._DIVISION_CACHE_TTL
|
|
)
|
|
except Exception as exc:
|
|
self.logger.warning(
|
|
"Could not resolve %s teams for %s (%s); division filtering "
|
|
"will allow everything", name, self.league, exc
|
|
)
|
|
self._division_team_ids[name] = ids
|
|
return self._division_team_ids
|
|
|
|
def _ranked_positions(self, game: Dict) -> List[int]:
|
|
"""Poll positions held by either side: by id, else by abbreviation.
|
|
|
|
The id table is authoritative whenever a side carries an id and a poll
|
|
has been parsed, because it is built from a top-division poll alone --
|
|
so an FCS side cannot appear in it however it is abbreviated, and an
|
|
abbreviation shared across divisions cannot promote the wrong school.
|
|
Decided per side: a side without a usable id falls back to its
|
|
abbreviation, without costing the other side its id.
|
|
"""
|
|
by_id = getattr(self, "_ranked_team_ids", None) or {}
|
|
by_abbr = getattr(self, "_team_rankings_cache", None) or {}
|
|
found = []
|
|
for side in ("home", "away"):
|
|
rank = None
|
|
if by_id:
|
|
try:
|
|
rank = by_id.get(int(game.get(f"{side}_id")), 0) # type: ignore[arg-type] # None: caught below
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if rank is None:
|
|
rank = by_abbr.get(game.get(f"{side}_abbr"), 0)
|
|
if rank:
|
|
found.append(rank)
|
|
return found
|
|
|
|
def _is_ranked_game(self, game: Dict) -> bool:
|
|
return bool(self._ranked_positions(game))
|
|
|
|
def _best_rank(self, game: Dict) -> int:
|
|
"""The better of the two sides' poll positions, or 99 if neither ranks."""
|
|
found = self._ranked_positions(game)
|
|
return min(found) if found else 99
|
|
|
|
def _passes_other_filters(self, game: Dict) -> bool:
|
|
"""Is this non-favourite game worth one of the remaining slots?
|
|
|
|
"ranked" means ranked in the TOP division's poll -- see _choose_poll.
|
|
An FCS side ranked in its own poll does not qualify the game it is
|
|
playing in, which for college football is most often an FCS school
|
|
visiting an unranked FBS one.
|
|
|
|
Every check fails OPEN. If rankings could not be fetched or the
|
|
division rosters did not resolve, the game is allowed: a board showing
|
|
filler is a poor board, but a board showing nothing is a broken one.
|
|
"""
|
|
if self.other_games_min_quality == "ranked":
|
|
if self._rankings_loaded() and not self._is_ranked_game(game):
|
|
return False
|
|
|
|
wanted = self.other_games_divisions
|
|
if wanted:
|
|
present = self._game_divisions(game)
|
|
if present is not None and not (present & set(wanted)):
|
|
return False
|
|
return True
|
|
|
|
|
|
class _PollChooser(SportsRankingsMixin):
|
|
"""Just enough host for ``_choose_poll``."""
|
|
|
|
def __init__(self, logger: Any, league: str) -> None:
|
|
self.logger = logger
|
|
self.league = league
|
|
|
|
|
|
def choose_top_division_poll(rankings_data: Optional[List[Dict]], logger: Any = None,
|
|
label: Optional[str] = None) -> Dict:
|
|
"""``SportsRankingsMixin._choose_poll`` for code without a scoreboard.
|
|
|
|
The one rule, for the dynamic team resolvers that expand ``AP_TOP_25`` and
|
|
the like, so the badge and the favourites they resolve read the same poll.
|
|
``label`` names the league in the debug line for a skipped block.
|
|
"""
|
|
return _PollChooser(logger or _LOGGER, label or "?")._choose_poll(rankings_data)
|