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

151 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +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, Optional 

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)