scorer.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. # -*- coding: utf-8 -*-
  2. """
  3. Health-Scoring für Satellites.
  4. Berechnet zusammengesetzte Gesundheits-Scores basierend auf
  5. verschiedenen Metriken wie Latenz, Paketverlust, Uptime etc.
  6. """
  7. import time
  8. from collections import deque
  9. from dataclasses import dataclass, field
  10. from datetime import datetime
  11. from enum import IntEnum, auto
  12. from typing import Any, Callable, TYPE_CHECKING
  13. if TYPE_CHECKING:
  14. from trixy_core.satellite.satellite import Satellite
  15. class HealthState(IntEnum):
  16. """Gesundheitszustand eines Satellites."""
  17. HEALTHY = auto() # Alles in Ordnung
  18. DEGRADED = auto() # Eingeschränkt, aber funktionsfähig
  19. UNHEALTHY = auto() # Problematisch
  20. CRITICAL = auto() # Kritisch, sollte nicht verwendet werden
  21. UNKNOWN = auto() # Keine Daten verfügbar
  22. @dataclass
  23. class HealthMetrics:
  24. """
  25. Gesammelte Gesundheitsmetriken.
  26. Attributes:
  27. latency_ms: Durchschnittliche Latenz in Millisekunden
  28. packet_loss: Paketverlustrate (0.0-1.0)
  29. uptime: Uptime-Prozentsatz (0.0-1.0)
  30. error_rate: Fehlerrate (0.0-1.0)
  31. memory_usage: Speicherauslastung (0.0-1.0)
  32. cpu_usage: CPU-Auslastung (0.0-1.0)
  33. last_heartbeat_age: Zeit seit letztem Heartbeat in Sekunden
  34. connection_quality: Verbindungsqualität (0.0-1.0)
  35. custom_metrics: Zusätzliche benutzerdefinierte Metriken
  36. """
  37. latency_ms: float = 0.0
  38. packet_loss: float = 0.0
  39. uptime: float = 1.0
  40. error_rate: float = 0.0
  41. memory_usage: float = 0.0
  42. cpu_usage: float = 0.0
  43. last_heartbeat_age: float = 0.0
  44. connection_quality: float = 1.0
  45. custom_metrics: dict[str, float] = field(default_factory=dict)
  46. timestamp: datetime = field(default_factory=datetime.now)
  47. @dataclass
  48. class HealthScore:
  49. """
  50. Berechneter Gesundheits-Score.
  51. Attributes:
  52. overall: Gesamtscore (0.0-100.0)
  53. state: Abgeleiteter Zustand
  54. components: Einzelne Komponenten-Scores
  55. metrics: Die zugrundeliegenden Metriken
  56. warnings: Warnungen
  57. calculated_at: Berechnungszeitpunkt
  58. """
  59. overall: float
  60. state: HealthState
  61. components: dict[str, float]
  62. metrics: HealthMetrics
  63. warnings: list[str] = field(default_factory=list)
  64. calculated_at: datetime = field(default_factory=datetime.now)
  65. @property
  66. def is_healthy(self) -> bool:
  67. """Prüft, ob der Satellite gesund ist."""
  68. return self.state in (HealthState.HEALTHY, HealthState.DEGRADED)
  69. @property
  70. def is_usable(self) -> bool:
  71. """Prüft, ob der Satellite verwendbar ist."""
  72. return self.state != HealthState.CRITICAL
  73. def to_dict(self) -> dict[str, Any]:
  74. """Konvertiert zu Dictionary."""
  75. return {
  76. "overall": self.overall,
  77. "state": self.state.name,
  78. "components": self.components,
  79. "warnings": self.warnings,
  80. "is_healthy": self.is_healthy,
  81. "is_usable": self.is_usable,
  82. "calculated_at": self.calculated_at.isoformat(),
  83. }
  84. class HealthScorer:
  85. """
  86. Berechnet Gesundheits-Scores für Satellites.
  87. Kombiniert verschiedene Metriken zu einem Gesamtscore.
  88. Gewichtung der Metriken ist konfigurierbar.
  89. Example:
  90. scorer = HealthScorer()
  91. # Metriken aktualisieren
  92. scorer.update_metrics("satellite_1", HealthMetrics(
  93. latency_ms=50.0,
  94. packet_loss=0.01,
  95. uptime=0.999,
  96. error_rate=0.001
  97. ))
  98. # Score berechnen
  99. score = scorer.calculate_score("satellite_1")
  100. print(f"Gesamtscore: {score.overall}")
  101. print(f"Zustand: {score.state.name}")
  102. # Alle Satellites bewerten
  103. for satellite_id, score in scorer.get_all_scores().items():
  104. print(f"{satellite_id}: {score.overall}")
  105. """
  106. # Standard-Gewichte für Metriken
  107. DEFAULT_WEIGHTS = {
  108. "latency": 0.20,
  109. "packet_loss": 0.15,
  110. "uptime": 0.15,
  111. "error_rate": 0.15,
  112. "heartbeat": 0.15,
  113. "connection_quality": 0.10,
  114. "resources": 0.10,
  115. }
  116. # Schwellenwerte für Zustandsbestimmung
  117. DEFAULT_THRESHOLDS = {
  118. HealthState.HEALTHY: 80.0,
  119. HealthState.DEGRADED: 60.0,
  120. HealthState.UNHEALTHY: 40.0,
  121. HealthState.CRITICAL: 0.0,
  122. }
  123. def __init__(
  124. self,
  125. weights: dict[str, float] | None = None,
  126. thresholds: dict[HealthState, float] | None = None,
  127. history_size: int = 100,
  128. ) -> None:
  129. """
  130. Initialisiert den HealthScorer.
  131. Args:
  132. weights: Gewichtung der Metriken
  133. thresholds: Schwellenwerte für Zustände
  134. history_size: Anzahl der zu speichernden historischen Werte
  135. """
  136. self._weights = weights or self.DEFAULT_WEIGHTS.copy()
  137. self._thresholds = thresholds or self.DEFAULT_THRESHOLDS.copy()
  138. self._history_size = history_size
  139. self._metrics: dict[str, HealthMetrics] = {}
  140. self._history: dict[str, deque[HealthScore]] = {}
  141. self._custom_scorers: dict[str, Callable[[HealthMetrics], float]] = {}
  142. def update_metrics(self, satellite_id: str, metrics: HealthMetrics) -> None:
  143. """
  144. Aktualisiert die Metriken für einen Satellite.
  145. Args:
  146. satellite_id: Satellite-ID
  147. metrics: Neue Metriken
  148. """
  149. metrics.timestamp = datetime.now()
  150. self._metrics[satellite_id] = metrics
  151. def update_metric(
  152. self,
  153. satellite_id: str,
  154. metric_name: str,
  155. value: float
  156. ) -> None:
  157. """
  158. Aktualisiert eine einzelne Metrik.
  159. Args:
  160. satellite_id: Satellite-ID
  161. metric_name: Name der Metrik
  162. value: Neuer Wert
  163. """
  164. if satellite_id not in self._metrics:
  165. self._metrics[satellite_id] = HealthMetrics()
  166. metrics = self._metrics[satellite_id]
  167. if hasattr(metrics, metric_name):
  168. setattr(metrics, metric_name, value)
  169. else:
  170. metrics.custom_metrics[metric_name] = value
  171. metrics.timestamp = datetime.now()
  172. def add_custom_scorer(
  173. self,
  174. name: str,
  175. scorer: Callable[[HealthMetrics], float],
  176. weight: float = 0.1
  177. ) -> None:
  178. """
  179. Fügt einen benutzerdefinierten Scorer hinzu.
  180. Args:
  181. name: Name des Scorers
  182. scorer: Funktion die Metriken -> Score (0-100) umwandelt
  183. weight: Gewicht des Scorers
  184. """
  185. self._custom_scorers[name] = scorer
  186. self._weights[name] = weight
  187. def calculate_score(self, satellite_id: str) -> HealthScore:
  188. """
  189. Berechnet den Gesundheits-Score.
  190. Args:
  191. satellite_id: Satellite-ID
  192. Returns:
  193. HealthScore
  194. """
  195. metrics = self._metrics.get(satellite_id)
  196. if metrics is None:
  197. return HealthScore(
  198. overall=0.0,
  199. state=HealthState.UNKNOWN,
  200. components={},
  201. metrics=HealthMetrics(),
  202. warnings=["Keine Metriken verfügbar"],
  203. )
  204. components = {}
  205. warnings = []
  206. # Latenz-Score (niedrigere Latenz = höherer Score)
  207. if "latency" in self._weights:
  208. latency_score = self._calculate_latency_score(metrics.latency_ms)
  209. components["latency"] = latency_score
  210. if latency_score < 50:
  211. warnings.append(f"Hohe Latenz: {metrics.latency_ms:.0f}ms")
  212. # Paketverlust-Score
  213. if "packet_loss" in self._weights:
  214. loss_score = (1.0 - metrics.packet_loss) * 100
  215. components["packet_loss"] = loss_score
  216. if metrics.packet_loss > 0.05:
  217. warnings.append(f"Hoher Paketverlust: {metrics.packet_loss*100:.1f}%")
  218. # Uptime-Score
  219. if "uptime" in self._weights:
  220. uptime_score = metrics.uptime * 100
  221. components["uptime"] = uptime_score
  222. if metrics.uptime < 0.99:
  223. warnings.append(f"Niedrige Uptime: {metrics.uptime*100:.1f}%")
  224. # Fehlerrate-Score
  225. if "error_rate" in self._weights:
  226. error_score = (1.0 - metrics.error_rate) * 100
  227. components["error_rate"] = error_score
  228. if metrics.error_rate > 0.05:
  229. warnings.append(f"Hohe Fehlerrate: {metrics.error_rate*100:.1f}%")
  230. # Heartbeat-Score
  231. if "heartbeat" in self._weights:
  232. heartbeat_score = self._calculate_heartbeat_score(metrics.last_heartbeat_age)
  233. components["heartbeat"] = heartbeat_score
  234. if heartbeat_score < 50:
  235. warnings.append(f"Heartbeat veraltet: {metrics.last_heartbeat_age:.0f}s")
  236. # Verbindungsqualität
  237. if "connection_quality" in self._weights:
  238. conn_score = metrics.connection_quality * 100
  239. components["connection_quality"] = conn_score
  240. # Ressourcen-Score
  241. if "resources" in self._weights:
  242. resource_score = self._calculate_resource_score(
  243. metrics.cpu_usage,
  244. metrics.memory_usage
  245. )
  246. components["resources"] = resource_score
  247. if resource_score < 50:
  248. warnings.append("Hohe Ressourcenauslastung")
  249. # Benutzerdefinierte Scorer
  250. for name, scorer in self._custom_scorers.items():
  251. try:
  252. score = scorer(metrics)
  253. components[name] = max(0, min(100, score))
  254. except Exception:
  255. components[name] = 50.0 # Default bei Fehler
  256. # Gewichteter Gesamtscore
  257. overall = self._calculate_weighted_score(components)
  258. # Zustand bestimmen
  259. state = self._determine_state(overall)
  260. score = HealthScore(
  261. overall=overall,
  262. state=state,
  263. components=components,
  264. metrics=metrics,
  265. warnings=warnings,
  266. )
  267. # In Historie speichern
  268. self._add_to_history(satellite_id, score)
  269. return score
  270. def _calculate_latency_score(self, latency_ms: float) -> float:
  271. """Berechnet den Latenz-Score."""
  272. if latency_ms <= 0:
  273. return 100.0
  274. elif latency_ms <= 50:
  275. return 100.0
  276. elif latency_ms <= 100:
  277. return 90.0 - (latency_ms - 50) * 0.2
  278. elif latency_ms <= 200:
  279. return 80.0 - (latency_ms - 100) * 0.3
  280. elif latency_ms <= 500:
  281. return 50.0 - (latency_ms - 200) * 0.1
  282. else:
  283. return max(0.0, 20.0 - (latency_ms - 500) * 0.02)
  284. def _calculate_heartbeat_score(self, age_seconds: float) -> float:
  285. """Berechnet den Heartbeat-Score."""
  286. if age_seconds <= 5:
  287. return 100.0
  288. elif age_seconds <= 10:
  289. return 90.0
  290. elif age_seconds <= 30:
  291. return 70.0 - (age_seconds - 10) * 1.0
  292. elif age_seconds <= 60:
  293. return 50.0 - (age_seconds - 30) * 0.5
  294. else:
  295. return max(0.0, 35.0 - (age_seconds - 60) * 0.3)
  296. def _calculate_resource_score(self, cpu: float, memory: float) -> float:
  297. """Berechnet den Ressourcen-Score."""
  298. avg_usage = (cpu + memory) / 2
  299. return (1.0 - avg_usage) * 100
  300. def _calculate_weighted_score(self, components: dict[str, float]) -> float:
  301. """Berechnet den gewichteten Gesamtscore."""
  302. total_weight = 0.0
  303. weighted_sum = 0.0
  304. for name, score in components.items():
  305. weight = self._weights.get(name, 0.0)
  306. weighted_sum += score * weight
  307. total_weight += weight
  308. if total_weight > 0:
  309. return weighted_sum / total_weight
  310. return 0.0
  311. def _determine_state(self, overall: float) -> HealthState:
  312. """Bestimmt den Zustand basierend auf dem Score."""
  313. if overall >= self._thresholds[HealthState.HEALTHY]:
  314. return HealthState.HEALTHY
  315. elif overall >= self._thresholds[HealthState.DEGRADED]:
  316. return HealthState.DEGRADED
  317. elif overall >= self._thresholds[HealthState.UNHEALTHY]:
  318. return HealthState.UNHEALTHY
  319. else:
  320. return HealthState.CRITICAL
  321. def _add_to_history(self, satellite_id: str, score: HealthScore) -> None:
  322. """Fügt einen Score zur Historie hinzu."""
  323. if satellite_id not in self._history:
  324. self._history[satellite_id] = deque(maxlen=self._history_size)
  325. self._history[satellite_id].append(score)
  326. def get_history(self, satellite_id: str) -> list[HealthScore]:
  327. """Gibt die Score-Historie zurück."""
  328. return list(self._history.get(satellite_id, []))
  329. def get_average_score(self, satellite_id: str, samples: int = 10) -> float:
  330. """
  331. Berechnet den Durchschnittsscore über die letzten N Samples.
  332. Args:
  333. satellite_id: Satellite-ID
  334. samples: Anzahl Samples
  335. Returns:
  336. Durchschnittsscore
  337. """
  338. history = self._history.get(satellite_id, [])
  339. if not history:
  340. return 0.0
  341. recent = list(history)[-samples:]
  342. return sum(s.overall for s in recent) / len(recent)
  343. def get_all_scores(self) -> dict[str, HealthScore]:
  344. """Berechnet und gibt alle Scores zurück."""
  345. return {
  346. satellite_id: self.calculate_score(satellite_id)
  347. for satellite_id in self._metrics.keys()
  348. }
  349. def get_healthy_satellites(
  350. self,
  351. min_state: HealthState = HealthState.DEGRADED
  352. ) -> list[str]:
  353. """
  354. Gibt alle Satellites mit mindestens dem angegebenen Zustand zurück.
  355. Args:
  356. min_state: Minimaler akzeptabler Zustand
  357. Returns:
  358. Liste der Satellite-IDs
  359. """
  360. result = []
  361. for satellite_id in self._metrics.keys():
  362. score = self.calculate_score(satellite_id)
  363. if score.state <= min_state:
  364. result.append(satellite_id)
  365. return result
  366. def remove_satellite(self, satellite_id: str) -> None:
  367. """Entfernt einen Satellite aus dem Scoring."""
  368. self._metrics.pop(satellite_id, None)
  369. self._history.pop(satellite_id, None)
  370. def clear(self) -> None:
  371. """Löscht alle Daten."""
  372. self._metrics.clear()
  373. self._history.clear()