setiastrosuitepro 1.8.0.post3__py3-none-any.whl → 1.8.2__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 (43) hide show
  1. setiastro/images/finderchart.png +0 -0
  2. setiastro/saspro/__main__.py +41 -39
  3. setiastro/saspro/_generated/build_info.py +2 -2
  4. setiastro/saspro/abe.py +1 -1
  5. setiastro/saspro/blink_comparator_pro.py +3 -1
  6. setiastro/saspro/bright_stars.py +305 -0
  7. setiastro/saspro/continuum_subtract.py +2 -1
  8. setiastro/saspro/cosmicclarity_engines/darkstar_engine.py +22 -2
  9. setiastro/saspro/cosmicclarity_engines/denoise_engine.py +68 -15
  10. setiastro/saspro/cosmicclarity_engines/satellite_engine.py +7 -3
  11. setiastro/saspro/cosmicclarity_engines/sharpen_engine.py +371 -98
  12. setiastro/saspro/cosmicclarity_engines/superres_engine.py +1 -0
  13. setiastro/saspro/cosmicclarity_preset.py +2 -1
  14. setiastro/saspro/doc_manager.py +8 -0
  15. setiastro/saspro/exoplanet_detector.py +22 -17
  16. setiastro/saspro/finder_chart.py +1639 -0
  17. setiastro/saspro/gui/main_window.py +36 -14
  18. setiastro/saspro/gui/mixins/menu_mixin.py +2 -0
  19. setiastro/saspro/gui/mixins/toolbar_mixin.py +9 -1
  20. setiastro/saspro/legacy/image_manager.py +18 -4
  21. setiastro/saspro/legacy/xisf.py +3 -3
  22. setiastro/saspro/main_helpers.py +18 -0
  23. setiastro/saspro/memory_utils.py +18 -14
  24. setiastro/saspro/model_manager.py +65 -0
  25. setiastro/saspro/model_workers.py +58 -24
  26. setiastro/saspro/ops/settings.py +45 -8
  27. setiastro/saspro/planetprojection.py +68 -36
  28. setiastro/saspro/resources.py +193 -175
  29. setiastro/saspro/runtime_torch.py +622 -137
  30. setiastro/saspro/sfcc.py +5 -3
  31. setiastro/saspro/stacking_suite.py +4 -3
  32. setiastro/saspro/star_alignment.py +266 -212
  33. setiastro/saspro/texture_clarity.py +1 -1
  34. setiastro/saspro/widgets/image_utils.py +12 -4
  35. setiastro/saspro/widgets/spinboxes.py +5 -7
  36. setiastro/saspro/wimi.py +2 -1
  37. setiastro/saspro/xisf.py +3 -3
  38. {setiastrosuitepro-1.8.0.post3.dist-info → setiastrosuitepro-1.8.2.dist-info}/METADATA +4 -4
  39. {setiastrosuitepro-1.8.0.post3.dist-info → setiastrosuitepro-1.8.2.dist-info}/RECORD +43 -40
  40. {setiastrosuitepro-1.8.0.post3.dist-info → setiastrosuitepro-1.8.2.dist-info}/WHEEL +0 -0
  41. {setiastrosuitepro-1.8.0.post3.dist-info → setiastrosuitepro-1.8.2.dist-info}/entry_points.txt +0 -0
  42. {setiastrosuitepro-1.8.0.post3.dist-info → setiastrosuitepro-1.8.2.dist-info}/licenses/LICENSE +0 -0
  43. {setiastrosuitepro-1.8.0.post3.dist-info → setiastrosuitepro-1.8.2.dist-info}/licenses/license.txt +0 -0
@@ -33,8 +33,8 @@ from urllib.parse import quote, quote_plus
33
33
  # ============================================================================
34
34
  import numpy as np
35
35
  import matplotlib
36
- from tifffile import imwrite
37
- from setiastro.saspro.xisf import XISF
36
+ # tifffile and XISF imports removed (unused in this file)
37
+
38
38
 
39
39
  # ============================================================================
40
40
  # Bootstrap Configuration (must run early)
