setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (162) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/rotatearbitrary.png +0 -0
  14. setiastro/images/waning_crescent_1.png +0 -0
  15. setiastro/images/waning_crescent_2.png +0 -0
  16. setiastro/images/waning_crescent_3.png +0 -0
  17. setiastro/images/waning_crescent_4.png +0 -0
  18. setiastro/images/waning_crescent_5.png +0 -0
  19. setiastro/images/waning_gibbous_1.png +0 -0
  20. setiastro/images/waning_gibbous_2.png +0 -0
  21. setiastro/images/waning_gibbous_3.png +0 -0
  22. setiastro/images/waning_gibbous_4.png +0 -0
  23. setiastro/images/waning_gibbous_5.png +0 -0
  24. setiastro/images/waxing_crescent_1.png +0 -0
  25. setiastro/images/waxing_crescent_2.png +0 -0
  26. setiastro/images/waxing_crescent_3.png +0 -0
  27. setiastro/images/waxing_crescent_4.png +0 -0
  28. setiastro/images/waxing_crescent_5.png +0 -0
  29. setiastro/images/waxing_gibbous_1.png +0 -0
  30. setiastro/images/waxing_gibbous_2.png +0 -0
  31. setiastro/images/waxing_gibbous_3.png +0 -0
  32. setiastro/images/waxing_gibbous_4.png +0 -0
  33. setiastro/images/waxing_gibbous_5.png +0 -0
  34. setiastro/qml/ResourceMonitor.qml +84 -82
  35. setiastro/saspro/__main__.py +20 -1
  36. setiastro/saspro/_generated/build_info.py +2 -2
  37. setiastro/saspro/abe.py +37 -4
  38. setiastro/saspro/aberration_ai.py +237 -21
  39. setiastro/saspro/acv_exporter.py +379 -0
  40. setiastro/saspro/add_stars.py +33 -6
  41. setiastro/saspro/backgroundneutral.py +114 -37
  42. setiastro/saspro/blemish_blaster.py +4 -1
  43. setiastro/saspro/blink_comparator_pro.py +548 -275
  44. setiastro/saspro/clahe.py +4 -1
  45. setiastro/saspro/continuum_subtract.py +4 -1
  46. setiastro/saspro/convo.py +13 -7
  47. setiastro/saspro/cosmicclarity.py +129 -18
  48. setiastro/saspro/crop_dialog_pro.py +134 -8
  49. setiastro/saspro/curve_editor_pro.py +109 -42
  50. setiastro/saspro/doc_manager.py +246 -16
  51. setiastro/saspro/exoplanet_detector.py +120 -28
  52. setiastro/saspro/frequency_separation.py +1158 -204
  53. setiastro/saspro/function_bundle.py +16 -16
  54. setiastro/saspro/ghs_dialog_pro.py +81 -16
  55. setiastro/saspro/graxpert.py +1 -0
  56. setiastro/saspro/gui/main_window.py +519 -289
  57. setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
  58. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  59. setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
  60. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  61. setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
  62. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  63. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  64. setiastro/saspro/halobgon.py +4 -0
  65. setiastro/saspro/histogram.py +5 -1
  66. setiastro/saspro/image_combine.py +4 -0
  67. setiastro/saspro/image_peeker_pro.py +4 -0
  68. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  69. setiastro/saspro/imageops/stretch.py +582 -62
  70. setiastro/saspro/isophote.py +4 -0
  71. setiastro/saspro/layers.py +13 -9
  72. setiastro/saspro/layers_dock.py +183 -3
  73. setiastro/saspro/legacy/image_manager.py +154 -20
  74. setiastro/saspro/legacy/numba_utils.py +67 -47
  75. setiastro/saspro/legacy/xisf.py +240 -98
  76. setiastro/saspro/live_stacking.py +180 -79
  77. setiastro/saspro/luminancerecombine.py +228 -27
  78. setiastro/saspro/mask_creation.py +174 -15
  79. setiastro/saspro/mfdeconv.py +113 -35
  80. setiastro/saspro/mfdeconvcudnn.py +119 -70
  81. setiastro/saspro/mfdeconvsport.py +112 -35
  82. setiastro/saspro/morphology.py +4 -0
  83. setiastro/saspro/multiscale_decomp.py +748 -255
  84. setiastro/saspro/numba_utils.py +72 -57
  85. setiastro/saspro/ops/commands.py +18 -18
  86. setiastro/saspro/ops/script_editor.py +10 -2
  87. setiastro/saspro/ops/scripts.py +122 -0
  88. setiastro/saspro/perfect_palette_picker.py +37 -3
  89. setiastro/saspro/plate_solver.py +84 -49
  90. setiastro/saspro/psf_viewer.py +119 -37
  91. setiastro/saspro/remove_stars_preset.py +55 -13
  92. setiastro/saspro/resources.py +97 -11
  93. setiastro/saspro/rgbalign.py +4 -0
  94. setiastro/saspro/selective_color.py +83 -21
  95. setiastro/saspro/sfcc.py +364 -152
  96. setiastro/saspro/shortcuts.py +253 -49
  97. setiastro/saspro/signature_insert.py +692 -33
  98. setiastro/saspro/stacking_suite.py +1610 -574
  99. setiastro/saspro/star_alignment.py +522 -453
  100. setiastro/saspro/star_spikes.py +4 -0
  101. setiastro/saspro/star_stretch.py +38 -3
  102. setiastro/saspro/stat_stretch.py +743 -128
  103. setiastro/saspro/status_log_dock.py +1 -1
  104. setiastro/saspro/subwindow.py +786 -360
  105. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  106. setiastro/saspro/swap_manager.py +77 -42
  107. setiastro/saspro/translations/all_source_strings.json +1588 -516
  108. setiastro/saspro/translations/ar_translations.py +915 -684
  109. setiastro/saspro/translations/de_translations.py +442 -463
  110. setiastro/saspro/translations/es_translations.py +277 -47
  111. setiastro/saspro/translations/fr_translations.py +279 -47
  112. setiastro/saspro/translations/hi_translations.py +253 -21
  113. setiastro/saspro/translations/integrate_translations.py +3 -2
  114. setiastro/saspro/translations/it_translations.py +1211 -161
  115. setiastro/saspro/translations/ja_translations.py +3340 -3107
  116. setiastro/saspro/translations/pt_translations.py +3315 -3337
  117. setiastro/saspro/translations/ru_translations.py +351 -117
  118. setiastro/saspro/translations/saspro_ar.qm +0 -0
  119. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  120. setiastro/saspro/translations/saspro_de.qm +0 -0
  121. setiastro/saspro/translations/saspro_de.ts +14428 -133
  122. setiastro/saspro/translations/saspro_es.qm +0 -0
  123. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  124. setiastro/saspro/translations/saspro_fr.qm +0 -0
  125. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  126. setiastro/saspro/translations/saspro_hi.qm +0 -0
  127. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  128. setiastro/saspro/translations/saspro_it.qm +0 -0
  129. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  130. setiastro/saspro/translations/saspro_ja.qm +0 -0
  131. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  132. setiastro/saspro/translations/saspro_pt.qm +0 -0
  133. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  134. setiastro/saspro/translations/saspro_ru.qm +0 -0
  135. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  136. setiastro/saspro/translations/saspro_sw.qm +0 -0
  137. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  138. setiastro/saspro/translations/saspro_uk.qm +0 -0
  139. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  140. setiastro/saspro/translations/saspro_zh.qm +0 -0
  141. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  142. setiastro/saspro/translations/sw_translations.py +282 -56
  143. setiastro/saspro/translations/uk_translations.py +264 -35
  144. setiastro/saspro/translations/zh_translations.py +282 -47
  145. setiastro/saspro/view_bundle.py +17 -17
  146. setiastro/saspro/wavescale_hdr.py +4 -1
  147. setiastro/saspro/wavescalede.py +4 -1
  148. setiastro/saspro/whitebalance.py +84 -12
  149. setiastro/saspro/widgets/common_utilities.py +28 -21
  150. setiastro/saspro/widgets/minigame/game.js +11 -6
  151. setiastro/saspro/widgets/resource_monitor.py +133 -57
  152. setiastro/saspro/widgets/spinboxes.py +28 -13
  153. setiastro/saspro/wimi.py +92 -721
  154. setiastro/saspro/wims.py +46 -36
  155. setiastro/saspro/window_shelf.py +2 -2
  156. setiastro/saspro/xisf.py +101 -11
  157. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
  158. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
  159. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  160. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  161. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  162. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
