Coverage for openhcs/pyqt_gui/services/persistent_system_monitor.py: 0.0%

151 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 02:09 +0000

1""" 

2Persistent System Monitor for PyQt GUI 

3 

4Uses a single persistent QThread to continuously collect system metrics 

5without creating/destroying threads repeatedly. This prevents UI hanging 

6and provides smooth, responsive performance monitoring. 

7""" 

8 

9import time 

10import logging 

11import subprocess 

12import platform 

13from typing import Dict, Any 

14from collections import deque 

15 

16from PyQt6.QtCore import QThread, pyqtSignal, QMutex, QMutexLocker 

17 

18import psutil 

19 

20try: 

21 import GPUtil 

22 GPU_AVAILABLE = True 

23except ImportError: 

24 GPU_AVAILABLE = False 

25 

26logger = logging.getLogger(__name__) 

27 

28 

29def is_wsl() -> bool: 

30 """Check if running in Windows Subsystem for Linux.""" 

31 return 'microsoft' in platform.uname().release.lower() 

32 

33 

34def get_cpu_freq_mhz() -> int: 

35 """Get CPU frequency in MHz, with WSL compatibility.""" 

36 if is_wsl(): 

37 try: 

38 output = subprocess.check_output( 

39 ['powershell.exe', '-Command', 

40 'Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty CurrentClockSpeed'], 

41 stderr=subprocess.DEVNULL, 

42 timeout=2 # Add timeout to prevent hanging 

43 ) 

44 return int(output.strip()) 

45 except Exception: 

46 return 0 

47 try: 

48 freq = psutil.cpu_freq() 

49 return int(freq.current) if freq else 0 

50 except Exception: 

51 return 0 

52 

53 

54class PersistentSystemMonitorThread(QThread): 

55 """ 

56 Persistent thread that continuously collects system metrics. 

57  

58 This thread stays alive and runs a continuous loop, collecting metrics 

59 at regular intervals and emitting signals with the results. This is much 

60 more efficient than creating/destroying threads repeatedly. 

61 """ 

62 

63 # Signals 

64 metrics_updated = pyqtSignal(dict) # Emitted when new metrics are available 

65 error_occurred = pyqtSignal(str) # Emitted when an error occurs 

66 

67 def __init__(self, update_interval: float = 1.0, history_length: int = 60): 

68 """ 

69 Initialize the persistent monitor thread. 

70  

71 Args: 

72 update_interval: Time between updates in seconds 

73 history_length: Number of historical data points to keep 

74 """ 

75 super().__init__() 

76 

77 self.update_interval = update_interval 

78 self.history_length = history_length 

79 self._stop_requested = False 

80 

81 # Thread-safe data storage 

82 self._mutex = QMutex() 

83 self.cpu_history = deque(maxlen=history_length) 

84 self.ram_history = deque(maxlen=history_length) 

85 self.gpu_history = deque(maxlen=history_length) 

86 self.vram_history = deque(maxlen=history_length) 

87 self.time_stamps = deque(maxlen=history_length) 

88 

89 # Initialize with zeros 

90 for _ in range(history_length): 

91 self.cpu_history.append(0) 

92 self.ram_history.append(0) 

93 self.gpu_history.append(0) 

94 self.vram_history.append(0) 

95 self.time_stamps.append(0) 

96 

97 # Cache for current metrics 

98 self._current_metrics: Dict[str, Any] = {} 

99 

100 def run(self): 

101 """Main thread loop - continuously collect metrics.""" 

102 logger.debug("Persistent system monitor thread started") 

103 

104 while not self._stop_requested: 

105 try: 

106 # Collect all metrics 

107 metrics = self._collect_metrics() 

108 

109 # Update history with thread safety 

110 with QMutexLocker(self._mutex): 

111 self.cpu_history.append(metrics.get('cpu_percent', 0)) 

112 self.ram_history.append(metrics.get('ram_percent', 0)) 

113 self.gpu_history.append(metrics.get('gpu_percent', 0)) 

