mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06:36 +00:00
feat(common): sports_rotation -- the reconciled other-games rotation (sports family 7) (#786)
* feat(common): sports_rotation -- the reconciled other-games rotation (sports family 7) New hardware-free module src/common/sports_rotation.py, copied from ledmatrix-plugins claude/family7-reconcile once the nine scoreboards made _by_importance, _other_games_window, _advance_other_games_if_due (two bodies each), _rotate_other_games_on_display (two) and _attach_odds_to_rotated_games (three; ufc had none) one body each. All of them live on the plugins' SportsCore, so one mixin, SportsRotationMixin, carries exactly those five plus the default _rankings_loaded, and no manager gains a method it lacks today. - The window advances under _games_lock: update() and display() both advance it, and interleaved, each added a width and skipped a window. - The display path's due-check reads the pool _compose_selection will cut, unfiltered fallback included. - Rotated-in games get odds when show_odds is on (decided 2026-10-09: ufc too), skipped without an odds manager. - _rankings_loaded() is the seam _by_importance asks: the abbreviation table by default; football overrides it to count its by-id rankings. - test/test_sports_rotation.py: the plugins' pinned cases (importance order per rankings table and the seam, the window over time, the display-path rotation incl. the card on screen, redraws and recompose counts, update() and display() in sequence and interleaved on two threads -- with and without the lock -- and rotated-in odds), plus the host contract. - test/test_sports_rotation_parity.py: with LEDMATRIX_PLUGINS, compares each body with every plugin copy (drift-report normalisation plus decorators), checks no other plugin class carries a copy, and that only football overrides _rankings_loaded. Passes against claude/family7-reconcile; fails against the pre-reconcile tree, as it should. - mypy ratchet, src/common/README.md, CHANGELOG (Unreleased, New modules). - sports_shared docstring: _by_importance and _other_games_window (and the long-promoted _is_favorite_game) leave the "stay per-plugin" list. - docs/SPORTS_UNIFICATION.md: family 7 status, decisions, the _rankings_loaded seam; families 5 and 6 marked done and adopted (3.8.1 / ledmatrix-plugins #631, 3.8.2 / #637) instead of "adoption waits". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(sports): stage 4 is adopted (core 3.8.0, ledmatrix-plugins #594) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: cite ledmatrix-plugins #641 for the family 7 reconcile Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ Rules for the package:
|
||||
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
|
||||
| [`sports_plugin_host`](#sports_plugin_host) | Helpers of a scoreboard's plugin class (`manager.py`) | Yes (scoreboards) | 3.8.0 |
|
||||
| [`sports_rotation`](#sports_rotation) | Which non-favourite games a scoreboard shows, and when the slice moves | Yes (scoreboards) | next release |
|
||||
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
||||
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
|
||||
@@ -368,6 +369,21 @@ shape), `_dispatch_switch_refresh()` (a manager refresh on a daemon thread, so
|
||||
`display()` never waits on the network), `get_vegas_content_type()` and small
|
||||
dynamic-duration helpers. List it before `BasePlugin`.
|
||||
|
||||
### sports_rotation
|
||||
|
||||
[`sports_rotation.py`](sports_rotation.py). Sports family 7:
|
||||
`SportsRotationMixin` (`SportsCore`), the other-games rotation.
|
||||
`_by_importance` orders the non-favourite pool best matchup first, one game per
|
||||
team, when `_rankings_loaded()` says a poll loaded (football overrides that to
|
||||
count its by-id rankings). `_other_games_window` cuts the slice on screen,
|
||||
advancing by its width every `other_rotation_interval_seconds` under
|
||||
`_games_lock`, catching up on missed intervals and wrapping.
|
||||
`_rotate_other_games_on_display` (with `_advance_other_games_if_due`) re-cuts
|
||||
it from `display()` between fetches, looking at the pool `_compose_selection`
|
||||
will cut, unfiltered fallback included, and keeps the card on screen when it
|
||||
survives; `_attach_odds_to_rotated_games` fetches odds for the games it brought
|
||||
in when `show_odds` is on.
|
||||
|
||||
### sports_scroll
|
||||
|
||||
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Which non-favourite games a scoreboard shows, and when the slice moves (sports family 7).
|
||||
|
||||
The scoreboards' other-games rotation, reconciled in ledmatrix-plugins
|
||||
(family 7) from two bodies each into one, and copied here under the existing
|
||||
names. All of it lives on the plugins' ``SportsCore``, so there is one mixin,
|
||||
``SportsRotationMixin``:
|
||||
|
||||
- ``_by_importance(games, newest_first)``: the non-favourite pool, best
|
||||
matchup first and one game per team, when a poll has loaded; kickoff order
|
||||
otherwise. ``SportsCoreSharedMixin._favorites_first`` asks it for both the
|
||||
filtered and the unfiltered pool.
|
||||
- ``_other_games_window(others, limit)``: the slice of a pool on screen now.
|
||||
It advances by its own width every ``other_rotation_interval_seconds``
|
||||
(catching up on intervals that passed unseen) and wraps.
|
||||
``SportsCoreSharedMixin._compose_selection`` cuts with it.
|
||||
- ``_advance_other_games_if_due`` and ``_rotate_other_games_on_display``:
|
||||
the display path's re-cut between fetches. The plugins' ``display()`` calls
|
||||
``_rotate_other_games_on_display`` before its dwell check; the card on
|
||||
screen keeps its place if it survived the cut.
|
||||
- ``_attach_odds_to_rotated_games``: odds for the games a rotation
|
||||
brought in, on a daemon thread, when ``show_odds`` is on and there is an
|
||||
odds manager.
|
||||
- ``_rankings_loaded()``: the override point (below).
|
||||
|
||||
THE RULES
|
||||
---------
|
||||
``update()`` (through ``_favorites_first``) and ``display()`` (through
|
||||
``_rotate_other_games_on_display``) both advance the window, so the advance
|
||||
holds ``_games_lock`` (the plugins' RLock): interleaved without it, both saw
|
||||
the interval elapse and each added a width, skipping a window nobody saw.
|
||||
|
||||
The display path's due-check looks at the pool ``_compose_selection`` will
|
||||
actually cut from: the filtered others, or, when nothing survived at all (no
|
||||
favourite to show either), the unfiltered fallback. Guessing the unfiltered
|
||||
pool whenever the others were empty recomposed an identical list on every
|
||||
frame while a favourite was playing; never looking at it left the fallback
|
||||
moving only when ``update()`` ran.
|
||||
|
||||
Rotated-in games get odds by the same rule as the games ``update()`` picks:
|
||||
``show_odds`` on (decided 2026-10-09, which brought ufc-scoreboard in line).
|
||||
|
||||
OVERRIDE POINT
|
||||
--------------
|
||||
``_rankings_loaded()`` -- did a poll load? ``_by_importance`` keeps kickoff
|
||||
order when not. The default is ``_team_rankings_cache`` being non-empty.
|
||||
football-scoreboard overrides it to count its rankings keyed by ESPN team id
|
||||
(``_ranked_team_ids``) as well, the table its ``_best_rank`` reads first.
|
||||
|
||||
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_rotation.py`` fails if a read is added without being
|
||||
listed here. Every scoreboard ``SportsCore`` supplies all of them.
|
||||
|
||||
- ``other_rotation_interval_seconds`` -- seconds per window; 0 pins it.
|
||||
- ``_other_window_start`` and ``_other_window_rotated_at`` -- the window's
|
||||
position and the monotonic time it last moved (0: never cut). Written here.
|
||||
- ``_games_lock`` -- the re-entrant lock around ``games_list``.
|
||||
- ``_selection_pools`` -- what ``_favorites_first`` settled (``favorites``,
|
||||
``others``, ``unfiltered``, ``favorite_limit``, ``other_limit``); read with
|
||||
getattr, so a manager that never built it does not rotate.
|
||||
- ``_compose_selection`` -- from ``SportsCoreSharedMixin``.
|
||||
- ``_best_rank`` -- a game's better poll position, 99 if neither side ranks
|
||||
(family 8, still per-plugin).
|
||||
- ``_team_rankings_cache`` -- read with getattr by the default
|
||||
``_rankings_loaded``.
|
||||
- ``games_list``, ``current_game``, ``current_game_index``,
|
||||
``last_game_switch`` -- the switch-mode state a rotation swaps.
|
||||
- ``logger`` -- one INFO line per rotation.
|
||||
- ``show_odds`` and ``odds_manager`` (read with getattr), ``mode_config``
|
||||
(``odds_update_interval``), ``sport``, ``league`` and ``sport_key`` -- the
|
||||
rotated-in odds fetch.
|
||||
|
||||
The methods read the game dict's ``id``, ``start_time_utc``, ``home_abbr``,
|
||||
``away_abbr`` and ``odds``; any may be missing.
|
||||
|
||||
BASE ORDER
|
||||
----------
|
||||
No other mixin defines these methods, so the position in the bases does not
|
||||
change which body runs; a method on the plugin's own class (football's
|
||||
``_rankings_loaded``) still wins. The mixin has no ``__init__``; the state it
|
||||
writes is the host's.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
|
||||
class SportsRotationMixin:
|
||||
"""``SportsCore``'s other-games rotation. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only.
|
||||
other_rotation_interval_seconds: int
|
||||
_other_window_start: int
|
||||
_other_window_rotated_at: float
|
||||
_games_lock: Any
|
||||
_compose_selection: Callable[[], List[Dict]]
|
||||
_best_rank: Callable[[Dict], int]
|
||||
games_list: List[Dict]
|
||||
current_game: Optional[Dict]
|
||||
current_game_index: int
|
||||
last_game_switch: float
|
||||
logger: Any
|
||||
odds_manager: Any
|
||||
mode_config: Dict[str, Any]
|
||||
sport: str
|
||||
league: str
|
||||
sport_key: str
|
||||
|
||||
def _rankings_loaded(self) -> bool:
|
||||
"""Did a poll load at all? The ranking reads fail open when not.
|
||||
|
||||
The seam ``_by_importance`` asks before ordering by rank: by default
|
||||
the abbreviation table (``_team_rankings_cache``). football-scoreboard
|
||||
overrides it to count its table keyed by ESPN team id as well.
|
||||
"""
|
||||
return bool(getattr(self, "_team_rankings_cache", None))
|
||||
|
||||
def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]:
|
||||
"""Non-favourite games, best matchup first.
|
||||
|
||||
The quality filter already declares the poll to be the thing worth
|
||||
showing -- and then selection ignored the number entirely. #1 against #2
|
||||
and #25 against an unranked side were interchangeable, and whichever
|
||||
kicked off sooner took the slot, so the biggest game of the week had no
|
||||
better chance of being seen than any other.
|
||||
|
||||
The rotation still walks the entire pool, so nothing is lost and
|
||||
coverage is unchanged; it now walks DOWN the ladder instead of along the
|
||||
clock. The first window after a restart holds the best games available
|
||||
rather than the earliest ones, which is the case that matters -- a board
|
||||
is far more often freshly started or freshly updated than three hours
|
||||
into a lap.
|
||||
|
||||
Ties fall back to kickoff order, and a league with no poll keeps the
|
||||
chronological order it had, because there is nothing to sort on.
|
||||
|
||||
One game per team, which is the part rank ordering cannot do without.
|
||||
The upcoming pool is not a week of fixtures -- for college football it
|
||||
is the whole season, 947 games on a real board -- so ordering by rank
|
||||
alone put all twelve of the #1 team's games above the #2 team's first
|
||||
one, and the board walked one team's season. Measured on ledpi the
|
||||
moment this shipped: KENT@OSU, ILL@OSU, then OSU@IOWA, MD@OSU. Keeping
|
||||
only the soonest game per team makes the pool "what each team has
|
||||
next", which is both what an upcoming board means and inherently
|
||||
near-term, since a team's next game is by definition the closest one.
|
||||
"""
|
||||
if not self._rankings_loaded():
|
||||
return games
|
||||
if newest_first:
|
||||
def key(game):
|
||||
when = game.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc)
|
||||
return (self._best_rank(game), -when.timestamp())
|
||||
else:
|
||||
def key(game):
|
||||
when = game.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc)
|
||||
return (self._best_rank(game), when.timestamp())
|
||||
|
||||
# Soonest-first so "one per team" keeps each team's NEXT game, then
|
||||
# re-ordered by rank. Doing it the other way round would keep whichever
|
||||
# of a team's games happened to sort first by rank, which for a game
|
||||
# between two ranked sides is not necessarily the next one.
|
||||
soonest_first = sorted(
|
||||
games,
|
||||
key=lambda g: (g.get("start_time_utc")
|
||||
or datetime.max.replace(tzinfo=timezone.utc)).timestamp(),
|
||||
reverse=newest_first,
|
||||
)
|
||||
seen, once_each = set(), [] # type: ignore[var-annotated]
|
||||
for game in soonest_first:
|
||||
sides = (game.get("home_abbr"), game.get("away_abbr"))
|
||||
if any(side in seen for side in sides):
|
||||
continue
|
||||
seen.update(s for s in sides if s)
|
||||
once_each.append(game)
|
||||
return sorted(once_each, key=key)
|
||||
|
||||
def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]:
|
||||
"""A rotating slice of the non-favourite games.
|
||||
|
||||
The window advances by its own width, so consecutive windows are
|
||||
disjoint and the board walks the schedule rather than resampling the
|
||||
same front of it. It wraps, so a short list still cycles.
|
||||
|
||||
Advancing is time-based, not per-update. update() runs every 30s; if
|
||||
the window moved with it the games list would change identity on every
|
||||
pass, reset the display index, and no card past the first would ever be
|
||||
reached.
|
||||
"""
|
||||
if limit <= 0 or not others:
|
||||
return []
|
||||
if len(others) <= limit:
|
||||
return others[:limit]
|
||||
|
||||
interval = self.other_rotation_interval_seconds
|
||||
# Under the lock: update() advances this window through
|
||||
# _favorites_first, and display() advances it through
|
||||
# _rotate_other_games_on_display, so the read-modify-write below has two
|
||||
# writers. Interleaved, both can see the interval elapsed and each add a
|
||||
# width, skipping a window of games nobody ever sees. _games_lock is an
|
||||
# RLock and the display path takes it again straight after, which is
|
||||
# why this can be the same lock rather than another one to reason about.
|
||||
with self._games_lock:
|
||||
if interval > 0:
|
||||
now = time.monotonic()
|
||||
if not self._other_window_rotated_at:
|
||||
self._other_window_rotated_at = now
|
||||
elapsed = now - self._other_window_rotated_at
|
||||
if elapsed >= interval:
|
||||
# Advance by however many intervals actually passed. The
|
||||
# board is not guaranteed to be running -- or this mode
|
||||
# displayed -- for every one of them, and stepping once
|
||||
# would let a plugin that sat idle crawl a step at a time.
|
||||
steps = int(elapsed // interval)
|
||||
self._other_window_start += steps * limit
|
||||
self._other_window_rotated_at = now
|
||||
|
||||
start = self._other_window_start % len(others)
|
||||
window = others[start:start + limit]
|
||||
if len(window) < limit:
|
||||
window += others[:limit - len(window)]
|
||||
return window
|
||||
|
||||
def _rotate_other_games_on_display(self) -> bool:
|
||||
"""Swap in a freshly cut slice when the rotation interval has passed.
|
||||
|
||||
Returns True when the list changed, so the caller forces a redraw.
|
||||
|
||||
The card currently on screen keeps its place if it survived the cut:
|
||||
rotating the pool should change what comes NEXT, not interrupt whatever
|
||||
someone is reading. Only when it is gone does the index reset, and then
|
||||
the dwell resets with it so the replacement gets a full turn rather than
|
||||
the tail of its predecessor's.
|
||||
"""
|
||||
rebuilt = self._advance_other_games_if_due()
|
||||
if not rebuilt:
|
||||
return False
|
||||
with self._games_lock:
|
||||
if [g.get("id") for g in rebuilt] == [g.get("id") for g in self.games_list]:
|
||||
return False
|
||||
current_id = (self.current_game or {}).get("id")
|
||||
self.games_list = rebuilt
|
||||
for index, game in enumerate(rebuilt):
|
||||
if game.get("id") == current_id:
|
||||
self.current_game_index = index
|
||||
self.current_game = game
|
||||
break
|
||||
else:
|
||||
self.current_game_index = 0
|
||||
self.current_game = rebuilt[0]
|
||||
self.last_game_switch = time.time()
|
||||
self.logger.info(
|
||||
"Rotated the other-games slice to: %s",
|
||||
", ".join("%s@%s" % (g.get("away_abbr"), g.get("home_abbr"))
|
||||
for g in rebuilt),
|
||||
)
|
||||
self._attach_odds_to_rotated_games(rebuilt)
|
||||
return True
|
||||
|
||||
def _attach_odds_to_rotated_games(self, games: List[Dict]) -> None:
|
||||
"""Fetch odds for freshly rotated-in games off the display path.
|
||||
|
||||
The rotation deliberately does no network work, but odds are only
|
||||
attached in update(), and for an upcoming list that runs hourly --
|
||||
far longer than any rotated-in card stays on screen. Every slice cut
|
||||
between updates therefore rendered without a line even though ESPN
|
||||
had one, while the favourites, which survive every cut, kept the
|
||||
odds update() gave them.
|
||||
|
||||
One daemon thread per rotation, bounded by the slice size rather
|
||||
than the pool's: only games actually going on screen are asked
|
||||
about, and get_odds caches per game, so one re-entering the window
|
||||
inside its TTL costs a cache lookup rather than a request. The
|
||||
thread mutates each game dict in place; the renderer re-reads
|
||||
game["odds"] every frame, so a line appears as soon as its fetch
|
||||
lands, mid-dwell included. Same as football-scoreboard #343.
|
||||
"""
|
||||
# getattr: managers are built partially in places (the plugin tests
|
||||
# among them) that never set show_odds or an odds manager.
|
||||
if not getattr(self, "show_odds", False) or not getattr(self, "odds_manager", None):
|
||||
return
|
||||
pending = [g for g in games if not g.get("odds")]
|
||||
if not pending:
|
||||
return
|
||||
interval = self.mode_config.get("odds_update_interval", 3600)
|
||||
|
||||
def fetch() -> None:
|
||||
for game in pending:
|
||||
try:
|
||||
odds = self.odds_manager.get_odds(
|
||||
sport=self.sport,
|
||||
league=self.league,
|
||||
event_id=game["id"],
|
||||
update_interval_seconds=interval,
|
||||
)
|
||||
if odds:
|
||||
game["odds"] = odds
|
||||
except Exception as exc:
|
||||
self.logger.debug(
|
||||
"Odds fetch for rotated-in game %s failed: %s",
|
||||
game.get("id"), exc)
|
||||
|
||||
threading.Thread(
|
||||
target=fetch, daemon=True,
|
||||
name="%s-rotated-odds" % self.sport_key).start()
|
||||
|
||||
def _advance_other_games_if_due(self) -> List[Dict]:
|
||||
"""Re-cut the non-favourite slice on the display path, or [] if not due.
|
||||
|
||||
Costs one list slice and a sort of at most a few games -- no fetch, no
|
||||
parsing, no network. Returns the new list rather than assigning it,
|
||||
because the two callers keep different bookkeeping around games_list
|
||||
and both hold their own lock while they swap it in.
|
||||
"""
|
||||
pools = getattr(self, "_selection_pools", None)
|
||||
if not pools:
|
||||
return []
|
||||
interval = self.other_rotation_interval_seconds
|
||||
limit = max(0, pools["other_limit"])
|
||||
# Whichever pool _compose_selection will actually slice. It falls back
|
||||
# to the unfiltered list only when NOTHING survived -- favourites
|
||||
# included. With a favourite playing and the filters rejecting every
|
||||
# other game, compose keeps the favourites-only list, so guessing the
|
||||
# unfiltered pool here made the due-check fire on every display() call
|
||||
# forever, recomposing an identical list each frame.
|
||||
others = pools["others"]
|
||||
favorites_fill = pools["favorites"] and pools["favorite_limit"] > 0
|
||||
if not others and limit > 0 and not favorites_fill:
|
||||
others = pools["unfiltered"]
|
||||
if interval <= 0 or limit <= 0 or len(others) <= limit:
|
||||
return [] # pinned, favourites-only, or nothing to rotate through
|
||||
if not self._other_window_rotated_at:
|
||||
return [] # no window has been cut yet; update() does the first
|
||||
if time.monotonic() - self._other_window_rotated_at < interval:
|
||||
return []
|
||||
return self._compose_selection()
|
||||
@@ -54,12 +54,14 @@ State: ``config``, ``fonts``, ``logger``, ``display_width``, ``display_height``,
|
||||
|
||||
Methods that stay per-plugin, because they are not identical across the eight
|
||||
(or, for ``_get_timezone``, because they bind per-plugin modules):
|
||||
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
|
||||
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
|
||||
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
|
||||
``_is_ranked_game``, ``_passes_other_filters``. (``_is_game_really_over``,
|
||||
which ``_detect_stale_games`` below calls, was here too until the plugins
|
||||
reconciled it; it is now ``src.common.sports_game_over``.)
|
||||
``_get_layout_offset``, ``_upcoming_date_and_time_text``,
|
||||
``_extract_game_details_common``, ``_load_division_team_ids``,
|
||||
``_get_timezone``, ``_is_ranked_game``, ``_passes_other_filters``. Three
|
||||
families were here until the plugins reconciled them: ``_is_game_really_over``,
|
||||
which ``_detect_stale_games`` below calls, is now ``src.common.sports_game_over``;
|
||||
``_is_favorite_game`` is ``src.common.sports_favorites``; and
|
||||
``_by_importance`` and ``_other_games_window``, which ``_favorites_first`` and
|
||||
``_compose_selection`` below call, are ``src.common.sports_rotation``.
|
||||
|
||||
Of the fourteen shared class constants, thirteen are identical everywhere and
|
||||
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
|
||||
|
||||
Reference in New Issue
Block a user