@@ -88,7 +88,7 @@ def plot_star_color_ratios_comparison(raw_pixels: np.ndarray, after_pixels: np.n
88
88
 
89
89
  plt.suptitle("Star Color Ratios with RGB Mapping", fontsize=14)
90
90
  plt.tight_layout()
91
- plt.show()
91
+ plt.show(block=False)
92
92
 
93
93
  def apply_manual_white_balance(img: np.ndarray, r_gain: float, g_gain: float, b_gain: float) -> np.ndarray:
94
94
  """Simple per-channel gain, clipped to [0,1]."""
@@ -184,6 +184,30 @@ def apply_white_balance_to_doc(doc, preset: Optional[Dict] = None):
184
184
  step_name="White Balance",
185
185
  )
186
186
 
187
+ def apply_pivot_gain(img: np.ndarray, med: np.ndarray, gains: np.ndarray) -> np.ndarray:
188
+ # img: HxWx3 float32 in [0,1]
189
+ med3 = med.reshape(1, 1, 3).astype(np.float32)
190
+ g3 = gains.reshape(1, 1, 3).astype(np.float32)
191
+
192
+ # pivot around median; do not scale negative deltas
193
+ d = img - med3
194
+ d = np.maximum(d, 0.0)
195
+ out = d * g3 + med3
196
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
197
+
198
+ def smoothstep(edge0, edge1, x):
199
+ t = np.clip((x - edge0) / (edge1 - edge0 + 1e-12), 0.0, 1.0)
200
+ return t * t * (3.0 - 2.0 * t)
201
+
202
+ def apply_soft_protect(img: np.ndarray, out_pivot: np.ndarray, k: float = 0.02) -> np.ndarray:
203
+ # luminance-based fade-in above median luminance
204
+ L = 0.2126*img[...,0] + 0.7152*img[...,1] + 0.0722*img[...,2]
205
+ Lm = float(np.median(L))
206
+ w = smoothstep(Lm, Lm + k, L).astype(np.float32)
207
+ w3 = w[..., None]
208
+ out = img * (1.0 - w3) + out_pivot * w3
209
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
210
+
187
211
 
