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:
co-authored by
Claude Opus 5.5
parent
904598031a
commit
2972cdfeca
@@ -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")}
|
||||
Reference in New Issue
Block a user