Files
LEDMatrix/src/common/sports_vegas.py
T
ChuckandClaude Opus 5.5 a12be7c3c5 feat(sports): live Vegas cards for the scoreboards (shared layer) (#698)
* perf(timing): say which render-thread work a late frame followed

The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.

- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
  Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
  aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
  moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
  --patch-every / --patch-where (in-place column writes, as a live element
  update does) and --extend-every-screens / --extend-width (append + trim on
  a fixed cadence that holds the strip's width).

No runtime behaviour changes: this is the measurement gate for live Vegas
elements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note the frame-op attribution and bench modes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(scroll): build the strip's PIL image only when something reads it

Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.

cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.

In Vegas the image is now built only by a multi-display sync push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(vegas): live elements -- a plugin API for content that changes while it scrolls

Vegas bakes each plugin's pictures into one strip, so a card already on its
way across the panel keeps what it showed when it was drawn. This adds the
API and bookkeeping for content that can be updated in place; the worker
that redraws and swaps it follows separately. No shipped plugin implements
the hook yet, so nothing changes for users.

Plugin API (core 3.8.0), all no-ops by default:
- BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version,
  live, refresh_hz)]: named, fixed-width pieces of Vegas content.
- BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free
  redraw for content that changes with time.
- BasePlugin.notify_vegas_data_changed(): data that lands outside update().
- src/plugin_system/vegas_elements.py (VegasElement, re-exported from
  base_plugin).

Core:
- PluginAdapter asks a plugin that implements the hook for elements on the
  background fetch only (under its lock, on its own canvas); every other
  path keeps get_vegas_content(). Live elements are pinned (padded with
  content_padding, never trimmed), tagged with their key, digest and data
  epoch in Image.info so the existing cache and group plumbing carry them
  unchanged, and untagged if a width budget crops them.
- RenderPipeline records where each live element lands (ElementRecord), in
  absolute strip columns a trim does not move; the block-start arithmetic
  is shared with the STATIC markers.
- PluginManager update listeners (add/remove_update_listener,
  notify_data_changed): told the moment update() completes, not at the
  next ~4s Vegas poll. The coordinator uses one to move each plugin's data
  epoch on.
- vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval,
  live_lead_screens; per-plugin core-owned vegas_live. Live elements are
  off under multi-display sync, in swap mode and with offscreen_prefetch off.