188
212
  # -------------------------
189
213
  # Interactive dialog (UI)
@@ -193,16 +217,23 @@ class WhiteBalanceDialog(QDialog):
193
217
  super().__init__(parent)
194
218
  self._main = parent
195
219
  self.doc = doc
196
-
197
- # Connect to active document change signal
220
+ self._active_doc_conn = False
198
221
  if hasattr(self._main, "currentDocumentChanged"):
199
- self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
222
+ try:
223
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
224
+ self._active_doc_conn = True
225
+ except Exception:
226
+ self._active_doc_conn = False
200
227
  if icon:
201
228
  self.setWindowIcon(icon)
202
229
  self.setWindowTitle(self.tr("White Balance"))
203
230
  self.setWindowFlag(Qt.WindowType.Window, True)
204
231
  self.setWindowModality(Qt.WindowModality.NonModal)
205
232
  self.setModal(False)
233
+ try:
234
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
235
+ except Exception:
236
+ pass # older PyQt6 versions
206
237
  self.resize(900, 600)
207
238
 
208
239
  self._build_ui()
@@ -213,6 +244,8 @@ class WhiteBalanceDialog(QDialog):
213
244
  self._update_mode_widgets()
214
245
  # kick off a first detection preview
215
246
  QTimer.singleShot(200, self._update_star_preview)
247
+ self.finished.connect(lambda *_: self._cleanup())
248
+
216
249
 
217
250
  # ---- UI construction ------------------------------------------------
218
251
  def _build_ui(self):
@@ -327,11 +360,16 @@ class WhiteBalanceDialog(QDialog):
327
360
  )
328
361
  self.star_count.setText(self.tr("Detected {0} stars.").format(count))
329
362
  # to pixmap
330
- h, w, _ = overlay.shape
331
- qimg = QImage((overlay * 255).astype(np.uint8).data, w, h, 3 * w, QImage.Format.Format_RGB888)
332
- pm = QPixmap.fromImage(qimg).scaled(self.preview.width(), self.preview.height(),
333
- Qt.AspectRatioMode.KeepAspectRatio,
334
- Qt.TransformationMode.SmoothTransformation)
363
+ overlay8 = np.ascontiguousarray(np.clip(overlay * 255.0, 0, 255).astype(np.uint8))
364
+ h, w, _ = overlay8.shape
365
+ qimg = QImage(overlay8.data, w, h, 3 * w, QImage.Format.Format_RGB888)
366
+
367
+ # Make sure the numpy buffer stays alive until QPixmap is created
368
+ pm = QPixmap.fromImage(qimg.copy()).scaled(
369
+ self.preview.width(), self.preview.height(),
370
+ Qt.AspectRatioMode.KeepAspectRatio,
371
+ Qt.TransformationMode.SmoothTransformation
372
+ )
335
373
  self.preview.setPixmap(pm)
