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")}
+7
View File
@@ -156,6 +156,13 @@ def start() -> dict:
}
@router.get("/ausreisser")
def ausreisser() -> dict:
"""Lernstand der Ausreißer-Erkennung und was gerade auffällt (seit 26.09.2026)."""
from services import ausreisser as box_ausreisser
return box_ausreisser.lage()
@router.get("/platten")
def platten() -> dict:
"""Plattengesundheit der KI-Box (smartctl, höchstens stündlich gelesen; seit 26.09.2026)."""
+7
View File
@@ -134,6 +134,13 @@ def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
return _antwort(waechter.fuehre_aktion_aus(hinweis_id, aktion_id))
@router.get("/ausreisser")
def ausreisser() -> dict:
"""Lernstand der Ausreißer-Erkennung im Homelab und was gerade auffällt (seit 26.09.2026)."""
from services import ausreisser as homelab_ausreisser
return homelab_ausreisser.lage()
@router.get("/platten")
def platten() -> dict:
"""Plattengesundheit: Proxmox-Host (Ausführer) und NAS (SNMP), bewertet (seit 26.09.2026)."""
+68
View File
@@ -0,0 +1,68 @@
"""Ausreißer ohne feste Schwellen — die Prüfung des Wächters (Ausbauplan „Später“, Punkt 22, seit 26.09.2026).
Beide Rollen: Die KI-Box lernt ihre eigene Messreihe („box“), der Homelab-Teil die des Proxmox-Hosts („pve“) und jedes
Gasts („ct-104“, „vm-106“). Das Lernen und Vergleichen steht in kern/ausreisser.py; hier kommen Gerätenamen und die
Sätze der Hinweise dazu. Der Wächter ruft pruefe_ausreisser() jede Minute; gerechnet wird nur, wenn eine Stunde neu
abgeschlossen ist. Hinweise sind gelb — etwas Ungewöhnliches, kein Ausfall; „24 h stumm“ passt, wenn es gewollt ist.
"""
import time
from kern import ausreisser as kern_ausreisser
from kern import messreihen
from kern.einstellungen import einstellungen
TITEL = {"temp_cpu": "ist seit {n} Stunden ungewöhnlich warm", "temp_gpu": "ist seit {n} Stunden ungewöhnlich warm",
"netz_rx": "hat seit {n} Stunden ungewöhnlich viel {was}", "netz_tx": "hat seit {n} Stunden ungewöhnlich viel {was}"}
def quellen(jetzt: float) -> list[str]:
if einstellungen().rolle == "box":
return ["box"]
return [q for q in messreihen.quellen(jetzt - 2 * 86400, jetzt + 1) if q == "pve" or q.startswith(("ct-", "vm-"))]
def geraetename(quelle: str) -> str:
if quelle == "box":
return "Die KI-Box"
if quelle == "pve":
return "Der Proxmox-Host"
try:
from services.homelab import apps, kanal
for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
if f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g.get('vmid')}" == quelle:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
return app.name if app else str(g.get("name") or quelle)
except Exception:
pass
return quelle
def satz(a: dict) -> tuple[str, str]:
anzeige, einheit, _, _ = kern_ausreisser.GROESSEN[a["name"]]
muster = TITEL.get(a["name"], "braucht seit {n} Stunden ungewöhnlich viel {was}")
titel = f"{geraetename(a['quelle'])} {muster.format(n=a['folge'], was=anzeige)}"
text = (f"{kern_ausreisser.zahl_text(a['wert'], einheit)} statt sonst etwa "
f"{kern_ausreisser.zahl_text(a['normal'], einheit)} {kern_ausreisser.fach_text(a['fach'])}. Ist das gewollt "
"(ein Update, eine Sicherung, ein Test), schalte den Hinweis stumm; sonst im Monitoring nachsehen.")
return titel, text
def pruefe_ausreisser(jetzt: float | None = None) -> list:
from services.waechter import Befund
jetzt = time.time() if jetzt is None else jetzt
daten = kern_ausreisser.lernen_und_pruefen(quellen(jetzt), jetzt)
befunde = []
for a in kern_ausreisser.auffaelligkeiten(daten):
titel, text = satz(a)
befunde.append(Befund(id=f"ausreisser:{a['quelle']}:{a['name']}", stufe="gelb", titel=titel, text=text,
quelle=a["quelle"], sofort=True))
return befunde
def lage() -> dict:
"""Für GET /api/ausreisser bzw. /api/homelab/ausreisser: Lernstand und was gerade auffällt."""
daten = kern_ausreisser.lesen()
return {**kern_ausreisser.lernstand(daten), "min_wochen": kern_ausreisser.MIN_WOCHEN,
"auffaellig": [{**a, "titel": satz(a)[0]} for a in kern_ausreisser.auffaelligkeiten(daten)]}
+3
View File
@@ -643,6 +643,9 @@ PRUEFUNGEN += (__import__("services.homelab.docker").homelab.docker.pruefe_docke
# Plattengesundheit (seit 26.09.2026): Host und NAS im Homelab, die NVMe der KI-Box auf der Box.
PRUEFUNGEN += ((__import__("services.homelab.platten").homelab.platten.pruefe_platten_gesundheit,) if ROLLE == "homelab"
else (__import__("services.platten").platten.pruefe_platten_gesundheit,))
# Ausreißer ohne feste Schwellen (seit 26.09.2026): gelb, wenn ein Stundenmittel zwei Stunden lang weit über dem
# Normalwert dieser Wochenstunde liegt (lernt mindestens drei Wochen, bevor es meldet).
PRUEFUNGEN += (__import__("services.ausreisser").ausreisser.pruefe_ausreisser,)
# Neue Geräte im Heimnetz (seit 26.09.2026): gelb mit „Ist bekannt“, bis jemand ihn drückt.
PRUEFUNGEN += (__import__("services.homelab.netzgeraete").homelab.netzgeraete.pruefe_netzgeraete,) \
if ROLLE == "homelab" else ()
+89
View File
@@ -0,0 +1,89 @@
"""Ausreißer ohne feste Schwellen (Ausbauplan „Später“, Punkt 22, 26.09.2026): Normalwert je Wochenstunde, Meldung erst
nach zwei auffälligen Stunden und erst mit drei Wochen Erfahrung; ein Ausreißer zieht den Normalwert nicht mit."""
import json
import math
from datetime import datetime
import pytest
from kern import ausreisser as ka
from kern import einstellungen as einstellungen_mod
from kern import messreihen
from kern.zeit import LOCAL_TZ
from services import ausreisser
DIENSTAG_18 = datetime(2026, 9, 29, 18, 0, tzinfo=LOCAL_TZ).timestamp()
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
def test_faecher():
assert ka.fach(datetime(2026, 9, 28, 0, 30, tzinfo=LOCAL_TZ).timestamp()) == 0 # Montag 0 Uhr
assert ka.fach(DIENSTAG_18) == 42
assert ka.fach_text(42) == "dienstags gegen 18 Uhr" and ka.fach_text(167) == "sonntags gegen 23 Uhr"
def test_lernen_genau_dann_gleitend():
stat = None
for x in (2.0, 4.0, 6.0):
stat = ka.lernen(stat, x)
assert stat[0] == 3 and stat[1] == pytest.approx(4.0) and stat[2] == pytest.approx(8 / 3)
for _ in range(20):
stat = ka.lernen(stat, 10.0)
assert stat[0] == 23 and 9.0 < stat[1] < 10.0 # folgt der dauerhaft höheren Nutzung
def test_auffaellig_braucht_erfahrung_und_abstand():
stat = [3, 3.0, 1.0]
assert ka.auffaellig(stat, 35.0, "cpu")
assert not ka.auffaellig(stat, 15.0, "cpu") # unter 15 Prozentpunkten Abstand
assert not ka.auffaellig([2, 3.0, 1.0], 35.0, "cpu") # erst zwei Wochen Erfahrung
assert not ka.auffaellig([5, 30.0, 100.0], 55.0, "cpu") # 25 Punkte, aber die Stunde schwankt stark (4 × 10)
assert not ka.auffaellig(stat, 0.0, "cpu") # weniger Last ist kein Problem
def _minuten(quelle: str, von: float, stunden: int, **werte: float) -> None:
for m in range(stunden * 60):
messreihen.schreiben(quelle, werte, t=von + m * 60)
def test_zwei_auffaellige_stunden_ergeben_einen_hinweis(daten, monkeypatch):
# Drei Wochen Erfahrung für dienstags 18 und 19 Uhr: Gitea braucht dann etwa 3 % CPU.
(daten / "mc2-normalwerte.json").write_text(json.dumps({"stunde": DIENSTAG_18 - 3600, "reihen": {
"ct-104/cpu": {"faecher": {"42": [3, 3.0, 1.0], "43": [3, 3.0, 1.0]}, "folge": 0}}}), encoding="utf-8")
monkeypatch.setattr(ausreisser, "geraetename", lambda q: "Gitea")
monkeypatch.setattr(ausreisser, "quellen", lambda jetzt: ["ct-104"])
_minuten("ct-104", DIENSTAG_18, 1, cpu=35.0, ram=40.0)
assert ausreisser.pruefe_ausreisser(DIENSTAG_18 + 3600 + 30) == [] # eine Stunde reicht nicht
_minuten("ct-104", DIENSTAG_18 + 3600, 1, cpu=36.0, ram=40.0)
(b,) = ausreisser.pruefe_ausreisser(DIENSTAG_18 + 7200 + 30)
assert (b.id, b.stufe, b.quelle) == ("ausreisser:ct-104:cpu", "gelb", "ct-104")
assert b.titel == "Gitea braucht seit 2 Stunden ungewöhnlich viel CPU"
assert b.text.startswith("36 % statt sonst etwa 3 % dienstags gegen 19 Uhr.")
# Der Ausreißer wird gekappt gelernt: Das Fach bleibt nahe 3 %.
fach = ka.lesen()["reihen"]["ct-104/cpu"]["faecher"]["43"]
assert fach[0] == 4 and fach[1] < 3 + 3 * math.sqrt(1.0) + 0.01
# Normale Stunde danach: vorbei.
_minuten("ct-104", DIENSTAG_18 + 7200, 1, cpu=3.0, ram=40.0)
assert ausreisser.pruefe_ausreisser(DIENSTAG_18 + 3 * 3600 + 30) == []
def test_erster_lauf_lernt_aus_den_aufbewahrten_tagen(daten, monkeypatch):
monkeypatch.setattr(ausreisser, "quellen", lambda jetzt: ["box"])
_minuten("box", DIENSTAG_18 - 2 * 3600, 2, cpu=5.0, temp_cpu=40.0)
assert ausreisser.pruefe_ausreisser(DIENSTAG_18 + 30) == []
stand = ausreisser.lage()
assert stand["reihen"] == 2 and stand["wochen"] == 1 and stand["lernt"] and stand["min_wochen"] == 3
assert ka.lesen()["stunde"] == DIENSTAG_18 - 3600
# Ohne neue Stunde rechnet er nicht noch einmal.
assert ausreisser.pruefe_ausreisser(DIENSTAG_18 + 600) == []
def test_zahlen_in_worten():
assert ka.zahl_text(2_450_000, "B/s") == "2,5 MB/s" and ka.zahl_text(71.6, "°C") == "72 °C"
+1 -1
View File
@@ -223,7 +223,7 @@ print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "date
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste",
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher",
"pruefe_zertifikate", "pruefe_docker", "pruefe_platten_gesundheit",
"pruefe_netzgeraete"]
"pruefe_ausreisser", "pruefe_netzgeraete"]
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):