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