"""Ausreißer ohne feste Schwellen (Ausbauplan „Später“, Punkt 22, seit 26.09.2026). Der Orchestrator lernt je Messreihe den Normalwert für jede Stunde der Woche (168 Fächer: montags 0 Uhr bis sonntags 23 Uhr) und meldet, wenn ein Stundenmittel zwei Stunden hintereinander deutlich darüber liegt — etwa „Gitea braucht seit 2 Stunden ungewöhnlich viel CPU: 35 % statt sonst etwa 3 % dienstags gegen 20 Uhr“. Deutlich heißt: mindestens STREUUNGEN Streuungen über dem Normalwert UND ein Mindestabstand je Größe (CPU 15 Prozentpunkte, Speicher 10, Temperatur 8 °C, Netz 2 MB/s), damit Rauschen auf fast leeren Geräten nicht auffällt. Gemeldet wird erst, wenn ein Fach Werte aus mindestens MIN_WOCHEN Wochen hat; weniger Last als sonst ist kein Problem und wird nicht gemeldet. Grundlage sind die Minutenwerte aus kern/messreihen.py (acht Tage aufbewahrt). Gelernt wird kompakt in /mc2-normalwerte.json: je Reihe und Fach [Anzahl, Mittel, Varianz]. lernen_und_pruefen() rechnet jede neu abgeschlossene Stunde (beim allerersten Mal die aufbewahrten acht Tage): Stundenmittel bilden, mit dem Fach vergleichen, dann ins Fach lernen — einen Ausreißer nur gekappt, damit er den Normalwert nicht mitzieht. Ab VERGESSEN_AB Werten je Fach vergisst es langsam (gleitendes Mittel) und folgt so, wenn sich die Nutzung dauerhaft ändert. """ import json import math from datetime import datetime from pathlib import Path from kern import messreihen from kern.einstellungen import einstellungen from kern.zeit import LOCAL_TZ # name → (Anzeige, Einheit, Mindestabstand, Mindeststreuung) GROESSEN: dict[str, tuple[str, str, float, float]] = { "cpu": ("CPU", "%", 15.0, 2.0), "gpu": ("Grafik", "%", 20.0, 2.0), "ram": ("Speicher", "%", 10.0, 1.5), "temp_cpu": ("CPU-Temperatur", "°C", 8.0, 1.0), "temp_gpu": ("Grafik-Temperatur", "°C", 8.0, 1.0), "netz_rx": ("eingehenden Netzverkehr", "B/s", 2e6, 5e4), "netz_tx": ("ausgehenden Netzverkehr", "B/s", 2e6, 5e4), } MIN_WOCHEN = 3 STREUUNGEN = 4.0 VERGESSEN_AB = 8 MIN_MINUTEN = 30 KAPPE_STREUUNGEN = 3.0 STUNDE = 3600 NACHHOLEN_S = messreihen.AUFBEWAHREN_TAGE * 86400 TAGE = ("montags", "dienstags", "mittwochs", "donnerstags", "freitags", "samstags", "sonntags") def fach(t: float) -> int: dt = datetime.fromtimestamp(t, LOCAL_TZ) return dt.weekday() * 24 + dt.hour def fach_text(nr: int) -> str: return f"{TAGE[nr // 24]} gegen {nr % 24} Uhr" def lernen(stat: list | None, wert: float) -> list: """[Anzahl, Mittel, Varianz]: erst genau (Welford), ab VERGESSEN_AB gleitend.""" n, mittel, var = stat if stat else (0, 0.0, 0.0) d = wert - mittel if n < VERGESSEN_AB: n1 = n + 1 mittel1 = mittel + d / n1 return [n1, mittel1, (var * n + d * (wert - mittel1)) / n1] a = 1 / VERGESSEN_AB return [n + 1, mittel + a * d, (1 - a) * (var + a * d * d)] def auffaellig(stat: list | None, wert: float, name: str) -> bool: if not stat or stat[0] < MIN_WOCHEN: return False _, _, abstand, mindeststreuung = GROESSEN[name] streuung = max(math.sqrt(max(stat[2], 0.0)), mindeststreuung) return wert - stat[1] >= max(abstand, STREUUNGEN * streuung) def _pfad() -> Path: return einstellungen().daten_dir / "mc2-normalwerte.json" def lesen() -> dict: try: daten = json.loads(_pfad().read_text(encoding="utf-8")) except (OSError, ValueError): return {"stunde": None, "reihen": {}} return daten if isinstance(daten, dict) and isinstance(daten.get("reihen"), dict) else {"stunde": None, "reihen": {}} def _schreiben(daten: dict) -> None: pfad = _pfad() pfad.parent.mkdir(parents=True, exist_ok=True) tmp = pfad.with_suffix(".tmp") tmp.write_text(json.dumps(daten, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") tmp.replace(pfad) def _stundenmittel(punkte: list[dict], name: str) -> dict[int, float]: """Stundenbeginn → Mittel der Minutenwerte (nur Stunden mit mindestens MIN_MINUTEN Werten).""" eimer: dict[int, list[float]] = {} for p in punkte: if messreihen.ist_zahl(p.get(name)): eimer.setdefault(int(p["t"] // STUNDE * STUNDE), []).append(float(p[name])) return {h: sum(w) / len(w) for h, w in eimer.items() if len(w) >= MIN_MINUTEN} def lernen_und_pruefen(quellen: list[str], jetzt: float) -> dict: """Jede neu abgeschlossene Stunde einarbeiten. Rückgabe: der Stand (auch unverändert, wenn es nichts Neues gab).""" daten = lesen() bis = int(jetzt // STUNDE * STUNDE) # Beginn der laufenden Stunde von = daten["stunde"] + STUNDE if daten.get("stunde") else bis - NACHHOLEN_S von = max(von, bis - NACHHOLEN_S) if von >= bis: return daten for quelle in quellen: punkte = messreihen.punkte(quelle, von, bis) for name in GROESSEN: mittel = _stundenmittel(punkte, name) if not mittel: continue schluessel = f"{quelle}/{name}" reihe = daten["reihen"].setdefault(schluessel, {"faecher": {}, "folge": 0}) for stunde in sorted(mittel): wert, nr = mittel[stunde], fach(stunde) stat = reihe["faecher"].get(str(nr)) ist = auffaellig(stat, wert, name) reihe["folge"] = reihe.get("folge", 0) + 1 if ist else 0 reihe["zuletzt"] = {"stunde": stunde, "wert": round(wert, 2), "fach": nr, "normal": round(stat[1], 2) if stat else None} kappe = stat[1] + KAPPE_STREUUNGEN * max(math.sqrt(max(stat[2], 0.0)), GROESSEN[name][3]) \ if ist and stat else wert reihe["faecher"][str(nr)] = [round(x, 4) for x in lernen(stat, min(wert, kappe))] # Fehlt die letzte Stunde (Gerät aus, keine Messung), ist die Folge gerissen. if bis - STUNDE not in mittel: reihe["folge"] = 0 daten["stunde"] = bis - STUNDE _schreiben(daten) return daten def zahl_text(wert: float, einheit: str) -> str: if einheit == "B/s": return f"{wert / 1e6:.1f} MB/s".replace(".", ",") return f"{round(wert)} {einheit}" if einheit == "°C" else f"{round(wert)} %" def auffaelligkeiten(daten: dict, min_folge: int = 2) -> list[dict]: """Reihen, die seit mindestens min_folge Stunden auffällig sind: {quelle, name, folge, wert, normal, fach}.""" ergebnis = [] for schluessel, reihe in (daten.get("reihen") or {}).items(): quelle, _, name = schluessel.partition("/") z = reihe.get("zuletzt") or {} if reihe.get("folge", 0) >= min_folge and name in GROESSEN and z.get("normal") is not None: ergebnis.append({"quelle": quelle, "name": name, "folge": reihe["folge"], "wert": z["wert"], "normal": z["normal"], "fach": z["fach"]}) return ergebnis def lernstand(daten: dict) -> dict: """Wie weit das Lernen ist: die meisten Wochen in einem Fach, Zahl der Reihen.""" wochen = max((s[0] for r in (daten.get("reihen") or {}).values() for s in (r.get("faecher") or {}).values()), default=0) return {"reihen": len(daten.get("reihen") or {}), "wochen": wochen, "lernt": wochen < MIN_WOCHEN, "stunde": daten.get("stunde")}