@@ -93,18 +93,8 @@ from setiastro.saspro.widgets.common_utilities import (
93
93
  )
94
94
 
95
95
 
96
- # Reproject for WCS-based alignment
97
- try:
98
- from reproject import reproject_interp
99
- except ImportError:
100
- reproject_interp = None # fallback if not installed
96
+ # Reproject and OpenCV imports removed (unused or available via lazy_imports)
101
97
 
102
- # OpenCV for transform estimation & warping
103
- try:
104
- import cv2
105
- OPENCV_AVAILABLE = True
106
- except ImportError:
107
- OPENCV_AVAILABLE = False
108
98
 
109
99
 
110
100
 
@@ -196,7 +186,7 @@ from setiastro.saspro.resources import (
196
186
  colorwheel_path, font_path, csv_icon_path, spinner_path, wims_path, narrowbandnormalization_path,
197
187
  wimi_path, linearfit_path, debayer_path, aberration_path, acv_icon_path,
198
188
  functionbundles_path, viewbundles_path, selectivecolor_path, rgbalign_path, planetarystacker_path,
199
- background_path, script_icon_path, planetprojection_path,clonestampicon_path,
189
+ background_path, script_icon_path, planetprojection_path,clonestampicon_path, finderchart_path,
200
190
  )
201
191
 
202
192
  import faulthandler
@@ -4238,6 +4228,38 @@ class AstroSuiteProMainWindow(
4238
4228
  except Exception:
4239
4229
  pass
4240
4230
 
4231
+ def _doc_has_wcs(self, doc) -> bool:
4232
+ if doc is None:
4233
+ return False
4234
+ meta = getattr(doc, "metadata", None) or {}
4235
+ if meta.get("wcs") is not None:
4236
+ return True
4237
+
4238
+ hdr = meta.get("original_header") or meta.get("fits_header") or meta.get("header")
4239
+ if hdr is None:
4240
+ return False
4241
+
4242
+ try:
4243
+ keys = {str(k).upper() for k in hdr.keys()}
4244
+ except Exception:
4245
+ try:
4246
+ keys = {str(k).upper() for k in dict(hdr).keys()}
4247
+ except Exception:
4248
+ return False
4249
+
4250
+ return {"CTYPE1","CTYPE2","CRVAL1","CRVAL2"}.issubset(keys)
4251
+
4252
+
4253
+ def _open_finder_chart(self):
4254
+ doc = self._active_doc()
4255
+ if not self._doc_has_wcs(doc):
4256
+ QMessageBox.information(self, self.tr("Finder Chart"), self.tr("Active image has no astrometric solution (WCS). Plate solve first."))
4257
+ return
4258
+
4259
+ from setiastro.saspro.finder_chart import FinderChartDialog
4260
+ dlg = FinderChartDialog(doc=doc, settings=self.settings, parent=self)
4261
+ dlg.setWindowIcon(QIcon(finderchart_path))
4262
+ dlg.show()
4241
4263
 
4242
4264
  def _open_stellar_alignment(self):
4243
4265
  from setiastro.saspro.star_alignment import StellarAlignmentDialog
@@ -204,6 +204,7 @@ class MenuMixin:
204
204
 
205
205
  m_star.addAction(self.act_astrospike)
206
206
  m_star.addAction(self.act_exo_detector)
207
+
207
208
  m_star.addAction(self.act_image_peeker)
208
209
  m_star.addAction(self.act_isophote)
209
210
  m_star.addAction(self.act_live_stacking)
@@ -233,6 +234,7 @@ class MenuMixin:
233
234
  m_wim = mb.addMenu(self.tr("&What's In My..."))
234
235
  m_wim.addAction(self.act_whats_in_my_sky)
235
236
  m_wim.addAction(self.act_wimi)
237
+ m_wim.addAction(self.act_finder_chart)
236
238
 
237
239
  m_scripts = mb.addMenu(self.tr("&Scripts"))
238
240
  self.menu_scripts = m_scripts
@@ -36,7 +36,7 @@ from setiastro.saspro.resources import (
36
36
  nbtorgb_path, freqsep_path, multiscale_decomp_path, contsub_path, halo_path, cosmic_path,
37
37
  satellite_path, imagecombine_path, wims_path, wimi_path, linearfit_path,
38
38
  debayer_path, aberration_path, functionbundles_path, viewbundles_path, planetarystacker_path,
39
- selectivecolor_path, rgbalign_path, planetprojection_path, clonestampicon_path,
39
+ selectivecolor_path, rgbalign_path, planetprojection_path, clonestampicon_path, finderchart_path,
40
40
  )
41
41
 
42
42
  # Import shortcuts module
@@ -298,6 +298,7 @@ class ToolbarMixin:
298
298
  tb_star.addAction(self.act_planetary_stacker)
299
299
  tb_star.addAction(self.act_planet_projection)
300
300
  tb_star.addAction(self.act_plate_solve)
301
+
301
302
  tb_star.addAction(self.act_star_align)
302
303
  tb_star.addAction(self.act_star_register)
303
304
  tb_star.addAction(self.act_rgb_align)
@@ -332,6 +333,7 @@ class ToolbarMixin:
332
333
  tb_wim = DraggableToolBar(self.tr("What's In My..."), self)
333
334
  tb_wim.setObjectName("What's In My...")
334
335
  tb_wim.setSettingsKey("Toolbar/WhatsInMy")
336
+ tb_wim.addAction(self.act_finder_chart)
335
337
  self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb_wim)