336
374
  except Exception as e:
337
375
  self.star_count.setText(self.tr("Detection failed."))
@@ -394,7 +432,8 @@ class WhiteBalanceDialog(QDialog):
394
432
  # Use the headless helper so doc metadata is consistent
395
433
  apply_white_balance_to_doc(self.doc, preset)
396
434
  # Dialog stays open - refresh document for next operation
397
- self._refresh_document_from_active()
435
+ self._finish_and_close()
436
+ return
398
437
 
399
438
  elif mode == "Auto":
400
439
  preset = {"mode": "auto"}
@@ -404,7 +443,8 @@ class WhiteBalanceDialog(QDialog):
404
443
 
405
444
  apply_white_balance_to_doc(self.doc, preset)
406
445
  # Dialog stays open - refresh document for next operation
407
- self._refresh_document_from_active()
446
+ self._finish_and_close()
447
+ return
408
448
 
409
449
  else: # --- Star-Based: compute here so we can plot like SASv2 ---
410
450
  thr = float(self.thr_slider.value())
@@ -472,7 +512,8 @@ class WhiteBalanceDialog(QDialog):
472
512
  self.tr("Star-Based WB applied.\nDetected {0} stars.").format(int(star_count)),
473
513
  )
474
514
  # Dialog stays open - refresh document for next operation
475
- self._refresh_document_from_active()
515
+ self._finish_and_close()
516
+ return
476
517
 
477
518
  except Exception as e:
478
519
  QMessageBox.critical(self, self.tr("White Balance"), self.tr("Failed to apply White Balance:\n{0}").format(e))
@@ -490,3 +531,34 @@ class WhiteBalanceDialog(QDialog):
490
531
  self.doc = new_doc
491
532
  except Exception:
492
533
  pass