114 self.vram_history.append(metrics.get('vram_percent', 0)) 

115 self.time_stamps.append(time.time()) 

116 

117 # Cache current metrics 

118 self._current_metrics = metrics.copy() 

119 

120 # Emit signal with new metrics 

121 self.metrics_updated.emit(metrics) 

122 

123 # Sleep for the update interval with frequent stop checks 

124 sleep_ms = int(self.update_interval * 1000) 

125 sleep_chunks = max(1, sleep_ms // 100) # Check every 100ms 

126 chunk_size = sleep_ms // sleep_chunks 

127 

128 for _ in range(sleep_chunks): 

129 if self._stop_requested: 

130 break 

131 self.msleep(chunk_size) 

132 

133 except Exception as e: 

134 logger.warning(f"Error collecting system metrics: {e}") 

135 self.error_occurred.emit(str(e)) 

136 # Sleep longer on error to avoid spam, but still check for stop 

137 for _ in range(20): # 20 * 100ms = 2 seconds 

138 if self._stop_requested: 

139 break 

140 self.msleep(100) 

141 

142 logger.debug("Persistent system monitor thread stopped") 

143 

144 def _collect_metrics(self) -> Dict[str, Any]: 

145 """Collect all system metrics in one go.""" 

146 metrics = {} 

147 

148 try: 

149 # CPU usage 

150 cpu_percent = psutil.cpu_percent(interval=None) 

151 metrics['cpu_percent'] = cpu_percent 

152 

153 # RAM usage 

154 ram = psutil.virtual_memory() 

155 metrics['ram_percent'] = ram.percent 

156 metrics['ram_used_gb'] = ram.used / (1024**3) 

157 metrics['ram_total_gb'] = ram.total / (1024**3) 

158 metrics['ram_available_gb'] = ram.available / (1024**3) 

159 

160 # CPU info 

161 metrics['cpu_cores'] = psutil.cpu_count() 

162 metrics['cpu_freq_mhz'] = get_cpu_freq_mhz() 

163 

164 # GPU usage 

165 if GPU_AVAILABLE: 

166 try: 

167 gpus = GPUtil.getGPUs() 

168 if gpus: 

169 gpu = gpus[0] # Use first GPU 

170 gpu_load = gpu.load * 100 

171 vram_util = gpu.memoryUtil * 100 

172 

173 metrics.update({ 

174 'gpu_percent': gpu_load, 

175 'vram_percent': vram_util, 

176 'gpu_name': gpu.name, 

177 'gpu_temp': gpu.temperature, 

178 'vram_used_mb': gpu.memoryUsed, 

179 'vram_total_mb': gpu.memoryTotal 

180 }) 

181 else: 

182 # No GPUs found 

183 metrics.update({ 

184 'gpu_percent': 0.0, 

185 'vram_percent': 0.0, 

186 'gpu_name': 'No GPU Found', 

187 'gpu_temp': 0, 

188 'vram_used_mb': 0, 

189 'vram_total_mb': 0 

190 }) 

191 except Exception as e: 

192 # GPU monitoring failed 

193 logger.debug(f"GPU monitoring failed: {e}") 

194 metrics.update({ 

195 'gpu_percent': 0.0, 

196 'vram_percent': 0.0, 

197 'gpu_name': 'GPU Error', 

198 'gpu_temp': 0, 

199 'vram_used_mb': 0, 

200 'vram_total_mb': 0 

201 }) 

202 else: 

203 # GPUtil not available 

204 metrics.update({ 

205 'gpu_percent': 0.0, 

206 'vram_percent': 0.0, 

207 'gpu_name': 'GPUtil Not Available', 

208 'gpu_temp': 0, 

209 'vram_used_mb': 0, 

210 'vram_total_mb': 0 

211 }) 

212 

213 except Exception as e: 

214 logger.warning(f"Error in metrics collection: {e}") 

215 # Return defaults on error 

216 metrics = { 

217 'cpu_percent': 0.0, 

218 'ram_percent': 0.0, 

219 'ram_used_gb': 0.0, 

220 'ram_total_gb': 0.0, 

221 'ram_available_gb': 0.0, 

222 'cpu_cores': 0, 

223 'cpu_freq_mhz': 0, 

224 'gpu_percent': 0.0, 

225 'vram_percent': 0.0, 

226 'gpu_name': 'Error', 

227 'gpu_temp': 0, 

228 'vram_used_mb': 0, 

229 'vram_total_mb': 0 

230 } 

231 

232 return metrics 

233 

234 def stop_monitoring(self): 

235 """Request the thread to stop monitoring.""" 

236 self._stop_requested = True 

237 

238 def get_current_metrics(self) -> Dict[str, Any]: 

239 """Get the current cached metrics (thread-safe).""" 

240 with QMutexLocker(self._mutex): 

241 return self._current_metrics.copy() if self._current_metrics else {} 

242 

243 def get_history_data(self) -> Dict[str, Any]: 

244 """Get historical data (thread-safe).""" 

245 with QMutexLocker(self._mutex): 

246 return { 

247 'cpu_history': list(self.cpu_history), 

248 'ram_history': list(self.ram_history), 

249 'gpu_history': list(self.gpu_history), 

250 'vram_history': list(self.vram_history), 

251 'time_stamps': list(self.time_stamps) 

252 } 

253 

254 def set_update_interval(self, interval: float): 

255 """Set the update interval in seconds.""" 

256 self.update_interval = interval 

257 

258 

259class PersistentSystemMonitor: 

260 """ 

261 System monitor that uses a persistent background thread. 

262  

263 This provides a simple interface to the persistent monitoring thread, 

264 ensuring the UI never blocks during metrics collection. 

265 """ 

266 

267 def __init__(self, update_interval: float = 1.0, history_length: int = 60): 

268 """ 

269 Initialize the persistent system monitor. 

270 

271 Args: 

272 update_interval: Time between updates in seconds 

273 history_length: Number of historical data points to keep 

274 """ 

275 self.thread = PersistentSystemMonitorThread(update_interval, history_length) 

276 self._is_running = False 

277 

278 def __del__(self): 

279 """Destructor - ensure thread is stopped.""" 

280 try: 

281 self.stop_monitoring() 

282 except: 

283 pass # Ignore errors during destruction 

284 

285 def start_monitoring(self): 

286 """Start the monitoring thread.""" 

287 if not self._is_running: 

288 self.thread.start() 

289 self._is_running = True 

290 logger.debug("Persistent system monitor started") 

291 

292 def stop_monitoring(self): 

293 """Stop the monitoring thread.""" 

294 if self._is_running: 

295 logger.debug("Stopping persistent system monitor...") 

296 self.thread.stop_monitoring() 

297 

298 # Wait for clean shutdown with shorter timeout 

299 if not self.thread.wait(2000): # Wait up to 2 seconds 

300 logger.warning("System monitor thread did not stop cleanly, terminating...") 

301 self.thread.terminate() 

302 self.thread.wait(1000) # Give terminate a chance 

303 

304 self._is_running = False 

305 logger.debug("Persistent system monitor stopped") 

306 

307 def get_current_metrics(self) -> Dict[str, Any]: 

308 """Get current metrics without blocking.""" 

309 return self.thread.get_current_metrics() 

310 

311 def get_history_data(self) -> Dict[str, Any]: 

312 """Get historical data without blocking.""" 

313 return self.thread.get_history_data() 

314 

315 def connect_signals(self, metrics_callback=None, error_callback=None): 

316 """Connect to thread signals.""" 

317 if metrics_callback: 

318 self.thread.metrics_updated.connect(metrics_callback) 

319 if error_callback: 

320 self.thread.error_occurred.connect(error_callback) 

321 

322 def set_update_interval(self, interval: float): 

323 """Set the update interval.""" 

324 self.thread.set_update_interval(interval)