- scripts/check_plugin.py checks the element contract
  (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub
  is a working example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(vegas): live elements update in place while they scroll

One background worker (src/vegas_mode/live_worker.py) redraws a plugin's
live elements when its data epoch moves on (update listener) or on their
refresh_hz, nearest the screen first, and hands changed pixels lock-free to
the render thread, which copies them into the strip between frames
(RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most
four patches or two screens of bytes a frame, no drawing or locks there.
The worker takes over group prefetch once a live element is placed, runs
inside the render gate, and is supervised. Update tick 1s while live
elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md
describes what was built and why SegmentStrip was not needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(sports): live Vegas cards for the scoreboards (shared layer)

One live element per game, drawn only when what the card shows changes, so
a score changes on a card already crossing the panel. The shared part, so
each scoreboard adopts it in a few lines:

- src/common/sports_vegas.py: game_key, game_fingerprint (the whole game
  dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache,
  StickyOdds (odds a live poll left out stay drawn), finished_games /
  with_finished_games (a game that just went final keeps its card, after its
  league's live games; one a heuristic only judged over keeps its live
  state, so a tied end of regulation never shows FINAL early).
- SportsScrollDisplay.make_vegas_renderer() is the override point;
  build_vegas_elements() and SportsScrollDisplayManager
  .get_vegas_elements_for() do the rest. A card's version includes its
  teams' ranks, which the renderer draws from the rankings cache.
- SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot():
  held for FINISHED_GAME_TTL after it leaves the live list.

A sport that does not implement make_vegas_renderer keeps its ordinary Vegas
content, so no scoreboard changes until it opts in.

scripts/render_plugin.py --vegas renders a plugin's Vegas block as the
ticker lays it out, and --timeline stacks it at successive moments as
the ticker would update it in place; the join is now
render_pipeline.join_plugin_rows().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(sports): a default _determine_game_type on SportsScrollDisplay

render_vegas_card looked the method up with getattr and a None default, which
static analysis (Codacy) reports as calling something that may not be
callable. The base class now has the default -- the card type from the game's
state -- and the plugins that define their own override it as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: review follow-ups on the shared live-card layer

- The reused Vegas renderer always gets the current rankings, empty
  included, so ranks cleared since are not kept drawn.
- render_plugin.py: --timeline refuses --no-live (a timeline shows live
  elements changing), --timeline/--no-live need --vegas, and the Vegas
  paths create the output's directory like the display path does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:32:07 -04:00

251 lines
10 KiB
Python

"""Live Vegas cards for the sports scoreboards.
A scoreboard hands the Vegas ticker one card per game. As live elements
(src/plugin_system/vegas_elements.py) those cards change on the panel while
they scroll: a goal redraws its game's card and the ticker swaps it in place.
This module is what every scoreboard needs for that and would otherwise write
nine times:
- :func:`game_key` -- a stable key per game, so the ticker can tell which card
a redraw belongs to however the slate is re-sorted.
- :class:`VegasCardCache` -- draws a card only when what it shows changed
(its fingerprint), so an unchanged slate costs a dictionary lookup per game
and a changed one only the cards that changed.
- :class:`StickyOdds` -- live odds are fetched only for games near the front
of the rotation, so a card's odds come and go between polls; this keeps the
last odds for a while instead of redrawing the card without them.
- :func:`dedupe_games` -- a game present in two managers' lists (live and
recent, around the final whistle) appears once, its liveliest copy.
- :func:`finished_games` / :func:`with_finished_games` -- a game that has just
gone final keeps its card, now showing FINAL, where its live card was,
until the recent list (refreshed about hourly) takes it over.
- :func:`game_fingerprint` -- what a card is redrawn on by default: the whole
game dict, frozen hashable.
SportsScrollDisplay.build_vegas_elements (src/common/sports_scroll.py) puts
them together; a plugin adopts it by implementing make_vegas_renderer().
"""
from __future__ import annotations
import time
from collections import OrderedDict
from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Tuple
from PIL import Image
#: Which copy of a duplicated game wins: the liveliest.
_STATE_PRIORITY = {'in': 3, 'post': 2, 'pre': 1}
def _state(game: Dict[str, Any]) -> str:
status = game.get('status')
state = status.get('state') if isinstance(status, dict) else status
if isinstance(state, str):
return state
if game.get('is_live'):
return 'in'
if game.get('is_final'):
return 'post'
return 'pre'
def _freeze(value: Any) -> Any:
"""A hashable, order-stable copy of feed data."""
if isinstance(value, dict):
return tuple(sorted((str(k), _freeze(v)) for k, v in value.items()))
if isinstance(value, (list, tuple)):
return tuple(_freeze(v) for v in value)
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return repr(value)
def game_fingerprint(game: Dict[str, Any]) -> Hashable:
"""Everything in a game dict, hashable: a card drawn from it changes only if this does.
The default card version. Nothing a card could draw is left out, so no
field is ever frozen on the panel; the cost is a redraw when a field the
card does not draw changes too, which feed data rarely does between polls.
"""
frozen: Hashable = _freeze(game)
return frozen
def game_key(game: Dict[str, Any]) -> str:
"""A key that names this game and nothing else, across polls.
``game:<league>:<id>`` from the feed's own id. A game without one falls
back to its teams and start time, which is stable for the life of a game.
"""
league = game.get('league') or 'game'
game_id = game.get('id') or game.get('game_id')
if game_id not in (None, ''):
return f"game:{league}:{game_id}"
away = game.get('away_abbr') or game.get('away_team') or '?'
home = game.get('home_abbr') or game.get('home_team') or '?'
start = game.get('start_time_utc') or game.get('start_time') or ''
return f"game:{league}:{away}@{home}:{start}"
def dedupe_games(games: Iterable[Dict[str, Any]],
key_fn: Callable[[Dict[str, Any]], str] = game_key) -> List[Dict[str, Any]]:
"""Each game once, in first-seen order, keeping its liveliest copy.
Around a final whistle a game can be in the live list (last poll) and the
recent list (next poll) at once; two cards with one key would be refused
by the ticker, and showing the game twice is wrong anyway.
"""
chosen: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
for game in games:
key = key_fn(game)
current = chosen.get(key)
if current is None or _STATE_PRIORITY.get(_state(game), 0) > \
_STATE_PRIORITY.get(_state(current), 0):
chosen[key] = game
return list(chosen.values())
def finished_games(
live_managers: Iterable[Tuple[str, Any]]) -> List[Dict[str, Any]]:
"""Games that just left these live managers' lists, final ones as recent games.
``live_managers`` pairs each league with its live manager (None is
skipped). Each manager reports what SportsLiveSharedMixin recorded
(finished_games_snapshot, copies), with its league. A final game is
drawn as a recent card. One a poll only judged over -- a tied end of
regulation looks like that too -- keeps its last live state, so its card
never says FINAL early; if play resumes the live list has it again, and
dedupe_games keeps that copy.
"""
finished: List[Dict[str, Any]] = []
for league, manager in live_managers:
snapshot = getattr(manager, 'finished_games_snapshot', None)
if not callable(snapshot):
continue
for game in snapshot():
game['league'] = league
if game.get('is_final'):
status = game.get('status')
status = dict(status) if isinstance(status, dict) else {}
status['state'] = 'post'
game.update(status=status, is_live=False)
finished.append(game)
return finished
def with_finished_games(
games: List[Dict[str, Any]], leagues: List[str],
finished: List[Dict[str, Any]],
) -> Tuple[List[Dict[str, Any]], List[str]]:
"""The slate with games that just went final where their live cards were.
A slate lists each league's games together, live ones first. Each
finished game goes after its league's live games, ahead of the rest; a
league with no games left in the slate is added at the end. A finished
game the slate also has (the recent list caught up) is left for
dedupe_games, which keeps one copy.
"""
if not finished:
return list(games), list(leagues)
pending: "OrderedDict[Any, List[Dict[str, Any]]]" = OrderedDict()
for game in finished:
pending.setdefault(game.get('league'), []).append(game)
merged: List[Dict[str, Any]] = []
for index, game in enumerate(games):
league = game.get('league')
if league in pending and _state(game) != 'in':
merged.extend(pending.pop(league))
merged.append(game)
following = games[index + 1] if index + 1 < len(games) else None
if league in pending and (following is None or following.get('league') != league):
merged.extend(pending.pop(league)) # the league's games were all live
leagues = list(leagues)
for league, rest in pending.items():
merged.extend(rest)
if league not in leagues:
leagues.append(league)
return merged, leagues
class VegasCardCache:
"""Cards drawn once per fingerprint, kept for as long as their game is.
``element(key, fingerprint, render)`` returns a VegasElement whose image is
``render()``'s -- called only when the fingerprint differs from the one the
cached card was drawn for. The fingerprint is also the element's version,
so the ticker skips unchanged cards without comparing pixels.
Bounded: keys not passed to :meth:`retain` after a slate are dropped, and
at most ``max_entries`` are ever held (oldest first).
"""
def __init__(self, max_entries: int = 96) -> None:
self.max_entries = max(1, int(max_entries))
self._cards: "OrderedDict[str, Tuple[Hashable, Image.Image]]" = OrderedDict()
self.renders = 0
def element(self, key: str, fingerprint: Hashable,
render: Callable[[], Image.Image], live: bool = True) -> Any:
from src.plugin_system.vegas_elements import VegasElement
cached = self._cards.get(key)
if cached is not None and cached[0] == fingerprint:
self._cards.move_to_end(key)
image = cached[1]
else:
image = render()
self.renders += 1
self._cards[key] = (fingerprint, image)
self._cards.move_to_end(key)
while len(self._cards) > self.max_entries:
self._cards.popitem(last=False)
return VegasElement(key=key, image=image, version=fingerprint, live=live)
def retain(self, keys: Iterable[str]) -> None:
"""Forget every card whose key is not in ``keys``."""
keep = set(keys)
for key in [k for k in self._cards if k not in keep]:
self._cards.pop(key, None)
def clear(self) -> None:
self._cards.clear()
def __len__(self) -> int:
return len(self._cards)
class StickyOdds:
"""Keep a game's last odds on its card while a live poll leaves them out.
Live odds are fetched only for games near the front of the rotation
(src/common/sports_fetch.py), so the same game's dict has odds on one poll
and none on the next. Drawn as-is that redraws the card every poll with
the odds flickering in and out. ``apply`` returns the game with its last
non-empty odds put back, for up to ``ttl_s`` seconds after they were seen.
"""
def __init__(self, ttl_s: float = 600.0) -> None:
self.ttl_s = float(ttl_s)
self._seen: Dict[str, Tuple[float, Any]] = {}
def apply(self, key: str, game: Dict[str, Any],
now: Optional[float] = None) -> Dict[str, Any]:
now = time.monotonic() if now is None else now
odds = game.get('odds')
if odds:
self._seen[key] = (now, odds)
return game
seen = self._seen.get(key)
if seen is None or now - seen[0] > self.ttl_s:
self._seen.pop(key, None)
return game
refilled = dict(game)
refilled['odds'] = seen[1]
return refilled
def retain(self, keys: Iterable[str]) -> None:
keep = set(keys)
for key in [k for k in self._seen if k not in keep]:
self._seen.pop(key, None)