336
338
 
337
339
  tb_wim.addAction(self.act_whats_in_my_sky)
@@ -1245,6 +1247,11 @@ class ToolbarMixin:
1245
1247
  self.act_rgb_align.setStatusTip(self.tr("Align R and B channels to G using astroalign (affine/homography/poly)"))
1246
1248
  self.act_rgb_align.triggered.connect(self._open_rgb_align)
1247
1249
 
1250
+ self.act_finder_chart = QAction(QIcon(finderchart_path), self.tr("Finder Chart..."), self)
1251
+ self.act_finder_chart.setIconVisibleInMenu(True)
1252
+ self.act_finder_chart.setStatusTip(self.tr("Show a finder chart for the active plate-solved image"))
1253
+ self.act_finder_chart.triggered.connect(self._open_finder_chart)
1254
+
1248
1255
  self.act_whats_in_my_sky = QAction(QIcon(wims_path), self.tr("What's In My Sky..."), self)
1249
1256
  self.act_whats_in_my_sky.setIconVisibleInMenu(True)
1250
1257
  self.act_whats_in_my_sky.setStatusTip(self.tr("Plan targets by altitude, transit time, and lunar separation"))
@@ -1446,6 +1453,7 @@ class ToolbarMixin:
1446
1453
  reg("astrospike", self.act_astrospike)
1447
1454
  reg("exo_detector", self.act_exo_detector)
1448
1455
  reg("isophote", self.act_isophote)
1456
+ reg("finder_chart", self.act_finder_chart)
1449
1457
  reg("rgb_align", self.act_rgb_align)
1450
1458
  reg("whats_in_my_sky", self.act_whats_in_my_sky)
1451
1459
  reg("whats_in_my_image", self.act_wimi)
@@ -242,8 +242,16 @@ class ImageManager(QObject):
242
242
  def set_image(self, new_image, metadata, step_name=None):
243
243
  slot = self.current_slot
244
244
  if self._images[slot] is not None:
245
+ # OPTIMIZATION: If we are setting the EXACT SAME image object (e.g. metadata update),
246
+ # do not deep-copy the image data to undo stack.
247
+ # This saves massive memory when just renaming a slot or changing WCS.
248
+ if new_image is self._images[slot]:
249
+ stored_img = self._images[slot] # Shallow copy / reference
250
+ else:
251
+ stored_img = self._images[slot].copy() # Deep copy for safety
252
+
245
253
  self._undo_stacks[slot].append(
246
- (self._images[slot].copy(), self._metadata[slot].copy(), step_name or "Unnamed Step")
254
+ (stored_img, self._metadata[slot].copy(), step_name or "Unnamed Step")
247
255
  )
