fix(station): Ghosting-Ausgleich nach Buendel, Zeitsteuerung testbar

Der Reset von partials_since_full beim Einschlafen liess den faelligen
Ausgleichs-Vollaufbau dauerhaft ausfallen: nach einem Buendel Teilaufbauten
schlief das Panel mit dem Geisterbild ein und zeigte es stundenlang weiter,
weil stale nie mehr feuern konnte. Reset ersatzlos gestrichen - der Ausgleich
bleibt ausstehend und wird zum 30-Minuten-Termin nachgeholt; der in Fixrunde 3
beseitigte Bias-Zyklus kommt dadurch nicht zurueck.

Die gesamte Zeitsteuerung steckt jetzt in RefreshPlanner statt in run_daemon()s
Endlosschleife und ist ohne Schleife, ohne time.sleep() und ohne Hardware
pruefbar. Neue Tests spielen sechs Stunden Betrieb durch (statischer Inhalt,
Aenderung alle 3/9/11 Minuten, Buendel-dann-Ruhe) und schliessen die drei vom
Reviewer per Mutation belegten Luecken aus Fixrunde 3.
This commit is contained in:
Michael Sauer
2026-09-13 22:04:39 +02:00
parent 38c70ac5ce
commit b8c7febb8f
2 changed files with 322 additions and 48 deletions
+114 -48
View File
@@ -301,6 +301,104 @@ class Panel:
self._awake = False
class RefreshPlanner:
"""Die gesamte Zeitsteuerung der Anzeige - wann voll aufgebaut, wann teilweise, wann
geschlafen wird - als reine Zustandsmaschine ohne Schleife, ohne time.sleep() und ohne
Hardware.
Vorher steckte diese Logik direkt in run_daemon()s Endlosschleife und war dadurch
ungetestet (Fixrunde 4): drei Korrekturen aus Fixrunde 3 (die stale-Bedingung, die
Rueckmeldung einer intern aufgewerteten Teilaktualisierung, die Zeilenumbruchbehandlung
in compose()) liessen sich per Mutation aendern, ohne dass ein einziger Test rot wurde.
Hier laesst sich stattdessen ein kompletter Tagesverlauf in Millisekunden durchspielen
(siehe test_render.py, _run_schedule()).
Benutzung: pro Runde einmal decide() fragen, die zurueckgegebene Aktion ausfuehren und
das Ergebnis per record_draw()/record_sleep() zurueckmelden. decide() selbst aendert
nichts - der Aufrufer meldet, was tatsaechlich passiert ist (insbesondere, ob
Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat).
Aktionen:
"full" - kompletter Bildaufbau (Moduswechsel oder faelliger Ghosting-Ausgleich)
"partial" - Teilaktualisierung (Inhalt hat sich geaendert)
"sleep" - Panel schlafen legen (laenger nichts passiert)
"idle" - nichts tun
"""
_NO_MODE = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
def __init__(self, now: float,
full_refresh_interval_s: float = FULL_REFRESH_INTERVAL_S,
sleep_after_idle_s: float = SLEEP_AFTER_IDLE_S) -> None:
self.full_refresh_interval_s = full_refresh_interval_s
self.sleep_after_idle_s = sleep_after_idle_s
self.last_lines: list[str] | None = None
self.last_mode: object = self._NO_MODE
self.last_full = now
self.last_draw = now
# Zaehlt Teilaufbauten seit dem letzten Vollaufbau. full_refresh_interval_s
# existiert nur, um Geisterbilder aus AUFEINANDERFOLGENDEN Teilaufbauten
# auszugleichen - ohne mindestens einen Teilaufbau seit dem letzten Vollaufbau gibt
# es nichts auszugleichen, und ein Vollaufbau des UNVERAENDERTEN Bildes waere
# reine, nutzlose Alterung (Fixrunde 3, F3): ohne diese Absicherung entstand im
# eingeschwungenen Ruhezustand ein endloser 10-Minuten-schlafen/10-Minuten-wach-
# Zyklus (33% Bias-Anteil) rein aus der 30-Minuten-Uhr, obwohl sich der Inhalt nie
# aenderte.
#
# Der Zaehler wird beim Einschlafen BEWUSST NICHT zurueckgesetzt (Fixrunde 4, N1):
# das tat die Fassung aus Fixrunde 3 und liess den faelligen Ghosting-Ausgleich
# damit dauerhaft ausfallen - nach einem Buendel Teilaufbauten (z.B. 30 Lampen in
# 2,5 Minuten) schlief das Panel nach 10 Minuten mit dem entstandenen Geisterbild
# ein und zeigte genau dieses Bild stundenlang weiter, weil stale wegen
# partials_since_full == 0 nie mehr feuern konnte. So bleibt der Ausgleich
# ausstehend und wird zum 30-Minuten-Termin nachgeholt (das weckt das Panel einmal
# kurz auf; danach ist der Zaehler 0 und der eingeschwungene Ruhezustand bleibt
# wie in F3 gewuenscht bei 3% Wachanteil). Die Alternative - vor dem Einschlafen
# einmal draw_full() ziehen - kostet denselben einen Vollaufbau, aber JEDES Mal,
# wenn ein Buendel endet, auch wenn kurz darauf ohnehin neue Inhalte kommen (die
# das Panel nach dem Schlafen sowieso ueber draw_partial()s Aufwertung voll
# aufbauen). Diese Variante altert das Panel im Betriebsfall also mehr, nicht
# weniger, und der Ausgleich hier kommt spaetestens zum ohnehin vorgesehenen
# Termin.
self.partials_since_full = 0
self.asleep = False
def decide(self, now: float, lines: list[str], mode: object) -> str:
"""Was jetzt zu tun ist. Aendert nichts - das Ergebnis wird per record_*()
zurueckgemeldet."""
mode_changed = mode != self.last_mode
stale = (self.partials_since_full > 0
and (now - self.last_full) >= self.full_refresh_interval_s)
if lines != self.last_lines or mode_changed or stale:
return "full" if (mode_changed or stale) else "partial"
if not self.asleep and (now - self.last_draw) >= self.sleep_after_idle_s:
# Nach laengerer Ruhe schlafen legen (Fixrunde 1, I4: die Pixel bleiben -
# e-Paper ist bistabil - aber ohne dauerhafte Spannung altert das Panel
# deutlich weniger).
return "sleep"
return "idle"
def record_draw(self, now: float, lines: list[str], mode: object,
was_full: bool) -> None:
"""`was_full` ist True fuer einen regulaeren Vollaufbau UND dann, wenn
Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat (Fixrunde 3,
F4) - sonst kaeme der naechste "stale"-Vollaufbau zu frueh und altert das Panel
zusaetzlich, obwohl gerade erst vollstaendig aufgebaut wurde."""
self.last_lines = lines
self.last_mode = mode
self.last_draw = now
self.asleep = False
if was_full:
self.last_full = now
self.partials_since_full = 0
else:
self.partials_since_full += 1
def record_sleep(self, now: float) -> None:
self.asleep = True
def _test_image(revision: str):
from PIL import Image, ImageDraw, ImageFont
@@ -337,22 +435,13 @@ def run_clear(revision: str) -> None:
def run_daemon(revision: str) -> None:
"""Endlosschleife: alle POLL_INTERVAL_S Sekunden den Zustand einlesen und die von
RefreshPlanner entschiedene Aktion ausfuehren. Hier steht bewusst KEINE Zeitlogik mehr
(Fixrunde 4) - die steckt vollstaendig in RefreshPlanner und ist dort ohne Schleife,
ohne time.sleep() und ohne Hardware pruefbar."""
panel = Panel(revision)
panel.clear()
last_lines: list[str] | None = None
last_mode: object = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
last_full = time.monotonic()
last_draw = time.monotonic()
# Zaehlt Teilaufbauten seit dem letzten Vollaufbau. FULL_REFRESH_INTERVAL_S existiert
# nur, um Geisterbilder aus AUFEINANDERFOLGENDEN Teilaufbauten auszugleichen - ohne
# mindestens einen Teilaufbau seit dem letzten Vollaufbau gibt es nichts auszugleichen,
# und ein Vollaufbau des UNVERAENDERTEN Bildes waere reine, nutzlose Alterung
# (Fixrunde 3, F3): ohne diese Absicherung entstand im eingeschwungenen Ruhezustand ein
# endloser 10-Minuten-schlafen/10-Minuten-wach-Zyklus (33% Bias-Anteil) rein aus der
# 30-Minuten-Uhr, obwohl sich der Inhalt nie aenderte - genau das, was I4 verhindern
# sollte.
partials_since_full = 0
planner = RefreshPlanner(time.monotonic())
while True:
state = build_state()
@@ -360,41 +449,18 @@ def run_daemon(revision: str) -> None:
mode = state.get("mode")
now = time.monotonic()
mode_changed = mode != last_mode
stale = partials_since_full > 0 and (now - last_full) >= FULL_REFRESH_INTERVAL_S
if current_lines != last_lines or mode_changed or stale:
image = render.compose(state)
if mode_changed or stale:
panel.draw_full(image)
last_full = now
partials_since_full = 0
else:
upgraded = panel.draw_partial(image)
if upgraded:
# Panel.draw_partial() hat selbst zu einem Vollaufbau aufgewertet
# (Panel war nicht wach, siehe Panel-Klasse) - das zaehlt fuer die
# 30-Minuten-Uhr wie ein regulaerer Vollaufbau (Fixrunde 3, F4), sonst
# kaeme der naechste "stale"-Vollaufbau zu frueh.
last_full = now
partials_since_full = 0
else:
partials_since_full += 1
last_draw = now
last_lines = current_lines
last_mode = mode
elif (now - last_draw) >= SLEEP_AFTER_IDLE_S:
# Nach laengerer Ruhe schlafen legen (I4: die Pixel bleiben - e-Paper ist
# bistabil - aber ohne dauerhafte Spannung altert das Panel deutlich
# weniger). Panel.sleep() ist idempotent, ein wiederholter Aufruf hier
# (solange weiterhin nichts passiert) ist also unschaedlich.
action = planner.decide(now, current_lines, mode)
if action == "full":
panel.draw_full(render.compose(state))
planner.record_draw(now, current_lines, mode, was_full=True)
elif action == "partial":
# Panel.draw_partial() wertet sich selbst zu einem Vollaufbau auf, wenn das
# Panel nicht wach ist (siehe Panel-Klasse) - das meldet es hier zurueck.
upgraded = panel.draw_partial(render.compose(state))
planner.record_draw(now, current_lines, mode, was_full=upgraded)
elif action == "sleep":
panel.sleep()
# Nach dem Schlafen wertet Panel.draw_partial() den naechsten Aufbau ohnehin
# zu einem Vollaufbau auf - der 30-Minuten-Zaehler soll dieses "natuerliche"
# Zuruecksetzen nicht durch einen zusaetzlichen, inhaltslosen "stale"-
# Vollaufbau vorwegnehmen, falls vor dem Einschlafen schon Teilaufbauten
# stattgefunden hatten (Fixrunde 3, F3, zweiter Teil).
partials_since_full = 0
planner.record_sleep(now)
time.sleep(POLL_INTERVAL_S)
+208
View File
@@ -450,3 +450,211 @@ def test_panel_sleep_sends_no_commands_when_already_asleep(monkeypatch):
panel.sleep() # bereits asleep - darf keine weiteren Kommandos senden
assert recorder.commands == []
def test_draw_partial_reports_whether_it_upgraded_itself(monkeypatch):
"""Fixrunde 3, F4 - vom Reviewer per Mutation als ungetestet belegt: gibt
draw_partial() nach dem Aufwerten `False` statt `True` zurueck, blieben alle 21 Tests
gruen. Der Rueckgabewert ist aber die einzige Information, an der RefreshPlanner
erkennt, dass die 30-Minuten-Uhr neu beginnt - ohne ihn kaeme der naechste
Ghosting-Ausgleich zu frueh und alterte das Panel ohne Not."""
_install_recorder(monkeypatch)
panel = display.Panel("V3")
image = render.compose({"mode": "off"})
panel.clear()
assert panel.draw_partial(image) is False, "waches Panel: kein Vollaufbau noetig"
panel.sleep()
assert panel.draw_partial(image) is True, (
"nach sleep() wertet draw_partial() intern zu einem Vollaufbau auf - das muss es "
"auch zurueckmelden")
assert panel.draw_partial(image) is False, "danach ist das Panel wieder wach"
def test_compose_survives_a_newline_inside_a_value():
"""Fixrunde 3, F5 - ebenfalls per Mutation als ungetestet belegt (`line = raw_line`
liess alle Tests gruen): draw.textlength() wirft ValueError("can't measure length of
multiline text"), sobald eine Zeile ein "\\n" enthaelt. product_name kommt aus der
Lampenerkennung, ist also letztlich Netzwerkdatum - und ein ValueError hier faengt
main()s `except (DisplayUnavailable, ImportError)` NICHT ab, das waere ein echter
Absturz des Dienstes."""
state = {"mode": "migration", "phase": "auto", "served": 0, "recent": [],
"last_seen": {"product_name": "Klyqa\nGU10", "target": "gu10_rgb",
"variant": "prod", "lamp_version": "V1"}}
image = render.compose(state) # vor F5: ValueError
assert image.size == (render.PANEL_WIDTH_PX, render.PANEL_HEIGHT_PX)
# --- RefreshPlanner: die Zeitsteuerung ohne Schleife durchspielen (Fixrunde 4) ---------
#
# run_daemon() hatte null Testabdeckung - die gesamte Zeitsteuerung steckte in einer
# Endlosschleife. Sie liegt jetzt in display.RefreshPlanner und laesst sich hier als
# Zeitreihe durchspielen: pro Poll-Runde decide() fragen, die Aktion "ausfuehren" (das
# Panel-Verhalten wird dabei nur so weit nachgebildet, wie es die Entscheidung beeinflusst:
# ein Teilaufbau auf einem schlafenden Panel wertet sich selbst zu einem Vollaufbau auf,
# siehe Panel.draw_partial()) und das Ergebnis zurueckmelden - genau die Reihenfolge, die
# run_daemon() ebenfalls verwendet.
SIX_HOURS_S = 6 * 60 * 60
def _run_schedule(content_at, duration_s, mode="recovery",
poll=display.POLL_INTERVAL_S, planner=None):
"""Spielt `duration_s` Sekunden Anzeigebetrieb durch. `content_at(t)` liefert die
Zeilen, die build_state()/render.lines() zum Zeitpunkt t liefern wuerden.
Gibt (Protokoll, Wachsekunden) zurueck; Protokoll ist eine Liste von (t, Aktion)."""
planner = planner if planner is not None else display.RefreshPlanner(0.0)
log: list[tuple[float, str]] = []
awake = True # run_daemon() ruft direkt nach dem Start panel.clear()
awake_s = 0.0
t = 0.0
while t <= duration_s:
lines = content_at(t)
action = planner.decide(t, lines, mode)
if action == "full":
planner.record_draw(t, lines, mode, was_full=True)
awake = True
elif action == "partial":
# Panel.draw_partial() wertet auf einem schlafenden Panel selbst zu einem
# Vollaufbau auf und meldet das zurueck (F4, siehe Test oben) - im Protokoll
# als "wake-full" unterschieden, weil so ein Aufbau das Panel eben NICHT nur
# teilweise auffrischt.
upgraded = not awake
planner.record_draw(t, lines, mode, was_full=upgraded)
awake = True
if upgraded:
action = "wake-full"
elif action == "sleep":
planner.record_sleep(t)
awake = False
log.append((t, action))
if awake:
awake_s += poll
t += poll
return log, awake_s
def _times(log, action):
return [t for t, a in log if a == action]
def _changing_every(period_s):
"""Inhalt, der sich alle `period_s` Sekunden aendert (und sonst gleich bleibt)."""
return lambda t: ["RETTUNG", f"Ausgeliefert: {int(t // period_s)}"]
def test_static_content_never_triggers_another_full_refresh(monkeypatch):
"""Fixrunde 3, F3 - vom Reviewer per Mutation als ungetestet belegt: nimmt man die
Bedingung `partials_since_full > 0` aus `stale` heraus, bleiben alle 21 Tests gruen.
Genau diese Bedingung verhindert aber den 33-%-Bias-Zyklus: bei UNVERAENDERTEM Inhalt
gibt es keine Geisterbilder aus Teilaufbauten auszugleichen, ein Vollaufbau alle 30
Minuten waere reine Alterung - und weckt das Panel jedesmal fuer weitere 10 Minuten
auf."""
log, awake_s = _run_schedule(lambda t: ["RETTUNG", "Ausgeliefert: 0"], SIX_HOURS_S)
assert _times(log, "full") == [0.0], (
f"nur der Erstaufbau darf voll sein, tatsaechlich: {_times(log, 'full')}")
assert _times(log, "partial") == []
assert _times(log, "sleep") == [600.0], "genau einmal einschlafen, dann Ruhe"
assert awake_s <= 700, f"Panel war {awake_s} s wach - erwartet nur die ersten 10 min"
def test_change_every_3_minutes_stays_awake_and_compensates_every_30_minutes():
log, _ = _run_schedule(_changing_every(3 * 60), SIX_HOURS_S)
fulls = _times(log, "full")
assert _times(log, "sleep") == [], "alle 3 min Verkehr - das Panel darf nicht schlafen"
assert len(_times(log, "partial")) > 100, "die Aenderungen muessen Teilaufbauten sein"
assert len(fulls) >= 10, f"Ghosting-Ausgleich alle 30 min fehlt: {fulls}"
gaps = [b - a for a, b in zip(fulls, fulls[1:])]
assert all(gap >= display.FULL_REFRESH_INTERVAL_S for gap in gaps), (
f"Vollaufbauten zu dicht: {fulls}")
def test_change_every_9_minutes_still_never_sleeps():
"""9 Minuten liegen knapp unter SLEEP_AFTER_IDLE_S (10 min) - das Panel bleibt wach,
die Teilaufbauten sammeln sich, und die 30-Minuten-Uhr gleicht sie aus."""
log, _ = _run_schedule(_changing_every(9 * 60), SIX_HOURS_S)
fulls = _times(log, "full")
assert _times(log, "sleep") == []
assert _times(log, "partial"), "Aenderungen bei wachem Panel sind Teilaufbauten"
assert len(fulls) >= 5, f"Ghosting-Ausgleich fehlt: {fulls}"
gaps = [b - a for a, b in zip(fulls, fulls[1:])]
assert all(gap >= display.FULL_REFRESH_INTERVAL_S for gap in gaps)
def test_change_every_11_minutes_sleeps_between_and_wakes_with_a_full_refresh():
"""11 Minuten liegen knapp ueber SLEEP_AFTER_IDLE_S: das Panel schlaeft zwischen zwei
Aenderungen ein, und der naechste Aufbau wird von Panel.draw_partial() selbst zu einem
Vollaufbau aufgewertet - es darf also gar kein Teilaufbau mehr entstehen, und die
30-Minuten-Uhr darf nie zusaetzlich feuern (jeder Aufbau IST schon ein Vollaufbau)."""
log, _ = _run_schedule(_changing_every(11 * 60), SIX_HOURS_S)
assert _times(log, "partial") == [], (
"nach dem Schlafen wertet das Panel jeden Aufbau selbst zu einem Vollaufbau auf")
assert len(_times(log, "wake-full")) == SIX_HOURS_S // (11 * 60), (
f"pro Aenderung ein Aufbau erwartet: {_times(log, 'wake-full')}")
assert len(_times(log, "sleep")) >= 15, "zwischen den Aenderungen muss geschlafen werden"
# Nur der Erstaufbau ist ein regulaerer Vollaufbau: jeder weitere Aufbau IST wegen der
# Aufwertung schon vollstaendig, ein zusaetzlicher "stale"-Vollaufbau waere ein Zeichen,
# dass die Aufwertung nicht als Vollaufbau verbucht wurde (F4).
assert _times(log, "full") == [0.0], f"unerwartete Vollaufbauten: {_times(log, 'full')}"
def test_burst_then_quiet_still_gets_its_ghosting_compensation():
"""Fixrunde 4, N1 - der Dauerzustand, den jemand am laengsten ansieht: ein Buendel von
30 Teilaufbauten in 2,5 Minuten (Migration mehrerer Lampen), danach stundenlang Ruhe.
Die Fassung aus Fixrunde 3 setzte partials_since_full beim Einschlafen auf 0; das
Panel schlief 10 Minuten nach dem Buendel mit dem entstandenen Geisterbild ein, stale
konnte danach nie mehr feuern, und genau dieses Bild stand die restlichen rund 5 h 50
min unveraendert da. Der faellige Ausgleich muss stattdessen nachgeholt werden - und
das Panel danach wieder zur Ruhe kommen."""
def content_at(t):
# 30 Aenderungen im 5-Sekunden-Takt (t = 5 .. 150), danach unveraendert.
step = min(int(t // display.POLL_INTERVAL_S), 30)
return ["MIGRATION", f"Ausgeliefert: {step}"]
log, awake_s = _run_schedule(content_at, SIX_HOURS_S, mode="migration")
assert len(_times(log, "partial")) == 30, (
f"das Buendel muss aus Teilaufbauten bestehen: {_times(log, 'partial')}")
fulls = _times(log, "full")
assert len(fulls) == 2, f"Erstaufbau + genau ein Ausgleich erwartet, war: {fulls}"
ausgleich = fulls[1]
assert display.FULL_REFRESH_INTERVAL_S <= ausgleich <= 35 * 60, (
f"Ghosting-Ausgleich faellt aus oder kommt zu spaet (t={ausgleich})")
# ... und danach wirklich Ruhe: einmal nach dem Buendel, einmal nach dem Ausgleich.
assert len(_times(log, "sleep")) == 2, f"unruhiger Schlaf: {_times(log, 'sleep')}"
assert awake_s <= 30 * 60, (
f"Panel war {awake_s} s von {SIX_HOURS_S} s wach - der Ausgleich darf nicht in "
"einen Wachzyklus ausarten")
def test_planner_counts_an_upgraded_partial_as_a_full_refresh():
"""Direkt an der Zustandsmaschine (F4): meldet der Aufrufer eine von
Panel.draw_partial() selbst aufgewertete Teilaktualisierung als Vollaufbau, beginnt die
30-Minuten-Uhr neu und es gibt nichts mehr auszugleichen."""
planner = display.RefreshPlanner(0.0)
planner.record_draw(0.0, ["a"], "recovery", was_full=True)
planner.record_draw(10.0, ["b"], "recovery", was_full=False)
assert planner.partials_since_full == 1
planner.record_draw(20.0, ["c"], "recovery", was_full=True) # aufgewerteter Teilaufbau
assert planner.partials_since_full == 0
assert planner.decide(20.0 + display.FULL_REFRESH_INTERVAL_S, ["c"], "recovery") != "full"
def test_planner_draws_fully_on_mode_change():
planner = display.RefreshPlanner(0.0)
planner.record_draw(0.0, ["RETTUNG"], "recovery", was_full=True)
assert planner.decide(5.0, ["MIGRATION"], "migration") == "full"