mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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:]))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user