248
256
  self._redo_stacks[slot].clear()
249
257
  print(f"ImageManager: Previous image in slot {slot} pushed to undo stack.")
@@ -1526,8 +1534,12 @@ def load_image(filename, max_retries=3, wait_seconds=3, return_metadata: bool =
1526
1534
  # 1) PixInsight astrometric solution (fallback only)
1527
1535
  try:
1528
1536
  if not all(k in hdr for k in ("CRPIX1","CRPIX2","CRVAL1","CRVAL2")):
1529
- ref_img = props['PCL:AstrometricSolution:ReferenceImageCoordinates']['value']
1530
- ref_sky = props['PCL:AstrometricSolution:ReferenceCelestialCoordinates']['value']
1537
+ p_img = props['PCL:AstrometricSolution:ReferenceImageCoordinates']
1538
+ p_sky = props['PCL:AstrometricSolution:ReferenceCelestialCoordinates']
1539
+
1540
+ # Resolve lazy properties (decode base64/binary)
1541
+ ref_img = xisf.resolve_property(p_img)
1542
+ ref_sky = xisf.resolve_property(p_sky)
1531
1543
 
1532
1544
  # Some files store extra values; only first two are CRPIX/CRVAL
1533
1545
  im0, im1 = float(ref_img[0]), float(ref_img[1])
@@ -1547,7 +1559,9 @@ def load_image(filename, max_retries=3, wait_seconds=3, return_metadata: bool =
1547
1559
  # 2) CD matrix (fallback only)
1548
1560
  try:
1549
1561
  if not all(k in hdr for k in ("CD1_1","CD1_2","CD2_1","CD2_2")):
1550
- lin = np.asarray(props['PCL:AstrometricSolution:LinearTransformationMatrix']['value'], float)
1562
+ p_mat = props['PCL:AstrometricSolution:LinearTransformationMatrix']
1563
+ lin = np.asarray(xisf.resolve_property(p_mat), float)
1564
+
1551
1565
  hdr['CD1_1'], hdr['CD1_2'] = float(lin[0,0]), float(lin[0,1])
1552
1566
  hdr['CD2_1'], hdr['CD2_2'] = float(lin[1,0]), float(lin[1,1])
1553
1567
  _filled |= {'CD1_1','CD1_2','CD2_1','CD2_2'}
@@ -1072,7 +1072,7 @@ class XISF:
1072
1072
  }
1073
1073
  try:
1074
1074
  return _dtypes[s]
1075
- except:
1075
+ except KeyError:
1076
1076
  raise NotImplementedError(f"sampleFormat {s} not implemented")
1077
1077
 
1078
1078
  # Return XISF data type from numpy dtype
@@ -1087,7 +1087,7 @@ class XISF:
1087
1087
  }
1088
1088
  try:
1089
1089
  return _sampleFormats[str(dtype)]
1090
- except:
1090
+ except KeyError:
1091
1091
  raise NotImplementedError(f"sampleFormat for {dtype} not implemented")
1092
1092
 
1093
1093
  @staticmethod
@@ -1121,7 +1121,7 @@ class XISF:
1121
1121
  }
1122
1122
  try:
1123
1123
  return _dtypes[type_prefix]
1124
- except:
1124
+ except KeyError:
1125
1125
  raise NotImplementedError(f"data type {type_name} not implemented")
1126
1126
 
1127
1127
  # __/ Auxiliary functions for compression/shuffling \________
@@ -1,3 +1,4 @@
1
+
1
2
  # pro/main_helpers.py
2
3
  """
3
4
  Helper functions extracted from the main module.
@@ -7,12 +8,15 @@ Contains utility functions used throughout the main window:
7
8
  - Document name/type detection
8
9
  - Widget safety checks
9
10
  - WCS/FITS header utilities
11
+ - UI responsiveness helpers
10
12
  """
11
13
 
12
14
  import os
15
+ import time
13
16
  from typing import Optional, Tuple
14
17
 
15
18
  from PyQt6 import sip
19
+ from PyQt6.QtWidgets import QApplication
16
20
 
