Später · 22 Ausreißer ohne feste Schwellen: Normalwert je Wochenstunde

- kern/ausreisser.py: 168 Fächer je Reihe (CPU, GPU, Speicher, Temperaturen, Netz), erst genau, ab acht Werten
  gleitend; auffällig ab 4 Streuungen und Mindestabstand, erst mit drei Wochen Erfahrung; Ausreißer gekappt gelernt
- services/ausreisser.py: Wächter beider Rollen, gelber Hinweis nach zwei auffälligen Stunden mit Normalwert und
  Wochenstunde im Satz; erster Lauf lernt aus den aufbewahrten acht Tagen
- Monitoring: Zeile mit Lernwoche bzw. was gerade auffällt; GET /api/ausreisser, /api/homelab/ausreisser

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-26 10:04:30 +02:00
co-authored by Claude Opus 5.5
parent 904598031a
commit 2972cdfeca
32 changed files with 414 additions and 27 deletions
+161
View File
@@ -0,0 +1,161 @@
"""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
<Datenordner>/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")}