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
+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"