534
+
535
+ def _finish_and_close(self):
536
+ """
537
+ Close this dialog after a successful apply.
538
+ Use accept() so it behaves like a successful completion.
539
+ """
540
+ try:
541
+ self.accept()
542
+ except Exception:
543
+ self.close()
544
+
545
+ def _cleanup(self):
546
+ # Disconnect active-doc signal so the main window doesn't keep us alive
547
+ try:
548
+ if self._active_doc_conn and hasattr(self._main, "currentDocumentChanged"):
549
+ self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
550
+ except Exception:
551
+ pass
552
+ self._active_doc_conn = False
553
+
554
+ # Stop debounce timer
555
+ try:
556
+ if getattr(self, "_debounce", None) is not None:
557
+ self._debounce.stop()
558
+ except Exception:
559
+ pass
560
+
561
+
562
+ def closeEvent(self, ev):
563
+ self._cleanup()
564
+ super().closeEvent(ev)
@@ -1,3 +1,4 @@
1
+ #src/setiastro/saspro/widgets/common_utilities.py
1
2
  """
2
3
  Common UI utilities and shared components.
3
4
 
@@ -220,32 +221,38 @@ _strip_ui_decorations = strip_ui_decorations
220
221
  # ---------------------------------------------------------------------------
221
222
 
222
223
  def install_crash_handlers(app: 'QApplication') -> None:
223
- """
224
- Install global crash and exception handlers for the application.
225
-
226
- Sets up:
227
- 1. faulthandler for hard crashes (segfaults) → saspro_crash.log
228
- 2. sys.excepthook for uncaught main thread exceptions
229
- 3. threading.excepthook for uncaught background thread exceptions
230
-
231
- All exceptions are logged and displayed in a dialog to the user.
232
-
233
- Args:
234
- app: The QApplication instance
235
-
236
- Example:
237
- app = QApplication(sys.argv)
238
- install_crash_handlers(app)
239
- """
240
224
  import faulthandler
241
-
242
- # 1) Hard crashes (segfaults, access violations) → saspro_crash.log
225
+ import tempfile
226
+ from pathlib import Path
227
+
228
+ def _get_crash_log_path() -> str:
229
+ try:
230
+ if hasattr(sys, "_MEIPASS"):
231
+ if sys.platform.startswith("win"):
232
+ log_dir = Path(os.path.expandvars("%APPDATA%")) / "SetiAstroSuitePro" / "logs"
233
+ elif sys.platform.startswith("darwin"):
234
+ log_dir = Path.home() / "Library" / "Logs" / "SetiAstroSuitePro"
235
+ else:
236
+ log_dir = Path.home() / ".local" / "share" / "SetiAstroSuitePro" / "logs"
237
+ else:
238
+ # dev fallback
239
+ log_dir = Path("logs")
240
+
241
+ log_dir.mkdir(parents=True, exist_ok=True)
242
+ return str(log_dir / "saspro_crash.log")
243
+ except Exception:
244
+ return str(Path(tempfile.gettempdir()) / "saspro_crash.log")
245
+
246
+ # 1) Hard crashes → saspro_crash.log
243
247
  try:
244
- _crash_log = open("saspro_crash.log", "w", encoding="utf-8", errors="replace")
248
+ crash_path = _get_crash_log_path()
249
+ _crash_log = open(crash_path, "a", encoding="utf-8", errors="replace")
245
250
  faulthandler.enable(file=_crash_log, all_threads=True)
246
251
  atexit.register(_crash_log.close)
252
+ logging.info("Faulthandler crash log: %s", crash_path)
247
253
  except Exception:
248
254
  logging.exception("Failed to enable faulthandler")
255
+
249
256
 
250
257
  def _show_dialog(title: str, head: str, details: str) -> None:
251
258
  """Show error dialog marshaled to main thread."""
@@ -261,7 +268,7 @@ def install_crash_handlers(app: 'QApplication') -> None:
261
268
  m.setStandardButtons(QMessageBox.StandardButton.Ok)
262
269
  m.exec()
263
270
  QTimer.singleShot(0, _ui)
264
-
271
+
265
272
  # 2) Any uncaught exception on the main thread
266
273
  def _excepthook(exc_type, exc_value, exc_tb):
267
274
  tb = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
@@ -9,7 +9,7 @@ const ctx = canvas.getContext('2d');
9
9
  // --- CONSTANTS ---
10
10
  const GAME_WIDTH = 800;
11
11
  const GAME_HEIGHT = 600;
12
- const FPS = 60;
12
+ const FPS = 48; // Reduced to 80% speed (Original 60)
13
13
  const DT = 1 / FPS;
14
14
 
15
15
  const COLORS = {
@@ -971,14 +971,19 @@ function updateHUD() {
971
971
 
972
972
  // Game Loop
973
973
  let lastTimeMs = 0;
974
+ const FRAME_INTERVAL = 1000 / FPS;
975
+
974
976
  function loop(timestamp) {
975
- const dt = timestamp - lastTimeMs;
976
- lastTimeMs = timestamp;
977
+ requestAnimationFrame(loop);
977
978
 
978
- Game.update();
979
- Game.draw();
979
+ if (!lastTimeMs) lastTimeMs = timestamp;
980
+ const elapsed = timestamp - lastTimeMs;
980
981
 
981
- requestAnimationFrame(loop);
982
+ if (elapsed > FRAME_INTERVAL) {
983
+ lastTimeMs = timestamp - (elapsed % FRAME_INTERVAL);
984
+ Game.update();
985
+ Game.draw();
986
+ }
982
987
  }
983
988
 
984
989
  // Start
@@ -5,93 +5,122 @@ import psutil
5
5
  from PyQt6.QtCore import Qt, QUrl, QTimer, QObject, pyqtProperty, pyqtSignal, QThread
6
6
  from PyQt6.QtWidgets import QWidget, QVBoxLayout, QFrame
7
7
  from PyQt6.QtQuickWidgets import QQuickWidget
8
-
8
+ import time
9
+ import subprocess
9
10
  from setiastro.saspro.memory_utils import get_memory_usage_mb
10
11
  from setiastro.saspro.resources import _get_base_path
11
12
 
13
+
12
14
  class GPUWorker(QThread):
13
- """Background worker to monitor GPU without blocking the UI."""
14
15
  resultReady = pyqtSignal(float)
15
16
 
16
17
  def __init__(self, has_nvidia: bool, parent=None):
17
18
  super().__init__(parent)
18
19
  self._has_nvidia = has_nvidia
19
- self._last_val = 0.0
20
+
21
+ # cache + throttle (Windows PowerShell is expensive)
22
+ self._last_win_poll = 0.0
23
+ self._cached_win_val = 0.0
24
+
25
+ self._last_emit = 0.0
26
+ self._last_emitted_val = None
27
+
28
+ def _startupinfo_hidden(self):
29
+ if os.name != "nt":
30
+ return None
31
+ si = subprocess.STARTUPINFO()
32
+ si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
33
+ si.wShowWindow = 0
34
+ return si
20
35
 
21
36
  def _get_windows_gpu_load(self) -> float:
22
- if os.name != 'nt':
37
+ if os.name != "nt":
23
38
  return 0.0
39
+
40
+ now = time.monotonic()
41
+
42
+ # THROTTLE: run this at most once every 1.5 seconds
43
+ if (now - self._last_win_poll) < 1.5:
44
+ return self._cached_win_val
45
+
46
+ self._last_win_poll = now
47
+
24
48
  try:
25
- import subprocess
26
- # Aggregation logic to match Task Manager:
27
- # 1. Group by unique engine (Adapter LUID + Engine Type/Index).
28
- # 2. Sum utilization of all processes sharing that engine.
29
- # 3. Take the Maximum of these sums as the overall GPU load.
30
- # Aggregation logic to match Task Manager:
31
- # 1. Group by unique engine (Adapter LUID + Engine Type/Index).
32
- # 2. Sum utilization of all processes sharing that engine.
33
- # 3. Take the Maximum of these sums as the overall GPU load.
34
- cmd = (
35
- "powershell -NoProfile -ExecutionPolicy Bypass -Command \""
36
- "$groups = Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine -ErrorAction SilentlyContinue | "
37
- "Group-Object -Property { $_.Name -replace '^pid_\\d+_', '' }; "
38
- "$res_list = $groups | ForEach-Object { ($_.Group | Measure-Object -Property UtilizationPercentage -Sum).Sum }; "
39
- "$max_val = ($res_list | Measure-Object -Maximum).Maximum; "
40
- "if ($max_val) { [math]::Round($max_val, 1) } else { 0 }\""
49
+ # Use explicit powershell.exe and make it non-interactive + hidden
50
+ cmd = [
51
+ "powershell.exe",
52
+ "-NoProfile",
53
+ "-NonInteractive",
54
+ "-ExecutionPolicy", "Bypass",
55
+ "-Command",
56
+ (
57
+ "$x = Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine "
58
+ "-ErrorAction SilentlyContinue; "
59
+ "if (-not $x) { 0 } else { "
60
+ " $m = ($x | Measure-Object -Property UtilizationPercentage -Maximum).Maximum; "
61
+ " if ($m) { [math]::Round([double]$m, 1) } else { 0 } "
62
+ "}"
63
+ ),
64
+ ]
65
+
66
+ out = subprocess.check_output(
67
+ cmd,
68
+ startupinfo=self._startupinfo_hidden(),
69
+ timeout=2.0, # IMPORTANT: don’t allow 5s hangs
70
+ stderr=subprocess.DEVNULL, # keep it quiet
41
71
  )
42
- startupinfo = subprocess.STARTUPINFO()
43
- startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
44
- startupinfo.wShowWindow = 0
45
-
46
- out = subprocess.check_output(cmd, startupinfo=startupinfo, timeout=5.0)
47
- val_str = out.decode("utf-8").strip()
48
-
49
- if not val_str: return 0.0
50
- return float(val_str.replace(",", "."))
72
+ val_str = out.decode("utf-8", errors="ignore").strip()
73
+
74
+ val = float(val_str.replace(",", ".")) if val_str else 0.0
75
+ self._cached_win_val = val
76
+ return val
51
77
  except Exception:
52
- return 0.0
78
+ # keep last known value instead of spamming 0.0
79
+ return self._cached_win_val
53
80
 
54
81
  def _get_gpu_load(self) -> float:
55
82
  nv_val = 0.0
56
83
  win_val = 0.0
57
-
58
- # 1. Check NVIDIA (Discrete)
84
+
85
+ # NVIDIA (fast, keep it)
59
86
  if self._has_nvidia:
60
87
  try:
61
- import subprocess
62
- startupinfo = None
63
- if os.name == 'nt':
64
- startupinfo = subprocess.STARTUPINFO()
65
- startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
66
- startupinfo.wShowWindow = 0
67
-
68
88
  out = subprocess.check_output(
69
89
  ["nvidia-smi", "--query-gpu=utilization.gpu", "--format=csv,noheader,nounits"],
70
- startupinfo=startupinfo,
71
- timeout=0.6
90
+ startupinfo=self._startupinfo_hidden(),
91
+ timeout=0.6,
92
+ stderr=subprocess.DEVNULL,
72
93
  )
73
- line = out.decode("utf-8").strip().split('\n')[0]
94
+ line = out.decode("utf-8", errors="ignore").strip().split("\n")[0]
74
95
  nv_val = float(line)
75
96
  except Exception:
76
- pass
77
-
78
- # 2. Check Universal (Integrated)
79
- if os.name == 'nt':
97
+ pass
98
+
99
+ # Windows integrated (slow, throttled)
100
+ if os.name == "nt":
80
101
  win_val = self._get_windows_gpu_load()
81
-
102
+
82
103
  return max(nv_val, win_val)
83
104
 
84
105
  def run(self):
85
106
  while not self.isInterruptionRequested():
86
107
  try:
87
108
  val = self._get_gpu_load()
88
- self.resultReady.emit(val)
89
- # Sleep between measurements. 250ms as requested.
90
- # Note: PowerShell queries might take longer than 250ms,
91
- # but this loop will run as fast as the hardware allows without blocking UI.
109
+
110
+ # Optional: emit only if value changed a bit, or once per 250ms max
111
+ now = time.monotonic()
112
+ if (
113
+ self._last_emitted_val is None
114
+ or abs(val - self._last_emitted_val) >= 1.0
115
+ or (now - self._last_emit) >= 0.5
116
+ ):
117
+ self._last_emit = now
118
+ self._last_emitted_val = val
119
+ self.resultReady.emit(val)
120
+
92
121
  self.msleep(250)
93
122
  except Exception:
94
- self.msleep(1000) # Error backoff on failure
123
+ self.msleep(1000)
95
124
 
96
125
  class ResourceBackend(QObject):
97
126
  """Backend logic for the QML Resource Monitor."""
@@ -197,6 +226,7 @@ class SystemMonitorWidget(QQuickWidget):
197
226
  self.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False)
198
227
  self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
199
228
  self.setClearColor(Qt.GlobalColor.transparent)
229
+ self._qml_push_pending = False
200
230
 
201
231
  # Connect Backend
202
232
  self.backend = ResourceBackend(self)
@@ -218,16 +248,23 @@ class SystemMonitorWidget(QQuickWidget):
218
248
  # Let's re-write the QML loading part to use a safer 'initialProperties' approach or just signal/slots.
219
249
  #
220
250
  # EASIEST: QML binds to `root.cpuUsage`. Python sets `root.cpuUsage`.
221
-
222
- self.backend.cpuChanged.connect(self._push_data_to_qml)
223
- self.backend.ramChanged.connect(self._push_data_to_qml)
224
- self.backend.gpuChanged.connect(self._push_data_to_qml)
225
- self.backend.appRamChanged.connect(self._push_data_to_qml)
251
+
226
252
 
227
253
  # Load QML
228
254
  qml_path = os.path.join(_get_base_path(), "qml", "ResourceMonitor.qml")
229
255
  self.setSource(QUrl.fromLocalFile(qml_path))
230
256
 
257
+ def _schedule_qml_push(self):
258
+ if self._qml_push_pending:
259
+ return
260
+ self._qml_push_pending = True
261
+ QTimer.singleShot(0, self._push_data_to_qml_coalesced)
262
+
263
+ def _push_data_to_qml_coalesced(self):
264
+ self._qml_push_pending = False
265
+ self._push_data_to_qml()
266
+
267
+
231
268
  def _push_data_to_qml(self):
232
269
  root = self.rootObject()
233
270
  if root:
@@ -235,3 +272,42 @@ class SystemMonitorWidget(QQuickWidget):
235
272
  root.setProperty("ramUsage", self.backend.ramUsage)
236
273
  root.setProperty("gpuUsage", self.backend.gpuUsage)
237
274
  root.setProperty("appRamString", self.backend.appRamString)
275
+
276
+ # --- Drag & Drop Support ---
277
+ def mousePressEvent(self, event):
278
+ if event.button() == Qt.MouseButton.LeftButton:
279
+ # Wayland-friendly: ask compositor to move the window
280
+ wh = self.windowHandle()
281
+ if wh is not None:
282
+ try:
283
+ # Works best for frameless overlays on Wayland
284
+ wh.startSystemMove()
285
+ event.accept()
286
+ return
287
+ except Exception:
288
+ pass
289
+
290
+ # Fallback (Windows/X11): manual move tracking
291
+ self._drag_start_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
292
+ event.accept()
293
+ return
294
+
295
+ super().mousePressEvent(event)
296
+
297
+ def mouseMoveEvent(self, event):
298
+ if event.buttons() & Qt.MouseButton.LeftButton:
299
+ if hasattr(self, "_drag_start_pos"):
300
+ self.move(event.globalPosition().toPoint() - self._drag_start_pos)
301
+ event.accept()
302
+ else:
303
+ super().mouseMoveEvent(event)
304
+
305
+ def mouseReleaseEvent(self, event):
306
+ if event.button() == Qt.MouseButton.LeftButton:
307
+ from PyQt6.QtCore import QSettings
308
+ settings = QSettings("SetiAstro", "SetiAstroSuitePro")
309
+ pos = self.pos()
310
+ settings.setValue("ui/resource_monitor_pos_x", pos.x())
311
+ settings.setValue("ui/resource_monitor_pos_y", pos.y())
312
+ event.accept()
313
+ super().mouseReleaseEvent(event)
@@ -37,7 +37,7 @@ class CustomSpinBox(QWidget):
37
37
  self.minimum = minimum
38
38
  self.maximum = maximum
39
39
  self.step = step
40
- self._value = initial
40
+ self._value = int(initial)
41
41
 
42
42
  # Line edit for value display/entry
43
43
  self.lineEdit = QLineEdit(str(initial))
@@ -77,14 +77,13 @@ class CustomSpinBox(QWidget):
77
77
 
78
78
  self._update_button_states()
79
79
 
80
- @property
81
80
  def value(self) -> int:
82
- """Get the current value."""
83
- return self._value
81
+ """Qt-style getter."""
82
+ return int(self._value)
84
83
 
85
84
  def setValue(self, val: int) -> None:
86
- """Set the value, clamping to min/max."""
87
- val = max(self.minimum, min(self.maximum, val))
85
+ val = int(val)
86
+ val = max(int(self.minimum), min(int(self.maximum), val))
88
87
  if val != self._value:
89
88
  self._value = val
90
89
  self.lineEdit.setText(str(val))
@@ -134,8 +133,16 @@ class CustomSpinBox(QWidget):
134
133
  """Decrease value by step."""
135
134
  self.setValue(self._value - self.step)
136
135
 
136
+ def value(self) -> int:
137
+ """
138
+ Qt-compatible getter (QSpinBox uses value()).
139
+
140
+ Note: we also have @property value for convenience,
141
+ but code that expects QSpinBox calls value().
142
+ """
143
+ return self._value
144
+
137
145
  def _update_button_states(self) -> None:
138
- """Enable/disable buttons at limits."""
139
146
  self.upButton.setEnabled(self._value < self.maximum)
140
147
  self.downButton.setEnabled(self._value > self.minimum)
141
148
 
@@ -166,7 +173,7 @@ class CustomDoubleSpinBox(QWidget):
166
173
  self.maximum = maximum
167
174
  self.step = step
168
175
  self.decimals = decimals
169
- self._value = initial
176
+ self._value = float(initial)
170
177
 
171
178
  # Line edit for value display/entry
172
179
  self.lineEdit = QLineEdit(f"{initial:.{decimals}f}")
@@ -206,14 +213,13 @@ class CustomDoubleSpinBox(QWidget):
206
213
 
207
214
  self._update_button_states()
208
215
 
209
- @property
210
216
  def value(self) -> float:
211
- """Get the current value."""
212
- return self._value
217
+ """Qt-style getter."""
218
+ return float(self._value)
213
219
 
214
220
  def setValue(self, val: float) -> None:
215
- """Set the value, clamping to min/max."""
216
- val = max(self.minimum, min(self.maximum, val))
221
+ val = float(val)
222
+ val = max(float(self.minimum), min(float(self.maximum), val))
217
223
  if abs(val - self._value) > 1e-10:
218
224
  self._value = val
219
225
  self.lineEdit.setText(f"{val:.{self.decimals}f}")
@@ -228,6 +234,15 @@ class CustomDoubleSpinBox(QWidget):
228
234
  self.setValue(minimum)
229
235
  self._update_button_states()
230
236
 
237
+ def value(self) -> float:
238
+ """
239
+ Qt-compatible getter (QDoubleSpinBox uses value()).
240
+
241
+ Note: we also have @property value for convenience,
242
+ but code that expects QDoubleSpinBox calls value().
243
+ """
244
+ return self._value
245
+
231
246
  def setMaximum(self, maximum: float) -> None:
232
247
  """Set the maximum value."""
233
248
  self.maximum = maximum