Files
mission-control-v2/backend/kern/prognose.py
T
HitonabiandClaude Opus 5.5 f7a9b8ad50 Welle 1 · Vorhersage „voll in etwa N Tagen“: Pool, NAS, Systemplatte, Container, VMs und die Platte der KI-Box
Aus dem Verlauf der Woche (Tagesmittel, Schutz gegen einmalige Sprünge, schneller werdendes Volllaufen zählt sofort):
gelb unter 14, rot unter 3 Tagen. Der Wächter meldet es, auch wenn der Füllstand selbst noch unauffällig ist;
Speicherkarte, Geräte-Tabelle, Instrumente der Übersicht und die KI-Box-Seite zeigen es an. Jeder Bericht des
Ausführers schreibt dafür die Belegungen in die Messreihe „speicher“.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:21:38 +02:00

159 lines
7.6 KiB
Python

"""Füllstands-Vorhersage „voll in etwa N Tagen“ (Ausbauplan Welle 1, seit 25.09.2026).
Die Schwellen bei 80 und 90 % melden sich erst, wenn es schon eng ist. Die Vorhersage schaut auf das Tempo: Wächst
eine Belegung weiter wie zuletzt, wann ist sie voll? Gelb unter GELB_TAGE, rot unter ROT_TAGE Tagen — so bleibt Zeit,
Platz zu schaffen, bevor etwas stillsteht.
Grundlage sind die 30-min-Mittel der letzten sieben Tage aus kern/messreihen.py (Belegung in %). Daraus werden
Tagesmittel über 24-Stunden-Fenster (das jüngste endet jetzt); ein Fenster zählt ab sechs Stunden mit Messungen.
Die Rate in Prozentpunkten am Tag:
• Normalfall: die Steigung der Ausgleichsgeraden durch die Tagesmittel der Woche.
• Das Wachstum stockt — der letzte Tag wuchs um weniger als ein Viertel der Wochenrate, etwa nach einem großen
Download, der einmal kam: Dann gilt der letzte Tag (mindestens 0). Ein einzelner Sprung hält so keinen Alarm.
• Es wird schneller — die letzten beiden Tage wuchsen beide stärker als die Woche, und in der Woche wurde nichts
freigeräumt: Dann gilt ihr Mittel. Ein Protokoll, das gerade vollläuft, wartet so nicht eine Woche.
Unter MIN_RATE gilt die Belegung als gleichbleibend. Ohne die beiden jüngsten Fenster und ein drittes (also rund zwei
Tage Verlauf) oder ohne Messung in den letzten zwei Stunden gibt es keine Vorhersage.
„Voll“ ist nicht überall 100 %: Proxmox rechnet bei Containern und beim Host die für root reservierten Blöcke von
ext4 (5 %) als belegt — dort ist für Dienste schon bei rund 95 % Schluss (AdGuard am 24.09.2026). Das gibt der
Aufrufer mit (voll_bei).
"""
import math
import threading
import time
from kern import messreihen
TAG_S = 86400
FENSTER = 7 # Tagesfenster in der Woche
MIN_WERTE = 12 # 30-min-Mittel je Fenster: sechs Stunden mit Messungen
FRISCH_S = 2 * 3600 # so alt darf die jüngste Messung höchstens sein
MIN_RATE = 0.05 # Prozentpunkte am Tag; darunter „gleichbleibend“
STOCKT = 0.25 # der letzte Tag wuchs um weniger als dieser Anteil der Wochenrate
FREIGERAEUMT = -0.5 # ein Tag, der um mehr als so viele Prozentpunkte sank, hat freigeräumt
GELB_TAGE, ROT_TAGE = 14, 3
MERKEN_S = 600 # je Reihe höchstens alle zehn Minuten neu rechnen (der Wächter fragt jede Minute)
VOLL_EXT4 = 95.0 # Container und Proxmox-Host: siehe oben
_lock = threading.Lock()
_merk: dict[tuple[str, str, float], tuple[float, dict]] = {}
def tagesmittel(reihe: list, jetzt: float) -> dict[int, float]:
"""Fenster k (0 = die letzten 24 Stunden, 1 = die 24 davor …) → Mittel der Werte darin. Nur Fenster mit
mindestens MIN_WERTE Werten. reihe: [[t, wert], …] wie messreihen.lesen() sie liefert."""
summen: dict[int, list[float]] = {}
for t, wert in reihe:
if not messreihen.ist_zahl(wert):
continue
k = int((jetzt - t) // TAG_S)
if 0 <= k < FENSTER:
summe = summen.setdefault(k, [0.0, 0])
summe[0] += wert
summe[1] += 1
return {k: s[0] / s[1] for k, s in summen.items() if s[1] >= MIN_WERTE}
def _steigung(punkte: list[tuple[float, float]]) -> float:
"""Steigung der Ausgleichsgeraden (kleinste Quadrate)."""
mx = sum(x for x, _ in punkte) / len(punkte)
my = sum(y for _, y in punkte) / len(punkte)
sxx = sum((x - mx) ** 2 for x, _ in punkte)
return sum((x - mx) * (y - my) for x, y in punkte) / sxx if sxx else 0.0
def vorhersage(reihe: list, jetzt: float, voll_bei: float = 100.0) -> dict:
"""{"tage": Tage bis voll oder None, "rate": Prozentpunkte am Tag oder None, "anteil": jüngstes Mittel in % oder
None, "grund": None | "verlauf" (zu wenig Verlauf) | "gleich" (wächst nicht)}."""
werte = [(t, w) for t, w in reihe if messreihen.ist_zahl(w)]
if not werte or jetzt - werte[-1][0] > FRISCH_S:
return {"tage": None, "rate": None, "anteil": None, "grund": "verlauf"}
anteil = float(werte[-1][1])
mittel = tagesmittel(werte, jetzt)
if 0 not in mittel or 1 not in mittel or len(mittel) < 3:
return {"tage": None, "rate": None, "anteil": anteil, "grund": "verlauf"}
woche = _steigung([(-k, m) for k, m in mittel.items()])
zuletzt = mittel[0] - mittel[1]
davor = mittel[1] - mittel[2] if 2 in mittel else None
schritte = [mittel[k] - mittel[k + 1] for k in range(FENSTER - 1) if k in mittel and k + 1 in mittel]
rate = woche
if zuletzt < STOCKT * woche:
rate = max(zuletzt, 0.0)
elif davor is not None and zuletzt > woche and davor > woche and min(schritte) > FREIGERAEUMT:
rate = (zuletzt + davor) / 2
if rate < MIN_RATE:
return {"tage": None, "rate": round(rate, 2), "anteil": anteil, "grund": "gleich"}
return {"tage": round(max(0.0, (voll_bei - anteil) / rate), 1), "rate": round(rate, 2), "anteil": anteil,
"grund": None}
def aus_messreihe(quelle: str, name: str, voll_bei: float = 100.0, jetzt: float | None = None) -> dict:
"""Die Vorhersage für eine Reihe aus kern/messreihen.py. Ohne jetzt (Betrieb) je Reihe höchstens alle MERKEN_S
neu gerechnet; mit jetzt (Tests) immer frisch."""
betrieb = jetzt is None
jetzt = time.time() if jetzt is None else jetzt
schluessel = (quelle, name, voll_bei)
if betrieb:
with _lock:
gemerkt = _merk.get(schluessel)
if gemerkt and 0 <= jetzt - gemerkt[0] < MERKEN_S:
return gemerkt[1]
ergebnis = vorhersage(messreihen.lesen(quelle, "7d", [name], jetzt)["reihen"][name], jetzt, voll_bei)
if betrieb:
with _lock:
_merk[schluessel] = (jetzt, ergebnis)
return ergebnis
def stufe(v: dict | None) -> str:
"""„rot“ unter ROT_TAGE, „gelb“ unter GELB_TAGE Tagen bis voll, sonst „ok“."""
tage = (v or {}).get("tage")
if tage is None:
return "ok"
return "rot" if tage < ROT_TAGE else "gelb" if tage < GELB_TAGE else "ok"
def in_tagen(tage: float) -> str:
"""„in weniger als einem Tag“, „in etwa 9 Tagen“, „in etwa 5 Wochen“ … — für Hinweise."""
if tage < 1:
return "in weniger als einem Tag"
if tage < 1.5:
return "in etwa einem Tag"
if tage < 21:
return f"in etwa {round(tage)} Tagen"
if tage < 90:
return f"in etwa {round(tage / 7)} Wochen"
return f"in etwa {round(tage / 30)} Monaten" if tage < 365 else "frühestens in einem Jahr"
def tempo(v: dict) -> str:
"""„Die Belegung wächst um etwa 1,2 % am Tag (jetzt 78 %).“"""
rate = f"{v['rate']:.1f}".replace(".", ",")
return f"Die Belegung wächst um etwa {rate} % am Tag (jetzt {round(v['anteil'])} %)."
def bis_voll(v: dict | None) -> float:
"""Tage bis voll; unendlich, wenn es keine Vorhersage gibt."""
return v["tage"] if v and v.get("tage") is not None else math.inf
IM_TEXT_TAGE = 60 # weiter weg sagt ein Hinweis zum Füllstand nicht mehr dazu, wann es voll wäre
def dazu(v: dict | None) -> str:
"""„ Bei diesem Tempo voll in etwa 9 Tagen.“ — der Zusatz für Hinweise, die schon der Füllstand auslöst."""
tage = bis_voll(v)
return f" Bei diesem Tempo voll {in_tagen(tage)}." if tage < IM_TEXT_TAGE else ""
RANG = {"ok": 0, "gelb": 1, "rot": 2}
def schlimmer(stufe_jetzt: str | None, v: dict | None) -> tuple[str, bool]:
"""Die ernstere Stufe aus Füllstand (None/„ok“ = unauffällig) und Vorhersage — und ob die Vorhersage sie bestimmt
(dann gehört „voll in etwa N Tagen“ in den Titel)."""
jetzt, kuenftig = stufe_jetzt or "ok", stufe(v)
return (kuenftig, True) if RANG[kuenftig] > RANG.get(jetzt, 0) else (jetzt, False)