17
21
  from setiastro.saspro.file_utils import (
18
22
  _normalize_ext,
@@ -23,6 +27,20 @@ from setiastro.saspro.file_utils import (
23
27
  )
24
28
 
25
29
 
30
+ def non_blocking_sleep(duration_sec: float):
31
+ """
32
+ Sleep for duration_sec seconds while keeping the UI responsive.
33
+ Uses QApplication.processEvents() to process pending events.
34
+ """
35
+ end_time = time.time() + duration_sec
36
+ while time.time() < end_time:
37
+ QApplication.processEvents()
38
+ # Sleep a tiny bit to avoid 100% CPU usage
39
+ sleep_time = min(0.05, end_time - time.time())
40
+ if sleep_time > 0:
41
+ time.sleep(sleep_time)
42
+
43
+
26
44
  def safe_join_dir_and_name(directory: str, basename: str) -> str:
27
45
  """
28
46
  Join directory + sanitized basename.
@@ -128,30 +128,34 @@ class LRUDict(OrderedDict):
128
128
  When maxsize is exceeded, oldest items are evicted.
129
129
  Thread-safe for basic operations.
130
130
  """
131
- __slots__ = ('maxsize',)
131
+ __slots__ = ('maxsize', '_lock')
132
132
 
133
133
  def __init__(self, maxsize: int = 500):
134
134
  super().__init__()
135
135
  self.maxsize = maxsize
136
+ self._lock = threading.RLock()
136
137
 
137
138
  def __getitem__(self, key):
138
- # Move to end on access (most recently used)
139
- self.move_to_end(key)
140
- return super().__getitem__(key)
141
-
142
- def get(self, key, default=None):
143
- if key in self:
139
+ with self._lock:
140
+ # Move to end on access (most recently used)
144
141
  self.move_to_end(key)
145
142
  return super().__getitem__(key)
146
- return default
143
+
144
+ def get(self, key, default=None):
145
+ with self._lock:
146
+ if key in self:
147
+ self.move_to_end(key)
148
+ return super().__getitem__(key)
149
+ return default
147
150
 
148
151
  def __setitem__(self, key, value):
149
- if key in self:
150
- self.move_to_end(key)
151
- super().__setitem__(key, value)
152
- # Evict oldest if over limit
153
- while len(self) > self.maxsize:
154
- self.popitem(last=False) # Remove oldest
152
+ with self._lock:
153
+ if key in self:
154
+ self.move_to_end(key)
155
+ super().__setitem__(key, value)
156
+ # Evict oldest if over limit
157
+ while len(self) > self.maxsize:
158
+ self.popitem(last=False) # Remove oldest
155
159
 
156
160
 
157
161
  # ============================================================================
@@ -131,6 +131,71 @@ def _parse_gdrive_download_form(html: str) -> tuple[Optional[str], Optional[dict
131
131
 
132
132
  return action, params
133
133
 
134
+ def download_http_file(
135
+ url: str,
136
+ dst_path: str | os.PathLike,
137
+ *,
138
+ progress_cb: ProgressCB = None,
139
+ should_cancel=None,
140
+ timeout: int = 60,
141
+ chunk_size: int = 1024 * 1024,
142
+ ) -> Path:
143
+ import requests
144
+
145
+ dst = Path(dst_path)
146
+ tmp = dst.with_suffix(dst.suffix + ".part")
147
+ tmp.parent.mkdir(parents=True, exist_ok=True)
148
+
149
+ def log(msg: str):
150
+ if progress_cb:
151
+ progress_cb(msg)
152
+
153
+ try:
154
+ tmp.unlink(missing_ok=True)
155
+ except Exception:
156
+ pass
157
+
158
+ with requests.Session() as s:
159
+ log(f"Connecting… {url}")
160
+ r = s.get(url, stream=True, timeout=timeout, allow_redirects=True)
161
+ r.raise_for_status()
162
+
163
+ total = int(r.headers.get("Content-Length") or 0)
164
+ done = 0
165
+ t_last = time.time()
166
+ done_last = 0
167
+
168
+ with open(tmp, "wb") as f:
169
+ for chunk in r.iter_content(chunk_size=chunk_size):
170
+ if should_cancel and should_cancel():
171
+ try:
172
+ f.close()
173
+ tmp.unlink(missing_ok=True)
174
+ except Exception:
175
+ pass
176
+ raise RuntimeError("Download canceled.")
177
+
178
+ if not chunk:
179
+ continue
180
+
181
+ f.write(chunk)
182
+ done += len(chunk)
183
+
184
+ now = time.time()
185
+ if now - t_last >= 0.5:
186
+ if total > 0:
187
+ pct = (done * 100.0) / total
188
+ log(f"Downloading… {pct:5.1f}% ({done}/{total} bytes)")
189
+ else:
190
+ bps = (done - done_last) / max(now - t_last, 1e-9)
191
+ log(f"Downloading… {done} bytes ({bps/1024/1024:.1f} MB/s)")
192
+ t_last = now
193
+ done_last = done
194
+
195
+ os.replace(str(tmp), str(dst))
196
+ log(f"Download complete: {dst}")
197
+ return dst
198
+
134
199
 
135
200
  def download_google_drive_file(
136
201
  file_id: str,
@@ -9,6 +9,7 @@ import zipfile
9
9
  from setiastro.saspro.model_manager import (
10
10
  extract_drive_file_id,
11
11
  download_google_drive_file,
12
+ download_http_file,
12
13
  install_models_zip,
13
14
  sha256_file,
14
15
  )
@@ -54,49 +55,82 @@ class ModelsDownloadWorker(QObject):
54
55
  progress = pyqtSignal(str)
55
56
  finished = pyqtSignal(bool, str)
56
57
 
57
- def __init__(self, primary: str, backup: str, expected_sha256: str | None = None, should_cancel=None):
58
+ def __init__(self, primary: str, backup: str, tertiary: str | None = None,
59
+ expected_sha256: str | None = None, should_cancel=None):
58
60
  super().__init__()
59
61
  self.primary = primary
60
62
  self.backup = backup
63
+ self.tertiary = (tertiary or "").strip() or None
61
64
  self.expected_sha256 = (expected_sha256 or "").strip() or None
62
65
  self.should_cancel = should_cancel # callable -> bool
63
66
 
64
67
  def run(self):
65
68
  try:
66
- # The inputs should be FILE links (or IDs), not folder links.
67
- fid = extract_drive_file_id(self.primary) or extract_drive_file_id(self.backup)
68
- if not fid:
69
- raise RuntimeError(
70
- "Models URL is not a Google Drive *file* link or id.\n"
71
- "Please provide a shared file link (…/file/d/<ID>/view) to the models zip."
72
- )
73
-
74
69
  tmp = os.path.join(tempfile.gettempdir(), "saspro_models_latest.zip")
75
- try:
76
- self.progress.emit("Downloading from primary…")
77
- download_google_drive_file(fid, tmp, progress_cb=lambda s: self.progress.emit(s), should_cancel=self.should_cancel)
78
- except Exception as e:
79
- # Try backup if primary fails AND backup has a different file id
80
- fid2 = extract_drive_file_id(self.backup)
81
- if fid2 and fid2 != fid:
82
- self.progress.emit("Primary failed. Trying backup…")
83
- download_google_drive_file(fid2, tmp, progress_cb=lambda s: self.progress.emit(s), should_cancel=self.should_cancel)
84
- else:
85
- raise
86
70
 
71
+ # 1) Try Google Drive primary/backup (by file id)
72
+ fid_primary = extract_drive_file_id(self.primary)
73
+ fid_backup = extract_drive_file_id(self.backup)
74
+
75
+ drive_ok = False
76
+ used_source = None
77
+
78
+ if fid_primary or fid_backup:
79
+ try:
80
+ if fid_primary:
81
+ self.progress.emit("Downloading from primary (Google Drive)…")
82
+ download_google_drive_file(
83
+ fid_primary, tmp,
84
+ progress_cb=lambda s: self.progress.emit(s),
85
+ should_cancel=self.should_cancel
86
+ )
87
+ drive_ok = True
88
+ used_source = ("google_drive", fid_primary)
89
+ else:
90
+ raise RuntimeError("Primary is not a valid Drive file link/id.")
91
+ except Exception:
92
+ # Try backup if different id
93
+ if fid_backup and fid_backup != fid_primary:
94
+ self.progress.emit("Primary failed. Trying backup (Google Drive)…")
95
+ download_google_drive_file(
96
+ fid_backup, tmp,
97
+ progress_cb=lambda s: self.progress.emit(s),
98
+ should_cancel=self.should_cancel
99
+ )
100
+ drive_ok = True
101
+ used_source = ("google_drive", fid_backup)
102
+
103
+ # 2) If Drive failed (or links weren’t Drive), try GitHub / HTTP tertiary
104
+ if not drive_ok:
105
+ if not self.tertiary:
106
+ raise RuntimeError(
107
+ "Google Drive download failed and no tertiary mirror URL was provided."
108
+ )
109
+ self.progress.emit("Google Drive failed. Trying GitHub mirror…")
110
+ download_http_file(
111
+ self.tertiary, tmp,
112
+ progress_cb=lambda s: self.progress.emit(s),
113
+ should_cancel=self.should_cancel
114
+ )
115
+ used_source = ("http", self.tertiary)
116
+
117
+ # 3) Optional checksum
87
118
  if self.expected_sha256:
88
119
  self.progress.emit("Verifying checksum…")
89
120
  got = sha256_file(tmp)
90
121
  if got.lower() != self.expected_sha256.lower():
91
122
  raise RuntimeError(f"SHA256 mismatch.\nExpected: {self.expected_sha256}\nGot: {got}")
92
123
 
124
+ # 4) Install + manifest
125
+ src_kind, src_val = used_source if used_source else ("unknown", "")
93
126
  manifest = {
94
- "source": "google_drive",
95
- "file_id": fid,
96
- "sha256": self.expected_sha256,
127
+ "source": src_kind,
128
+ "source_ref": src_val,
129
+ "sha256": self.expected_sha256 or "",
97
130
  }
98
- install_models_zip(tmp, progress_cb=lambda s: self.progress.emit(s), manifest=manifest)
99
131
 
132
+ install_models_zip(tmp, progress_cb=lambda s: self.progress.emit(s), manifest=manifest)
100
133
  self.finished.emit(True, "Models updated successfully.")
101
134
  except Exception as e:
102
135
  self.finished.emit(False, str(e))
136
+
@@ -248,7 +248,8 @@ class SettingsDialog(QDialog):
248
248
  self.btn_models_install_zip.clicked.connect(self._models_install_from_zip_clicked)
249
249
 
250
250
  self.btn_models_open_drive = QPushButton(self.tr("Open Drive…"))
251
- self.btn_models_open_drive.setToolTip(self.tr("Open the models folder in your browser (Primary/Backup)"))
251
+ self.btn_models_open_drive.setToolTip(self.tr("Download models (Primary/Backup/GitHub mirror)"))
252
+
252
253
  self.btn_models_open_drive.clicked.connect(self._models_open_drive_clicked)
253
254
 
254
255
  row_models = QHBoxLayout()
@@ -319,16 +320,22 @@ class SettingsDialog(QDialog):
319
320
  def _models_open_drive_clicked(self):
320
321
  PRIMARY_FOLDER = "https://drive.google.com/drive/folders/1-fktZb3I9l-mQimJX2fZAmJCBj_t0yAF?usp=drive_link"
321
322
  BACKUP_FOLDER = "https://drive.google.com/drive/folders/1j46RV6touQtOmtxkhdFWGm_LQKwEpTl9?usp=drive_link"
323
+ GITHUB_ZIP = "https://github.com/setiastro/setiastrosuitepro/releases/download/benchmarkFIT/SASPro_Models_Latest.zip"
322
324
 
323
325
  menu = QMenu(self)
324
- act_primary = menu.addAction(self.tr("Primary"))
325
- act_backup = menu.addAction(self.tr("Backup"))
326
+ act_primary = menu.addAction(self.tr("Primary (Google Drive)"))
327
+ act_backup = menu.addAction(self.tr("Backup (Google Drive)"))
328
+ menu.addSeparator()
329
+ act_gh = menu.addAction(self.tr("GitHub (no quota limit)"))
326
330
 
327
331
  chosen = menu.exec(self.btn_models_open_drive.mapToGlobal(self.btn_models_open_drive.rect().bottomLeft()))
328
332
  if chosen == act_primary:
329
333
  webbrowser.open(PRIMARY_FOLDER)
330
334
  elif chosen == act_backup:
331
335
  webbrowser.open(BACKUP_FOLDER)
336
+ elif chosen == act_gh:
337
+ webbrowser.open(GITHUB_ZIP)
338
+
332
339
 
333
340
 
334
341
  def _models_install_from_zip_clicked(self):
@@ -404,6 +411,7 @@ class SettingsDialog(QDialog):
404
411
  # Put your actual *zip file* share links here once you create them.
405
412
  PRIMARY = "https://drive.google.com/file/d/1n4p0grtNpfllalMqtgaEmsTYaFhT5u7Y/view?usp=drive_link"
406
413
  BACKUP = "https://drive.google.com/file/d/1uRGJCITlfMMN89ZkOO5ICWEKMH24KGit/view?usp=drive_link"
414
+ TERTIARY = "https://github.com/setiastro/setiastrosuitepro/releases/download/benchmarkFIT/SASPro_Models_Latest.zip"
407
415
 
408
416
 
409
417
  self.btn_models_update.setEnabled(False)
@@ -418,7 +426,7 @@ class SettingsDialog(QDialog):
418
426
  from setiastro.saspro.model_workers import ModelsDownloadWorker
419
427
 
420
428
  self._models_thread = QThread(self)
421
- self._models_worker = ModelsDownloadWorker(PRIMARY, BACKUP, expected_sha256=None)
429
+ self._models_worker = ModelsDownloadWorker(PRIMARY, BACKUP, TERTIARY, expected_sha256=None)
422
430
  self._models_worker.moveToThread(self._models_thread)
423
431
 
424
432
  self._models_thread.started.connect(self._models_worker.run, Qt.ConnectionType.QueuedConnection)
@@ -457,10 +465,39 @@ class SettingsDialog(QDialog):
457
465
  if not m:
458
466
  self.lbl_models_status.setText(self.tr("Status: not installed"))
459
467
  return
460
- fid = m.get("file_id", "")
461
- self.lbl_models_status.setText(
462
- self.tr("Status: installed\nLocation: {0}").format(models_root())
463
- )
468
+
469
+ # New fields (preferred)
470
+ src = (m.get("source") or "").strip()
471
+ ref = (m.get("source_ref") or "").strip()
472
+ sha = (m.get("sha256") or "").strip()
473
+
474
+ # Back-compat with older manifests
475
+ if not src:
476
+ # old versions used google_drive + file_id
477
+ src = "google_drive" if m.get("file_id") else (m.get("source") or "unknown")
478
+ if not ref:
479
+ ref = (m.get("file_id") or m.get("file") or "").strip()
480
+
481
+ # Keep it short + readable
482
+ src_label = {
483
+ "google_drive": "Google Drive",
484
+ "http": "GitHub/HTTP",
485
+ "manual_zip": "Manual ZIP",
486
+ }.get(src, src or "Unknown")
487
+
488
+ lines = [self.tr("Status: installed"),
489
+ self.tr("Location: {0}").format(models_root())]
490
+
491
+ if ref:
492
+ # show just a compact hint (id or url or filename)
493
+ lines.append(self.tr("Source: {0}").format(src_label))
494
+ lines.append(self.tr("Ref: {0}").format(ref))
495
+
496
+ if sha:
497
+ lines.append(self.tr("SHA256: {0}").format(sha[:12] + "…"))
498
+
499
+ self.lbl_models_status.setText("\n".join(lines))
500
+ self.lbl_models_status.setStyleSheet("color:#888;")
464
501
 
465
502
 
466
503
  def _accel_pref_changed(self, idx: int):