Files
mission-control-v2/backend/services/messwerte.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

187 lines
8.3 KiB
Python

"""Messwerte der KI-Box mit Verlauf (Monitoring, seit 24.09.2026).
Der Ereignisstrom (/api/stream) zeigt nur den Augenblick. Für den Verlauf schreibt der Steward (Rolle box, eigener
Prozess, übersteht MC2-Neustarts) jede Minute zur halben Minute einen Punkt über kern/messreihen.py (Quelle „box“):
cpu Mittel der Minute in % (Rechnung wie psutil.cpu_percent(interval=None), aber aus eigenen
cpu_times-Ständen: psutil merkt sich den letzten Aufruf je Thread, und die Messung läuft in wechselnden
Threads des Event-Loops)
ram, platte Belegung in % beim Messen (platte = das Modell-Laufwerk, wie im Cockpit)
gpu Mittel der Proben alle PROBE_S Sekunden in %: gpu_busy_percent zeigt nur den Augenblick, eine Probe je
Minute träfe die kurzen Antworten der Modelle kaum
temp_cpu/_gpu Mittel derselben Proben in °C
netz_rx/_tx Bytes/s über die Minute, alle Schnittstellen außer lo
tokens Prompt- plus Antwort-Tokens pro Minute (Differenz der Gesamtzähler aus services.token_stats)
Ein Zähler, der kleiner wird (Neustart des Gateways, neue Token-Datei), ergibt für diese Minute keine Rate (null).
GET /api/messwerte (routers/messwerte.py) liefert daraus die Reihen für 1h, 24h und 7d und den letzten Punkt.
"""
import asyncio
import logging
import os
import time
from collections.abc import Callable
import psutil
from config import MODELS_DIR
from kern import messreihen, prognose
from services import system, token_stats
log = logging.getLogger(__name__)
QUELLE = "box"
REIHEN = ("cpu", "ram", "gpu", "temp_cpu", "temp_gpu", "platte", "netz_rx", "netz_tx", "tokens")
# Ein Trocken- oder Probelauf neben dem echten Steward schreibt nicht mit (sonst stünden zwei Punkte je Minute da).
ENABLED = (os.environ.get("MC_MESSWERTE_ENABLED", "1") != "0" and os.environ.get("MC_WAECHTER_TROCKEN", "") != "1"
and os.environ.get("MC_PROBELAUF", "") != "1")
PROBE_S = float(os.environ.get("MC_MESSWERTE_PROBE_S", "5"))
PHASE_S = 30 # gemessen wird zur halben Minute: jeder 60-s-Schritt bekommt genau einen Punkt
AKTUELL_MAX_S = 180 # älter ist der letzte Punkt nicht „aktuell“ (der Steward schreibt gerade nicht)
# --- Rohwerte ------------------------------------------------------------------------------------
def _cpu_zeiten() -> tuple[float, float] | None:
"""(beschäftigt, gesamt) in Sekunden seit dem Start — gezählt wie psutil.cpu_percent (guest steckt in user)."""
try:
z = psutil.cpu_times()
except Exception:
return None
gesamt = sum(z) - getattr(z, "guest", 0.0) - getattr(z, "guest_nice", 0.0)
return gesamt - z.idle - getattr(z, "iowait", 0.0), gesamt
def cpu_prozent(vorher: tuple[float, float] | None, jetzt: tuple[float, float] | None) -> float | None:
if not vorher or not jetzt or jetzt[1] - vorher[1] <= 0:
return None
anteil = (jetzt[0] - vorher[0]) / (jetzt[1] - vorher[1])
return round(min(100.0, max(0.0, anteil * 100)), 1)
def _netz() -> tuple[int, int] | None:
"""Empfangene und gesendete Bytes aller Schnittstellen außer lo (Gesamtzähler)."""
try:
zaehler = psutil.net_io_counters(pernic=True)
except Exception:
return None
return (sum(z.bytes_recv for name, z in zaehler.items() if name != "lo"),
sum(z.bytes_sent for name, z in zaehler.items() if name != "lo"))
def _tokens() -> int | None:
"""Prompt- plus Antwort-Tokens seit Beginn der Zählung (schreibt der Gateway-Prozess)."""
try:
stand = token_stats.get_stats()
return int(stand.get("prompt_tokens") or 0) + int(stand.get("completion_tokens") or 0)
except Exception:
return None
def _platte() -> float | None:
try:
return round(psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent, 1)
except Exception:
return None
def _mittel(werte: list[float]) -> float | None:
return round(sum(werte) / len(werte), 1) if werte else None
def _runden(wert: float | None, stellen: int | None = None) -> float | int | None:
return None if wert is None else round(wert, stellen)
# --- Die Minute --------------------------------------------------------------------------------------
class Messer:
"""Was zwischen zwei Minutenpunkten mitläuft: die Stände der Zähler und die Proben."""
def __init__(self, uhr: Callable[[], float] = time.monotonic) -> None:
self._uhr = uhr
self._seit = uhr()
self._cpu = _cpu_zeiten()
self._netz = _netz()
self._tokens = _tokens()
self._proben: dict[str, list[float]] = {"gpu": [], "temp_cpu": [], "temp_gpu": []}
def probe(self) -> None:
"""GPU-Last und Temperaturen einmal ablesen (sysfs, Bruchteile einer Millisekunde)."""
gpu = system._gpu_sysfs() or {}
temp = system._temps() or {}
for name, wert in (("gpu", gpu.get("busy_percent")), ("temp_cpu", temp.get("cpu")),
("temp_gpu", temp.get("gpu"))):
if messreihen.ist_zahl(wert):
self._proben[name].append(float(wert))
def punkt(self) -> dict[str, float | int | None]:
"""Der Punkt dieser Minute; danach beginnt die nächste."""
self.probe()
jetzt = self._uhr()
dt = jetzt - self._seit
cpu, netz, tokens = _cpu_zeiten(), _netz(), _tokens()
try:
ram = round(psutil.virtual_memory().percent, 1)
except Exception:
ram = None
tok_s = messreihen.rate(self._tokens, tokens, dt)
werte = {
"cpu": cpu_prozent(self._cpu, cpu),
"ram": ram,
"gpu": _mittel(self._proben["gpu"]),
"temp_cpu": _mittel(self._proben["temp_cpu"]),
"temp_gpu": _mittel(self._proben["temp_gpu"]),
"platte": _platte(),
"netz_rx": _runden(messreihen.rate(self._netz[0], netz[0], dt)) if self._netz and netz else None,
"netz_tx": _runden(messreihen.rate(self._netz[1], netz[1], dt)) if self._netz and netz else None,
"tokens": None if tok_s is None else round(tok_s * 60, 1),
}
self._seit, self._cpu, self._netz, self._tokens = jetzt, cpu, netz, tokens
for proben in self._proben.values():
proben.clear()
return werte
def naechste_messung(jetzt: float) -> float:
"""Die nächste halbe Minute, mindestens eine Sekunde entfernt."""
ziel = jetzt - jetzt % 60 + PHASE_S
return ziel if ziel > jetzt + 1 else ziel + 60
async def messwerte_loop() -> None:
"""Im mc2-steward (Rolle box): Proben alle PROBE_S Sekunden, ein Punkt je Minute. Fehler kosten einen Punkt, nie
die Schleife."""
messer = await asyncio.to_thread(Messer)
naechste = naechste_messung(time.time())
log.info("messwerte: aktiv (ein Punkt je Minute nach %s, Proben alle %ss)", messreihen.ordner(), PROBE_S)
while True:
await asyncio.sleep(max(0.2, min(PROBE_S, naechste - time.time())))
try:
if time.time() >= naechste:
werte = await asyncio.to_thread(messer.punkt)
await asyncio.to_thread(messreihen.schreiben, QUELLE, werte)
naechste = naechste_messung(time.time())
else:
await asyncio.to_thread(messer.probe)
except Exception:
log.warning("messwerte: Messung fehlgeschlagen", exc_info=True)
naechste = naechste_messung(time.time())
# --- Lesen (MC2-Prozess) -------------------------------------------------------------------------------
ZEITRAEUME = messreihen.ZEITRAEUME
def abfrage(zeitraum: str, jetzt: float | None = None) -> dict:
"""Für GET /api/messwerte: {"zeitraum", "schritt_s", "reihen": {…}, "aktuell": {…}, "prognose": {"platte": …}}.
aktuell ist der letzte Minutenpunkt, solange er höchstens AKTUELL_MAX_S alt ist, sonst lauter null; prognose
die Vorhersage „voll in etwa N Tagen“ des Modell-Laufwerks (kern/prognose.py, seit 25.09.2026)."""
zeit = time.time() if jetzt is None else jetzt
daten = messreihen.lesen(QUELLE, zeitraum, REIHEN, zeit)
letzter = messreihen.letzter_punkt(QUELLE, zeit, AKTUELL_MAX_S) or {}
return {**daten, "aktuell": {name: letzter.get(name) for name in REIHEN},
"prognose": {"platte": prognose.aus_messreihe(QUELLE, "platte", jetzt=jetzt)}}