"""Rechnet Reaktionszeiten aus dem, was ein Lauf des Paper-Runners ohnehin hinterlaesst -- nur lesend. Koordinator-Werkzeug fuer Tage, an denen der Runner seine Latenzen noch nicht selbst misst (vor Paket L). Quellen: das JSONL-Protokoll des Laufs (jede Zeile traegt echte Zeit, auch in der Generalprobe) und die Tabelle killswitch_ereignisse im Register (Zeitpunkt jedes Klicks). Das Register wird mit mode=ro geoeffnet. Gerechnet wird: Klick -> Runner reagiert Registerereignis (Ausloeser pascal, erfolgreich) bis zur ersten Taktzeile danach, die den neuen Zustand traegt. Die Taktzeile entsteht am ENDE des Takts -- der Wert ist eine obere Schranke fuer "gelesen" und sagt, ab wann der neue Zustand im Protokoll wirkt. Kerze -> Taktende nur im Modus paper: Taktzeile mit frischer Kerze und Aktion, Zeit der Zeile minus (juengste Kerze + 60 s). Obere Schranke fuer Kerze -> Entscheidung, weil die Zeile erst nach den Orders geschrieben wird. In der Generalprobe sinnlos (Kerzen von einem anderen Tag) und deshalb ausgelassen. Taktabstand Abstand aufeinanderfolgender Taktzeilen -- der Rahmen fuer alles andere. Nicht gerechnet: Fuellzeit (dafuer handelstag_auswertung.py --broker) und Meldungen (ntfy_lesen.py). Gibt nie einen Kontowert aus. Aufruf (WSL, aus dem Projektordner): python latenz_auswertung.py [] """ import json import sqlite3 import statistics import sys from datetime import datetime from datetime import timedelta from pathlib import Path NEUER_ZUSTAND = {"anhalten": "angehalten", "glattstellen": "gesperrt", "fortsetzen": "aktiv", "freigeben": "aktiv"} KLICKER = "pascal" def zeit(text: str) -> datetime: return datetime.fromisoformat(text) def kennzahlen(name: str, werte: list[float], einheit: str = "s") -> None: if not werte: print(f" {name}: keine Messung") return sortiert = sorted(werte) p90 = sortiert[min(len(sortiert) - 1, int(round(0.9 * (len(sortiert) - 1))))] print( f" {name}: n={len(werte)} Median {statistics.median(werte):.1f} {einheit} " f"Mittel {statistics.fmean(werte):.1f} p90 {p90:.1f} Min {sortiert[0]:.1f} Max {sortiert[-1]:.1f}" ) def main() -> int: protokoll = Path(sys.argv[1]) register = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("data/registry.db") zeilen = [] with open(protokoll, encoding="utf-8") as strom: # Dateiiteration, nie splitlines (U+2028) for roh in strom: if roh.strip(): zeilen.append(json.loads(roh)) kopf = next((z for z in zeilen if z.get("art") == "kopf"), {}) takte = [z for z in zeilen if z.get("art") == "takt"] modus = kopf.get("modus", "unbekannt") if not takte: print("Keine Taktzeile im Protokoll.") return 1 start, ende = zeit(zeilen[0]["zeit"]), zeit(zeilen[-1]["zeit"]) print(f"Protokoll {protokoll.name}: Modus {modus}, {len(takte)} Takte, {start:%H:%M:%S} bis {ende:%H:%M:%S} UTC") abstaende = [ (zeit(b["zeit"]) - zeit(a["zeit"])).total_seconds() for a, b in zip(takte, takte[1:]) ] print("Rahmen:") kennzahlen("Taktabstand", abstaende) print("Klick -> Runner reagiert (Register gegen erste Taktzeile mit dem neuen Zustand):") if not register.exists(): print(f" Register {register} fehlt -- ausgelassen") else: verbindung = sqlite3.connect(f"file:{register.as_posix()}?mode=ro", uri=True) try: ereignisse = verbindung.execute( "SELECT zeit, stufe, ausloeser, erfolgreich FROM killswitch_ereignisse ORDER BY id" ).fetchall() finally: verbindung.close() reaktionen = [] for ereignis_zeit, stufe, ausloeser, erfolgreich in ereignisse: wann = zeit(ereignis_zeit) if not (start <= wann <= ende): continue if ausloeser != KLICKER: print(f" {wann:%H:%M:%S} {stufe} von {ausloeser}: kein Klick, ausgelassen") continue if not erfolgreich: print(f" {wann:%H:%M:%S} {stufe}: nicht erfolgreich, ausgelassen") continue neu = NEUER_ZUSTAND.get(stufe) treffer = next( (t for t in takte if zeit(t["zeit"]) > wann and t.get("killswitch") == neu), None ) if treffer is None: print(f" {wann:%H:%M:%S} {stufe}: keine Taktzeile mit Zustand {neu} danach") continue sekunden = (zeit(treffer["zeit"]) - wann).total_seconds() reaktionen.append(sekunden) print(f" {wann:%H:%M:%S} {stufe:<13} -> Takt {treffer.get('schritt')} ({treffer.get('phase')}) nach {sekunden:5.1f} s") kennzahlen("Klick -> Reaktion", reaktionen) if modus == "paper": print("Kerze -> Taktende (obere Schranke fuer Kerze -> Entscheidung):") werte = [] for takt in takte: if takt.get("frische_kerze") and takt.get("aktion") is not None and takt.get("letzte_kerze"): schluss = zeit(takt["letzte_kerze"]) + timedelta(seconds=60) werte.append((zeit(takt["zeit"]) - schluss).total_seconds()) kennzahlen("Kerze -> Taktende", werte) ohne = sum(1 for takt in takte if takt.get("markt_offen") and not takt.get("frische_kerze")) print(f" Takte bei offenem Markt ohne frische Kerze: {ohne} von {len(takte)}") else: print("Kerze -> Entscheidung: im Modus", modus, "nicht gerechnet (Kerzen eines anderen Tages).") orders = [z for z in zeilen if z.get("art") == "order"] meldefehler = [z for z in zeilen if z.get("art") == "ereignis" and z.get("was") == "benachrichtigung_fehlgeschlagen"] print(f"Dazu: {len(orders)} Orderzeile(n), {len(meldefehler)} fehlgeschlagene Meldung(en).") return 0 if __name__ == "__main__": sys.exit(main())