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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user