mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
perf: cheap per-frame and per-fetch savings (#725)
Six small savings with no behaviour change: the odds fetch no longer pretty-prints every response for a debug line; the scroll integer-slice path drops a redundant full-frame np.ascontiguousarray; ledmatrix-web.service gets MALLOC_ARENA_MAX=2 like the display unit; core ESPN responses are parsed via response_json (orjson when installed); and the scroll frame stats go to INFO only for degraded windows plus a 5-minute heartbeat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -200,6 +200,24 @@ class TestGetOdds:
|
||||
manager.get_odds('football', 'nfl', '401') # hit
|
||||
assert [r for r in caplog.records if r.levelno == logging.INFO] == []
|
||||
|
||||
def test_debug_off_does_not_serialize_the_response(
|
||||
self, manager, mock_get, caplog):
|
||||
# json.dumps(indent=2) of every odds body ran even with DEBUG off.
|
||||
with caplog.at_level(logging.INFO, logger=manager.logger.name), \
|
||||
patch('src.base_odds_manager.json.dumps') as dumps:
|
||||
assert manager.get_odds('football', 'nfl', '401') == FULL_EXTRACTED
|
||||
dumps.assert_not_called()
|
||||
|
||||
def test_debug_on_still_logs_the_raw_response(
|
||||
self, manager, mock_get, caplog):
|
||||
with caplog.at_level(logging.DEBUG, logger=manager.logger.name):
|
||||
manager.get_odds('football', 'nfl', '401')
|
||||
messages = [r.getMessage() for r in caplog.records]
|
||||
assert any(m.startswith('Received raw odds data from ESPN: {')
|
||||
for m in messages), messages
|
||||
assert any(m.startswith('Returning extracted odds data: {')
|
||||
for m in messages), messages
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_espn_data
|
||||
|
||||
@@ -116,3 +116,24 @@ def test_response_json_prefers_orjson_and_falls_back():
|
||||
assert json_body.response_json(response) == payload
|
||||
# A response object without bytes content (a test double) still works.
|
||||
assert json_body.response_json(SimpleNamespace(json=lambda: payload)) == payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [b"not json", b"", b'{"a": NaN}', b"\xef\xbb\xbf{}"])
|
||||
def test_response_json_raises_and_returns_what_requests_does(body):
|
||||
# The core fetch paths (api_helper, base_odds_manager, logo_downloader,
|
||||
# dynamic_team_resolver) catch requests' JSONDecodeError on a bad body, so
|
||||
# response_json must raise exactly that, and parse whatever requests
|
||||
# parses (NaN, which orjson rejects) to the same value.
|
||||
import requests
|
||||
|
||||
response = requests.models.Response()
|
||||
response._content = body
|
||||
response.status_code = 200
|
||||
response.headers["Content-Type"] = "application/json"
|
||||
try:
|
||||
expected = response.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
with pytest.raises(requests.exceptions.JSONDecodeError):
|
||||
json_body.response_json(response)
|
||||
else:
|
||||
assert json.dumps(json_body.response_json(response)) == json.dumps(expected)
|
||||
|
||||
@@ -6,6 +6,7 @@ get_visible_portion, calculate_dynamic_duration, set_* methods,
|
||||
reset_scroll, clear_cache, get_scroll_info.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
@@ -172,6 +173,21 @@ class TestGetVisiblePortion:
|
||||
# Just verify both are valid PIL images with correct size
|
||||
assert img1.width == img2.width == DISPLAY_W
|
||||
|
||||
@pytest.mark.parametrize("start_x", [0, 1, 37, 200 - DISPLAY_W])
|
||||
def test_integer_slice_is_byte_identical_to_a_contiguous_copy(
|
||||
self, helper, start_x):
|
||||
# The integer path dropped np.ascontiguousarray() before tobytes():
|
||||
# a column slice of the strip is not C-contiguous, and tobytes()
|
||||
# must still give the same C-order bytes the copy did.
|
||||
rng = np.random.default_rng(start_x)
|
||||
strip = rng.integers(0, 256, (DISPLAY_H, 200, 3), dtype=np.uint8)
|
||||
helper.cached_array = strip
|
||||
view = strip[:, start_x:start_x + DISPLAY_W]
|
||||
assert not view.flags["C_CONTIGUOUS"]
|
||||
frame = helper._get_visible_portion_integer(start_x, start_x + DISPLAY_W)
|
||||
assert frame.tobytes() == np.ascontiguousarray(view).tobytes()
|
||||
assert view.tobytes() == np.ascontiguousarray(view).tobytes()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reset_scroll / clear_cache
|
||||
@@ -396,6 +412,75 @@ class TestFrameStatsPercentiles:
|
||||
assert helper._window == []
|
||||
|
||||
|
||||
class TestFrameStatsLogLevel:
|
||||
"""The stats line is INFO only when a window is degraded, on the window
|
||||
that recovers, and as a 5-minute heartbeat; every other window is DEBUG.
|
||||
Every 5s from every scroller at INFO was most of a healthy rig's journal.
|
||||
"""
|
||||
|
||||
HEALTHY = [0.010] * 500
|
||||
# 10% of frames a whole refresh late: fps 90.9 against a locked 100.
|
||||
SLOW = [0.010] * 450 + [0.020] * 50
|
||||
# 2% stalled: barely moves the mean, but it is the judder to see.
|
||||
STALLING = [0.010] * 490 + [0.025] * 10
|
||||
|
||||
def _log_window(self, helper, window):
|
||||
helper._window = list(window)
|
||||
helper.last_frame_time = time.time()
|
||||
helper.last_fps_log_time = 0.0
|
||||
with patch.object(helper.logger, "info") as info, \
|
||||
patch.object(helper.logger, "debug") as debug, \
|
||||
patch.object(helper.logger, "isEnabledFor", return_value=True):
|
||||
helper.log_frame_rate()
|
||||
return info, debug
|
||||
|
||||
def test_degraded_predicate(self):
|
||||
from src.common.scroll_helper import frame_stats_degraded
|
||||
assert not frame_stats_degraded(frame_stats(self.HEALTHY))
|
||||
assert frame_stats_degraded(frame_stats(self.SLOW))
|
||||
assert frame_stats_degraded(frame_stats(self.STALLING))
|
||||
# One stall in 500 is a normal wobble, not degraded.
|
||||
assert not frame_stats_degraded(
|
||||
frame_stats([0.010] * 499 + [0.025]))
|
||||
|
||||
def test_first_window_is_info_as_a_heartbeat(self, helper):
|
||||
info, debug = self._log_window(helper, self.HEALTHY)
|
||||
assert info.called and not debug.called
|
||||
|
||||
def test_healthy_window_after_the_heartbeat_is_debug(self, helper):
|
||||
helper._stats_last_info_log = time.time()
|
||||
info, debug = self._log_window(helper, self.HEALTHY)
|
||||
assert not info.called
|
||||
assert "Scroll frame stats" in debug.call_args[0][0]
|
||||
|
||||
@pytest.mark.parametrize("window", ["SLOW", "STALLING"])
|
||||
def test_degraded_window_is_info(self, helper, window):
|
||||
helper._stats_last_info_log = time.time()
|
||||
info, debug = self._log_window(helper, getattr(self, window))
|
||||
assert "Scroll frame stats" in info.call_args[0][0]
|
||||
assert not debug.called
|
||||
|
||||
def test_recovery_window_is_info_then_quiet(self, helper):
|
||||
helper._stats_last_info_log = time.time()
|
||||
self._log_window(helper, self.SLOW)
|
||||
info, _ = self._log_window(helper, self.HEALTHY)
|
||||
assert info.called, "the recovery was not reported"
|
||||
info, debug = self._log_window(helper, self.HEALTHY)
|
||||
assert not info.called and debug.called
|
||||
|
||||
def test_heartbeat_comes_back_after_the_interval(self, helper):
|
||||
from src.common.scroll_helper import STATS_HEARTBEAT_INTERVAL
|
||||
helper._stats_last_info_log = time.time() - STATS_HEARTBEAT_INTERVAL - 1
|
||||
info, _ = self._log_window(helper, self.HEALTHY)
|
||||
assert info.called
|
||||
|
||||
def test_reset_scroll_does_not_rearm_the_heartbeat(self, helper):
|
||||
helper._stats_last_info_log = time.time()
|
||||
helper.reset_scroll()
|
||||
info, _ = self._log_window(helper, self.HEALTHY)
|
||||
assert not info.called
|
||||
|
||||
|
||||
class TestIdleGapIsNotAFrame:
|
||||
"""The first frame of a scroll has no predecessor, so timing one measures
|
||||
the idle gap since the last scroll rather than a frame.
|
||||
|
||||
@@ -30,6 +30,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
UNIT = (Path(__file__).resolve().parent.parent / "systemd" / "ledmatrix.service")
|
||||
#: The web interface is a threaded process too, so it carries the same cap.
|
||||
WEB_UNIT = UNIT.parent / "ledmatrix-web.service"
|
||||
|
||||
#: The value the unit is expected to carry. 2 is the usual choice for a
|
||||
#: threaded Python process; 1-4 all keep some of the saving, but only one of
|
||||
@@ -49,8 +51,9 @@ def test_the_unit_exists():
|
||||
assert UNIT.is_file(), f"{UNIT} is missing"
|
||||
|
||||
|
||||
def test_malloc_arena_max_is_capped():
|
||||
env = _environment(UNIT.read_text(encoding="utf-8"))
|
||||
@pytest.mark.parametrize("unit", [UNIT, WEB_UNIT], ids=lambda p: p.name)
|
||||
def test_malloc_arena_max_is_capped(unit):
|
||||
env = _environment(unit.read_text(encoding="utf-8"))
|
||||
assert "MALLOC_ARENA_MAX" in env, (
|
||||
"the display unit does not cap glibc arenas; on a 3-core Pi the default "
|
||||
"ceiling is 24 and a measured rig held 23 of them, 920 MB"
|
||||
@@ -69,9 +72,10 @@ def test_malloc_arena_max_is_capped():
|
||||
)
|
||||
|
||||
|
||||
def test_the_reason_is_recorded_next_to_it():
|
||||
@pytest.mark.parametrize("unit", [UNIT, WEB_UNIT], ids=lambda p: p.name)
|
||||
def test_the_reason_is_recorded_next_to_it(unit):
|
||||
"""A bare tuning knob invites removal by whoever meets it next."""
|
||||
text = UNIT.read_text(encoding="utf-8")
|
||||
text = unit.read_text(encoding="utf-8")
|
||||
index = text.index("Environment=MALLOC_ARENA_MAX")
|
||||
preamble = text[:index].splitlines()[-12:]
|
||||
comment = "\n".join(line for line in preamble if line.startswith("#"))
|
||||
@@ -82,7 +86,7 @@ def test_the_reason_is_recorded_next_to_it():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unit", ["ledmatrix.service"])
|
||||
@pytest.mark.parametrize("unit", ["ledmatrix.service", "ledmatrix-web.service"])
|
||||
def test_the_unit_still_parses_as_ini(unit):
|
||||
"""systemd will refuse a malformed unit, and the panel stays dark."""
|
||||
import configparser
|
||||
|
||||
Reference in New Issue
Block a user