perf(display): throttle the per-frame update tick; check strips without building them (#731)

The frame loops and the dwell sleep run PluginManager.run_scheduled_updates() at most every 0.25 s instead of after every frame (the top of each loop pass still ticks unthrottled), and SportsScrollDisplay and the sync follower ask ScrollHelper.has_strip() instead of building cached_image just to see whether a strip exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-03 14:01:23 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ee789775b4
commit 0d179fdf12
6 changed files with 389 additions and 6 deletions
@@ -76,3 +76,44 @@ def test_only_the_latest_image_is_adopted():
dc._adopt_follower_scroll_image(rp)
assert helper.cached_image is latest
assert helper.total_scroll_width == 400
def test_a_follower_frame_does_not_build_a_deferred_strip_image():
"""Every follower frame asks whether the strip is there. A strip the
follower's own rebuild deferred (append_content) must be answered from
the helper's bookkeeping, not by building and keeping its PIL image."""
import numpy as np
from src.common.scroll_helper import ScrollHelper
width, height = 64, 16
helper = ScrollHelper(width, height)
helper.append_content([Image.new("RGB", (300, height), (0, 200, 0))])
helper.append_content([Image.new("RGB", (300, height), (0, 0, 200))])
assert helper.__dict__.get("_cached_image") is None
assert helper.has_strip()
dc = object.__new__(DisplayController)
dc.config = {"sync": {}}
dc.vegas_coordinator = SimpleNamespace(
render_pipeline=SimpleNamespace(scroll_helper=helper))
dc.display_manager = MagicMock(width=width)
dc.sync_manager = MagicMock()
dc.sync_manager.get_latest_scroll_x.return_value = 200
dc._follower_incoming_image = deque(maxlen=1)
dc._follower_dr_last_t = None
dc._follower_local_x = 200.0
dc._follower_pending_new_image = False
dc._follower_last_frame = None
dc._follower_deadline = None
dc._scroll_speed = 0
for _ in range(3):
dc._run_follower_frame()
# The frame was cut from the array...
frame = np.asarray(dc._follower_last_frame)
assert frame.shape == (height, width, 3)
assert frame.any()
# ...and the strip is still held once.
assert helper.__dict__.get("_cached_image") is None
+134
View File
@@ -0,0 +1,134 @@
"""The render loop's scheduled-update pass, throttled per frame.
The frame loops called _tick_plugin_updates() after every frame, about 125
times a second on a scroller, and each call ran
PluginManager.run_scheduled_updates(): a copy of the plugin dict and several
locks per plugin, to find that nothing was due (no interval is shorter than
5 s). The frame loops and the dwell sleep now call
_tick_plugin_updates_if_due(), which runs it at most once per
PLUGIN_UPDATE_TICK_INTERVAL. The top of each loop pass still calls
_tick_plugin_updates() unthrottled, because a plugin loaded, reloaded or
enabled for on-demand there is due at once.
"""
import os
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
os.environ.setdefault("EMULATOR", "true")
from src import display_controller as dc_mod # noqa: E402
from src.display_controller import DisplayController # noqa: E402
from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402
@pytest.fixture
def clock(monkeypatch):
now = {"t": 1000.0}
fake = SimpleNamespace(monotonic=lambda: now["t"], time=lambda: now["t"])
monkeypatch.setattr(dc_mod, "time", fake)
return now
@pytest.fixture
def dc():
controller = object.__new__(DisplayController)
controller.plugin_manager = MagicMock()
return controller
def _calls(dc):
return dc.plugin_manager.run_scheduled_updates.call_count
class TestThrottle:
def test_the_first_call_runs(self, dc, clock):
dc._tick_plugin_updates_if_due()
assert _calls(dc) == 1
def test_calls_inside_the_window_are_skipped(self, dc, clock):
dc._tick_plugin_updates_if_due()
for _ in range(30): # a scroller's frames, 8 ms apart
clock["t"] += 0.008
dc._tick_plugin_updates_if_due()
assert clock["t"] - 1000.0 < dc.PLUGIN_UPDATE_TICK_INTERVAL
assert _calls(dc) == 1
def test_it_runs_again_once_the_window_has_passed(self, dc, clock):
dc._tick_plugin_updates_if_due()
clock["t"] += dc.PLUGIN_UPDATE_TICK_INTERVAL
dc._tick_plugin_updates_if_due()
assert _calls(dc) == 2
def test_the_unthrottled_tick_always_runs_and_restarts_the_window(self, dc, clock):
# The top of the loop pass: a plugin just (re)loaded is due now.
dc._tick_plugin_updates_if_due()
clock["t"] += 0.01
dc._tick_plugin_updates()
assert _calls(dc) == 2
# ...and the frame right after it does not repeat the pass.
clock["t"] += 0.01
dc._tick_plugin_updates_if_due()
assert _calls(dc) == 2
def test_no_plugin_manager(self, clock):
controller = object.__new__(DisplayController)
controller.plugin_manager = None
controller._tick_plugin_updates_if_due() # must not raise
controller._tick_plugin_updates()
def test_a_failing_pass_is_contained_and_still_throttled(self, dc, clock):
dc.plugin_manager.run_scheduled_updates.side_effect = RuntimeError("boom")
dc._tick_plugin_updates_if_due()
dc._tick_plugin_updates_if_due()
assert _calls(dc) == 1
def test_the_vegas_tick_is_not_throttled(self, dc, clock):
# Vegas's own update thread calls the plugin manager directly.
dc.vegas_coordinator = None
dc.plugin_manager.run_scheduled_updates_with_changes.return_value = []
dc._tick_plugin_updates()
dc._tick_plugin_updates_for_vegas()
dc._tick_plugin_updates_for_vegas()
assert dc.plugin_manager.run_scheduled_updates_with_changes.call_count == 2
class TestInTheRunLoop:
"""The real run() on the golden-trace harness's fake clock."""
@pytest.fixture
def run(self, tmp_path):
h = RunLoopHarness(tmp_path, horizon=40)
h.add_plugin(FakePlugin("ticker", ["ticker"], duration=10, enable_scrolling=True))
h.add_plugin(FakePlugin("clock", ["clock"], duration=10))
ticks = []
h.pm.run_scheduled_updates = lambda: ticks.append(round(h.clock.rel(), 3))
h.run()
passes = sorted({t for t, kind, _s, _d in h.events if kind == "pass"})
frames = [t for t, kind, s, _d in h.events if kind in ("first", "frame") and s == "ticker"]
return SimpleNamespace(ticks=ticks, passes=passes, frames=frames,
interval=h.controller.PLUGIN_UPDATE_TICK_INTERVAL)
def test_a_scroller_ticks_a_few_times_a_second_not_every_frame(self, run):
ticker_frames = len(run.frames)
assert ticker_frames > 500 # 10 s screens at 125 Hz, twice
# 40 s at four a second, plus one per pass.
assert len(run.ticks) <= 40 / run.interval + len(run.passes) + 1
def test_ticks_are_never_closer_than_the_floor_except_at_a_pass(self, run):
passes = set(run.passes)
for prev, cur in zip(run.ticks, run.ticks[1:]):
if cur - prev < run.interval - 1e-6:
assert cur in passes, (prev, cur)
def test_every_pass_ticks_at_once(self, run):
# Unthrottled: the frame loop ticked just before the screen ended, and
# the pass ticks again anyway, so a plugin it just loaded is not kept
# waiting.
ticks = set(run.ticks)
for t in run.passes:
assert t in ticks, t
assert any(cur - prev < run.interval
for prev, cur in zip(run.ticks, run.ticks[1:]))
+126
View File
@@ -0,0 +1,126 @@
"""Asking a scoreboard whether it has a strip must not build its PIL image.
SportsScrollDisplay.display_scroll_frame (every frame) and has_cached_content
asked ``scroll_helper.cached_image``, a property that builds the strip's PIL
image from ``cached_array`` when the helper deferred it (after a patch,
append or trim) and keeps it, so the strip ends up held twice. They now ask
``has_strip()``, which answers from the helper's bookkeeping. These pin down
that the answer is the same as ``bool(cached_image)`` in every state, and
that the image is not built.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock
import numpy as np
import pytest
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.modules.setdefault("rgbmatrix", MagicMock())
from src.common.sports_scroll import SportsScrollDisplay # noqa: E402
W, H = 128, 32
class _Panel:
matrix = None
refresh_hz = 100.0
def __init__(self):
self.width, self.height = W, H
self.image = None
self.frames = 0
def set_scrolling_state(self, scrolling, frame_hold=1):
pass
def update_display(self):
self.frames += 1
class _Display(SportsScrollDisplay):
SCROLL_LEAGUE_KEYS = ("nfl",)
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
cards = [Image.new("RGB", (40, H), (200, 0, 0)) for _ in games]
self.scroll_helper.create_scrolling_image(cards, item_gap=8, element_gap=0)
return bool(cards)
@pytest.fixture
def display():
return _Display(_Panel(), {})
def _deferred(display):
"""A strip whose PIL image the helper has dropped, to build on read."""
display.prepare_scroll_content([1, 2, 3], "recent", ["nfl"])
helper = display.scroll_helper
helper.patch_columns(W, np.zeros((H, 4, 3), dtype=np.uint8))
assert helper.__dict__.get("_cached_image") is None
assert helper.__dict__.get("_image_source") is not None
return helper
class TestTheImageIsNotBuilt:
def test_has_cached_content(self, display):
helper = _deferred(display)
assert display.has_cached_content() is True
assert helper.__dict__.get("_cached_image") is None
def test_display_scroll_frame(self, display):
helper = _deferred(display)
for _ in range(5):
assert display.display_scroll_frame() is True
assert display.display_manager.frames == 5
assert helper.__dict__.get("_cached_image") is None
def _states():
"""(name, set-up) for every state a helper's strip can be in."""
def none(d):
pass
def placeholder(d): # create_scrolling_image([]): one blank panel
d.scroll_helper.create_scrolling_image([])
def built(d):
d.prepare_scroll_content([1, 2], "recent", ["nfl"])
def set_image(d):
d.scroll_helper.set_scrolling_image(Image.new("RGB", (300, H)))
def deferred(d):
_deferred(d)
def cleared(d):
built(d)
d.scroll_helper.clear_cache()
def assigned_none(d):
built(d)
d.scroll_helper.cached_image = None
return [none, placeholder, built, set_image, deferred, cleared, assigned_none]
@pytest.mark.parametrize("setup", _states(), ids=lambda f: f.__name__)
def test_same_answer_as_reading_the_image(display, setup):
setup(display)
answer = display.has_cached_content()
assert answer is bool(display.scroll_helper.cached_image)
def test_a_helper_without_has_strip_is_asked_the_old_way(display):
"""A plugin's own helper or a test double: cached_image decides."""
class _OldHelper:
cached_image = None
display.scroll_helper = _OldHelper()
assert display.has_cached_content() is False
assert display.display_scroll_frame() is False
display.scroll_helper.cached_image = Image.new("RGB", (10, H))
assert display.has_cached_content() is True