setiastrosuitepro 1.6.0__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 (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,2435 @@
1
+ # pro/plate_solver.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import math
7
+ import tempfile
8
+ from typing import Tuple, Dict, Any, Optional
9
+ from functools import lru_cache
10
+
11
+ import numpy as np
12
+ import json
13
+ import time
14
+ import requests
15
+ from astropy.io import fits
16
+ from astropy.io.fits import Header
17
+ from astropy.wcs import WCS
18
+
19
+ from PyQt6.QtCore import QProcess, QTimer, QEventLoop, Qt
20
+ from PyQt6.QtGui import QIcon
21
+ from PyQt6.QtWidgets import (
22
+ QDialog, QLabel, QPushButton, QVBoxLayout, QHBoxLayout,
23
+ QFileDialog, QComboBox, QStackedWidget, QWidget, QMessageBox,
24
+ QLineEdit, QTextEdit, QApplication, QProgressBar
25
+ )
26
+
27
+ # === our I/O & stretch — migrate from SASv2 ===
28
+ from setiastro.saspro.legacy.image_manager import load_image, save_image # <<<< IMPORTANT
29
+ try:
30
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
31
+ except Exception:
32
+ stretch_mono_image = None
33
+ stretch_color_image = None
34
+
35
+
36
+ _NONFITS_META_KEYS = {
37
+ "FILE_PATH",
38
+ "FITS_HEADER",
39
+ "BIT_DEPTH",
40
+ "WCS_HEADER",
41
+ "__HEADER_SNAPSHOT__",
42
+ "ORIGINAL_HEADER",
43
+ "PRE_SOLVE_HEADER",
44
+ }
45
+
46
+ def _strip_nonfits_meta_keys_from_header(h: Header | None) -> Header:
47
+ """
48
+ Return a copy of the header with all of our internal, non-FITS metadata
49
+ keys removed. This prevents HIERARCH warnings and WCS failures on keys
50
+ like FILE_PATH with very long values.
51
+ """
52
+ if not isinstance(h, Header):
53
+ return Header()
54
+
55
+ out = h.copy()
56
+ for k in list(out.keys()):
57
+ if k.upper() in _NONFITS_META_KEYS:
58
+ try:
59
+ out.remove(k)
60
+ except Exception:
61
+ pass
62
+ return out
63
+
64
+ # --- Lightweight, modeless status popup for headless runs ---
65
+ _STATUS_POPUP = None # module-level singleton
66
+
67
+ class _SolveStatusPopup(QDialog):
68
+ def __init__(self, parent=None):
69
+ super().__init__(parent, Qt.WindowType.Tool)
70
+ self.setObjectName("plate_solve_status_popup")
71
+ self.setWindowTitle("Plate Solving")
72
+ self.setWindowModality(Qt.WindowModality.NonModal)
73
+ self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
74
+ self.setMinimumWidth(420)
75
+
76
+ lay = QVBoxLayout(self)
77
+ lay.setContentsMargins(12, 12, 12, 12)
78
+ lay.setSpacing(10)
79
+
80
+ self.label = QLabel("Starting…", self)
81
+ self.label.setWordWrap(True)
82
+ lay.addWidget(self.label)
83
+
84
+ self.bar = QProgressBar(self)
85
+ self.bar.setRange(0, 0) # indeterminate
86
+ lay.addWidget(self.bar)
87
+
88
+ row = QHBoxLayout()
89
+ row.addStretch(1)
90
+ hide_btn = QPushButton("Hide", self)
91
+ hide_btn.clicked.connect(self.hide)
92
+ row.addWidget(hide_btn)
93
+ lay.addLayout(row)
94
+
95
+ def update_text(self, text: str):
96
+ self.label.setText(text or "")
97
+ self.label.repaint() # quick visual feedback
98
+ QApplication.processEvents()
99
+
100
+ def _status_popup_open(parent, text: str = ""):
101
+ """Show (or create) the singleton status popup for headless runs."""
102
+ global _STATUS_POPUP
103
+ if _STATUS_POPUP is None:
104
+ host = parent if isinstance(parent, QWidget) else QApplication.activeWindow()
105
+ _STATUS_POPUP = _SolveStatusPopup(host)
106
+ if text:
107
+ _STATUS_POPUP.update_text(text)
108
+ _STATUS_POPUP.show()
109
+ _STATUS_POPUP.raise_()
110
+ QApplication.processEvents()
111
+ return _STATUS_POPUP
112
+
113
+ def _status_popup_update(text: str):
114
+ global _STATUS_POPUP
115
+ if _STATUS_POPUP is not None:
116
+ _STATUS_POPUP.update_text(text)
117
+
118
+ def _status_popup_close():
119
+ """Hide (but do not destroy) the singleton status popup if it exists."""
120
+ global _STATUS_POPUP
121
+ try:
122
+ if _STATUS_POPUP is not None:
123
+ _STATUS_POPUP.hide()
124
+ # keep instance for reuse (fast re-open)
125
+ except Exception:
126
+ # Completely safe to ignore; worst case the popup was already gone.
127
+ pass
128
+
129
+ def _sleep_ui(ms: int):
130
+ """Non-blocking sleep that keeps the UI responsive."""
131
+ loop = QEventLoop()
132
+ QTimer.singleShot(ms, loop.quit)
133
+ loop.exec()
134
+
135
+ def _with_events():
136
+ """Yield to the UI event loop briefly."""
137
+ QApplication.processEvents()
138
+
139
+ def _set_status_ui(parent, text: str):
140
+ """
141
+ Update dialog/main-window status or batch log; if neither exists (headless),
142
+ show/update a small modeless popup. Always pumps events for responsiveness.
143
+ """
144
+ try:
145
+ updated_any = False
146
+
147
+ def _do():
148
+ nonlocal updated_any
149
+ target = None
150
+ # Dialog status label?
151
+ if hasattr(parent, "status") and isinstance(getattr(parent, "status"), QLabel):
152
+ target = parent.status
153
+ # Named child fallback
154
+ if target is None and hasattr(parent, "findChild"):
155
+ target = parent.findChild(QLabel, "status_label")
156
+ if target is not None:
157
+ target.setText(text)
158
+ updated_any = True
159
+
160
+ # Batch log?
161
+ logw = getattr(parent, "log", None)
162
+ if logw and hasattr(logw, "append"):
163
+ if text and (text.startswith("Status:") or text.startswith("▶") or text.startswith("✔") or text.startswith("❌")):
164
+ logw.append(text)
165
+ updated_any = True
166
+
167
+ # If we couldn't update any inline widget, use the headless popup.
168
+ if not updated_any:
169
+ _status_popup_open(parent, text)
170
+ else:
171
+ # If inline widgets exist and popup is visible, keep it quiet.
172
+ _status_popup_update(text)
173
+
174
+ QApplication.processEvents()
175
+
176
+ if isinstance(parent, QWidget):
177
+ QTimer.singleShot(0, _do)
178
+ else:
179
+ _do()
180
+ except Exception:
181
+ # Last-resort popup if even the above failed
182
+ try:
183
+ _status_popup_open(parent, text)
184
+ except Exception:
185
+ pass
186
+
187
+ def _wait_process(proc: QProcess, timeout_ms: int, parent=None) -> bool:
188
+ """
189
+ Incrementally wait for a QProcess while pumping UI events so the dialog stays responsive.
190
+ Returns True if the process finished with NormalExit, else False.
191
+ """
192
+ deadline = time.monotonic() + (timeout_ms / 1000.0)
193
+ step_ms = 100
194
+
195
+ while time.monotonic() < deadline:
196
+ if proc.state() == QProcess.ProcessState.NotRunning:
197
+ break
198
+ _sleep_ui(step_ms)
199
+
200
+ if proc.state() != QProcess.ProcessState.NotRunning:
201
+ # Timed out: try to stop the process cleanly, then force kill.
202
+ try:
203
+ proc.terminate()
204
+ if not proc.waitForFinished(2000):
205
+ proc.kill()
206
+ proc.waitForFinished(2000)
207
+ except Exception:
208
+ pass
209
+ _set_status_ui(parent, "Status: process timed out.")
210
+ return False
211
+
212
+ if proc.exitStatus() != QProcess.ExitStatus.NormalExit:
213
+ _set_status_ui(parent, "Status: process did not exit normally.")
214
+ return False
215
+
216
+ return True
217
+
218
+ # --- astrometry.net config (web API) ---
219
+ ASTROMETRY_API_URL_DEFAULT = "https://nova.astrometry.net/api/"
220
+
221
+ def _get_astrometry_api_url(settings) -> str:
222
+ return (settings.value("astrometry/server_url", "", type=str) or ASTROMETRY_API_URL_DEFAULT).rstrip("/") + "/"
223
+
224
+ def _get_solvefield_exe(settings) -> str:
225
+ # Support both SASpro-style and legacy keys
226
+ cand = [
227
+ settings.value("paths/solve_field", "", type=str) or "",
228
+ settings.value("astrometry/solvefield_path", "", type=str) or "",
229
+ ]
230
+ for p in cand:
231
+ if p and os.path.exists(p):
232
+ return p
233
+ return cand[0] # may be empty (used to decide web vs. local)
234
+
235
+ def _get_astrometry_api_key(settings) -> str:
236
+ return settings.value("astrometry/api_key", "", type=str) or ""
237
+
238
+ def _set_astrometry_api_key(settings, key: str):
239
+ settings.setValue("astrometry/api_key", key or "")
240
+
241
+ def _wcs_header_from_astrometry_calib(calib: dict, image_shape: tuple[int, ...]) -> Header:
242
+ """
243
+ calib: dict with keys 'ra','dec','pixscale'(arcsec/px),'orientation'(deg, +CCW).
244
+ image_shape: (H, W) or (H, W, C). CRPIX is image center (1-based vs 0-based—astropy expects pixel coordinates in "fits" sense; mid-frame is fine).
245
+ """
246
+ H = int(image_shape[0]); W = int(image_shape[1])
247
+ h = Header()
248
+ h["CTYPE1"] = "RA---TAN"
249
+ h["CTYPE2"] = "DEC--TAN"
250
+ h["CRPIX1"] = W / 2.0
251
+ h["CRPIX2"] = H / 2.0
252
+ h["CRVAL1"] = float(calib["ra"])
253
+ h["CRVAL2"] = float(calib["dec"])
254
+ scale_deg = float(calib["pixscale"]) / 3600.0 # deg/px
255
+ theta = math.radians(float(calib.get("orientation", 0.0)))
256
+ # note: same sign convention as your SASv2 builder
257
+ h["CD1_1"] = -scale_deg * math.cos(theta)
258
+ h["CD1_2"] = scale_deg * math.sin(theta)
259
+ h["CD2_1"] = -scale_deg * math.sin(theta)
260
+ h["CD2_2"] = -scale_deg * math.cos(theta)
261
+ h["RADECSYS"] = "ICRS"
262
+ h["WCSAXES"] = 2
263
+ return h
264
+
265
+ # If you already ship 'requests', this is simplest:
266
+
267
+ # ---- Seed controls (persisted in QSettings) ----
268
+ def _get_seed_mode(settings) -> str:
269
+ # "auto" (from header), "manual" (use user values), "none" (blind)
270
+ return (settings.value("astap/seed_mode", "auto", type=str) or "auto").lower()
271
+
272
+ def _set_seed_mode(settings, mode: str):
273
+ settings.setValue("astap/seed_mode", (mode or "auto").lower())
274
+
275
+ def _get_manual_ra(settings) -> str:
276
+ # store raw string so user can type hh:mm:ss or degrees
277
+ return settings.value("astap/manual_ra", "", type=str) or ""
278
+
279
+ def _get_manual_dec(settings) -> str:
280
+ return settings.value("astap/manual_dec", "", type=str) or ""
281
+
282
+ def _get_manual_scale(settings) -> float | None:
283
+ try:
284
+ v = settings.value("astap/manual_scale_arcsec", "", type=str)
285
+ return float(v) if v not in (None, "",) else None
286
+ except Exception:
287
+ return None
288
+
289
+ @lru_cache(maxsize=256)
290
+ def _parse_ra_input_to_deg(s: str) -> float | None:
291
+ """Parse RA input string to degrees. Cached for repeated lookups."""
292
+ s = (s or "").strip()
293
+ if not s: return None
294
+ # allow plain degrees if > 24 or contains "deg"
295
+ try:
296
+ if re.search(r"[a-zA-Z]", s) is None and ":" not in s and " " not in s:
297
+ x = float(s)
298
+ return x if x > 24.0 else x * 15.0
299
+ except Exception:
300
+ pass
301
+ parts = re.split(r"[:\s]+", s)
302
+ try:
303
+ if len(parts) >= 3:
304
+ hh, mm, ss = float(parts[0]), float(parts[1]), float(parts[2])
305
+ elif len(parts) == 2:
306
+ hh, mm, ss = float(parts[0]), float(parts[1]), 0.0
307
+ else:
308
+ hh, mm, ss = float(parts[0]), 0.0, 0.0
309
+ return (abs(hh) + mm/60.0 + ss/3600.0) * 15.0
310
+ except Exception:
311
+ return None
312
+
313
+ @lru_cache(maxsize=256)
314
+ def _parse_dec_input_to_deg(s: str) -> float | None:
315
+ """Parse DEC input string to degrees. Cached for repeated lookups."""
316
+ s = (s or "").strip()
317
+ if not s: return None
318
+ sign = -1.0 if s.startswith("-") else 1.0
319
+ s = s.lstrip("+-")
320
+ parts = re.split(r"[:\s]+", s)
321
+ try:
322
+ if len(parts) >= 3:
323
+ dd, mm, ss = float(parts[0]), float(parts[1]), float(parts[2])
324
+ elif len(parts) == 2:
325
+ dd, mm, ss = float(parts[0]), float(parts[1]), 0.0
326
+ else:
327
+ return sign * float(parts[0])
328
+ return sign * (abs(dd) + mm/60.0 + ss/3600.0)
329
+ except Exception:
330
+ return None
331
+
332
+ def _set_manual_seed(settings, ra: str, dec: str, scale_arcsec: float | None):
333
+ settings.setValue("astap/manual_ra", ra or "")
334
+ settings.setValue("astap/manual_dec", dec or "")
335
+ if scale_arcsec is None:
336
+ settings.setValue("astap/manual_scale_arcsec", "")
337
+ else:
338
+ settings.setValue("astap/manual_scale_arcsec", str(float(scale_arcsec)))
339
+
340
+ def _astrometry_api_request(method: str, url: str, *, data=None, files=None,
341
+ timeout=(10, 60),
342
+ max_retries: int = 5,
343
+ parent=None,
344
+ stage: str = "") -> dict | None:
345
+ """
346
+ Robust request with retries, exponential backoff + jitter.
347
+ """
348
+ if requests is None:
349
+ print("Requests not available for astrometry.net API.")
350
+ return None
351
+
352
+ import random
353
+ import requests as _rq
354
+ for attempt in range(1, max_retries + 1):
355
+ try:
356
+ if method.upper() == "POST":
357
+ # ✅ IMPORTANT: rewind any file handles before each attempt,
358
+ # because requests consumes them.
359
+ if files:
360
+ try:
361
+ for v in files.values():
362
+ if hasattr(v, "seek"):
363
+ v.seek(0)
364
+ except Exception:
365
+ pass
366
+
367
+ r = requests.post(url, data=data, files=files, timeout=timeout)
368
+ else:
369
+ r = requests.get(url, timeout=timeout)
370
+
371
+ if r.status_code == 200:
372
+ try:
373
+ return r.json()
374
+ except Exception:
375
+ return None
376
+
377
+ if r.status_code in (429, 500, 502, 503, 504):
378
+ raise _rq.RequestException(f"HTTP {r.status_code}")
379
+ else:
380
+ print(f"Astrometry API HTTP {r.status_code} (no retry).")
381
+ return None
382
+
383
+ except (_rq.Timeout, _rq.ConnectionError, _rq.RequestException) as e:
384
+ print(f"Astrometry API request error ({stage}): {e}")
385
+ if attempt >= max_retries:
386
+ break
387
+ delay = min(8.0, 0.5 * (2 ** (attempt - 1))) + random.random() * 0.2
388
+ _set_status_ui(parent, f"Status: {stage or 'request'} retry {attempt}/{max_retries}…")
389
+ _sleep_ui(int(delay * 1000))
390
+ return None
391
+
392
+
393
+ # ---------------------------------------------------------------------
394
+ # Utilities (headers, parsing, normalization)
395
+ # ---------------------------------------------------------------------
396
+
397
+ def _parse_astap_wcs_file(wcs_path: str) -> Dict[str, Any]:
398
+ """
399
+ Robustly load the .wcs file using astropy (instead of line parsing).
400
+ Returns a dictionary of key → value.
401
+ """
402
+ if not wcs_path or not os.path.exists(wcs_path):
403
+ return {}
404
+
405
+ try:
406
+ header = fits.getheader(wcs_path)
407
+ return dict(header)
408
+ except Exception as e:
409
+ print(f"[ASTAP] Failed to parse .wcs with astropy: {e}")
410
+ return {}
411
+
412
+
413
+ def _get_astap_exe(settings) -> str:
414
+ # Support both SASpro and SASv2 keys.
415
+ cand = [
416
+ settings.value("paths/astap", "", type=str) or "",
417
+ settings.value("astap/exe_path", "", type=str) or "",
418
+ ]
419
+ for p in cand:
420
+ if p and os.path.exists(p):
421
+ return p
422
+ return cand[0] # return first even if missing so we can error nicely
423
+
424
+
425
+ def _as_header(hdr_like: Any) -> Header | None:
426
+ """
427
+ Try to coerce whatever we have in metadata to a proper astropy Header.
428
+ Accepts: fits.Header, dict, flattened string blobs (best effort).
429
+ """
430
+ if hdr_like is None:
431
+ return None
432
+ if isinstance(hdr_like, Header):
433
+ return hdr_like
434
+
435
+ # 1) flattened single string? try hard to parse
436
+ if isinstance(hdr_like, str):
437
+ h = _parse_header_blob_to_header(hdr_like)
438
+ return h if len(h.keys()) else None
439
+
440
+ # 2) dict-ish
441
+ try:
442
+ d = dict(hdr_like)
443
+ h = Header()
444
+ int_keys = {"A_ORDER", "B_ORDER", "AP_ORDER", "BP_ORDER", "WCSAXES", "NAXIS", "NAXIS1", "NAXIS2", "NAXIS3"}
445
+ for k, v in d.items():
446
+ K = str(k).upper()
447
+
448
+ # 🚫 Never promote our internal metadata keys to FITS cards
449
+ if K in _NONFITS_META_KEYS:
450
+ continue
451
+
452
+ try:
453
+ if K in int_keys:
454
+ h[K] = int(float(str(v).strip().split()[0]))
455
+ elif re.match(r"^(?:A|B|AP|BP)_\d+_\d+$", K) or \
456
+ re.match(r"^(?:CRPIX|CRVAL|CDELT|CD|PC|CROTA|LATPOLE|LONPOLE|EQUINOX)\d?_?\d*$", K):
457
+ h[K] = float(str(v).strip().split()[0])
458
+ elif K.startswith("CTYPE") or K.startswith("CUNIT") or K in {"RADECSYS"}:
459
+ h[K] = str(v).strip().strip("'\"")
460
+ else:
461
+ h[K] = v
462
+ except Exception:
463
+ pass
464
+
465
+ # SIP order parity
466
+ if "A_ORDER" in h and "B_ORDER" not in h:
467
+ h["B_ORDER"] = int(h["A_ORDER"])
468
+ if "B_ORDER" in h and "A_ORDER" not in h:
469
+ h["A_ORDER"] = int(h["B_ORDER"])
470
+ return h
471
+ except Exception:
472
+ return None
473
+
474
+
475
+ def _parse_header_blob_to_header(blob: str) -> Header:
476
+ """
477
+ Turn a flattened header blob into a real fits.Header.
478
+ Handles 80-char concatenated cards or KEY=VAL regex fallback.
479
+ """
480
+ s = (blob or "").strip()
481
+ h = fits.Header()
482
+
483
+ # A) 80-char card chunking (if truly concatenated FITS cards)
484
+ if len(s) >= 80 and len(s) % 80 == 0:
485
+ cards = [s[i:i+80] for i in range(0, len(s), 80)]
486
+ for line in cards:
487
+ try:
488
+ card = fits.Card.fromstring(line)
489
+ if card.keyword not in ("COMMENT", "HISTORY", "END", ""):
490
+ h.append(card)
491
+ except Exception:
492
+ pass
493
+ if len(h.keys()):
494
+ return h
495
+
496
+ # B) Fallback regex KEY = value … next KEY
497
+ pattern = r"([A-Z0-9_]+)\s*=\s*([^=]*?)(?=\s{2,}[A-Z0-9_]+\s*=|$)"
498
+ for m in re.finditer(pattern, s):
499
+ key = m.group(1).strip().upper()
500
+ vraw = m.group(2).strip()
501
+ if vraw.startswith("'") and vraw.endswith("'"):
502
+ val = vraw[1:-1].strip()
503
+ else:
504
+ try:
505
+ if re.fullmatch(r"[+-]?\d+", vraw): val = int(vraw)
506
+ else: val = float(vraw)
507
+ except Exception:
508
+ val = vraw
509
+ try: h[key] = val
510
+ except Exception as e:
511
+ import logging
512
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
513
+
514
+ if "A_ORDER" in h and "B_ORDER" not in h:
515
+ h["B_ORDER"] = int(h["A_ORDER"])
516
+ if "B_ORDER" in h and "A_ORDER" not in h:
517
+ h["A_ORDER"] = int(h["B_ORDER"])
518
+
519
+ return h
520
+
521
+
522
+ def _strip_wcs_keys(h: Header) -> Header:
523
+ """Return a copy without WCS/SIP keys (so ASTAP can write fresh)."""
524
+ h = h.copy()
525
+ for key in list(h.keys()):
526
+ ku = key.upper()
527
+ for prefix in (
528
+ "CRPIX", "CRVAL", "CDELT", "CROTA",
529
+ "CD1_", "CD2_", "PC", "CTYPE", "CUNIT",
530
+ "WCSAXES", "LATPOLE", "LONPOLE", "EQUINOX",
531
+ "PV1_", "PV2_", "SIP",
532
+ "A_ORDER", "B_ORDER", "AP_ORDER", "BP_ORDER",
533
+ "A_", "B_", "AP_", "BP_", "PLTSOLVD"
534
+ ):
535
+ if ku.startswith(prefix):
536
+ h.pop(key, None)
537
+ break
538
+ return h
539
+
540
+ def _minimal_header_for_gray2d(h: int, w: int) -> Header:
541
+ hdu = Header()
542
+ hdu["SIMPLE"] = True
543
+ hdu["BITPIX"] = -32
544
+ hdu["NAXIS"] = 2
545
+ hdu["NAXIS1"] = int(w)
546
+ hdu["NAXIS2"] = int(h)
547
+ hdu["BZERO"] = 0.0
548
+ hdu["BSCALE"] = 1.0
549
+ hdu.add_comment("Temp FITS written for ASTAP solve.")
550
+ return hdu
551
+
552
+ def _minimal_header_for(img: np.ndarray, is_mono: bool) -> Header:
553
+ H = int(img.shape[0]) if img.ndim >= 2 else 1
554
+ W = int(img.shape[1]) if img.ndim >= 2 else 1
555
+ C = int(img.shape[2]) if (img.ndim == 3) else 1
556
+ h = Header()
557
+ h["SIMPLE"] = True
558
+ h["BITPIX"] = -32
559
+ h["NAXIS"] = 2 if is_mono else 3
560
+ h["NAXIS1"] = W
561
+ h["NAXIS2"] = H
562
+ if not is_mono:
563
+ h["NAXIS3"] = C
564
+ h["BZERO"] = 0.0
565
+ h["BSCALE"] = 1.0
566
+ h.add_comment("Temp FITS written for ASTAP solve.")
567
+ return h
568
+
569
+ def _write_temp_fit_web_16bit(gray2d_unit: np.ndarray) -> str:
570
+ """
571
+ Write full-res mono FITS as 16-bit unsigned for web upload.
572
+ gray2d_unit must be float32 in [0,1].
573
+ Returns path to temp .fits.
574
+ """
575
+ import os
576
+ import tempfile
577
+ import numpy as np
578
+ from astropy.io import fits
579
+ from astropy.io.fits import Header
580
+
581
+ if gray2d_unit.ndim != 2:
582
+ raise ValueError("Expected 2-D grayscale array for web FITS.")
583
+
584
+ g = np.clip(gray2d_unit.astype(np.float32), 0.0, 1.0)
585
+ u16 = (g * 65535.0 + 0.5).astype(np.uint16)
586
+
587
+ H, W = u16.shape
588
+ hdr = Header()
589
+ hdr["SIMPLE"] = True
590
+ hdr["BITPIX"] = 16
591
+ hdr["NAXIS"] = 2
592
+ hdr["NAXIS1"] = int(W)
593
+ hdr["NAXIS2"] = int(H)
594
+ hdr.add_comment("Temp FITS (16-bit) written for Astrometry.net upload.")
595
+
596
+ tmp = tempfile.NamedTemporaryFile(suffix=".fits", delete=False)
597
+ tmp_path = tmp.name
598
+ tmp.close()
599
+
600
+ fits.PrimaryHDU(u16, header=hdr).writeto(tmp_path, overwrite=True, output_verify="silentfix")
601
+
602
+ try:
603
+ print(f"[tempfits-web] Saved 16-bit FITS to: {tmp_path} (size={os.path.getsize(tmp_path)} bytes)")
604
+ except Exception:
605
+ pass
606
+
607
+ return tmp_path
608
+
609
+
610
+ def _astrometry_download_wcs_file(settings, job_id: int, parent=None) -> Header | None:
611
+ """
612
+ Download the solved WCS FITS from astrometry.net.
613
+ This includes SIP terms when present.
614
+ Returns fits.Header or None.
615
+ """
616
+ import os
617
+ import tempfile
618
+ from astropy.io import fits
619
+ from astropy.io.fits import Header
620
+
621
+ base_site = _get_astrometry_api_url(settings).split("/api/")[0].rstrip("/") + "/"
622
+ url = base_site + f"wcs_file/{int(job_id)}"
623
+
624
+ _set_status_ui(parent, "Status: Downloading WCS file (with SIP) from Astrometry.net…")
625
+ try:
626
+ r = requests.get(url, timeout=(10, 60))
627
+ if r.status_code != 200 or len(r.content) < 2000:
628
+ print(f"[Astrometry] WCS download failed HTTP {r.status_code}, bytes={len(r.content)}")
629
+ return None
630
+
631
+ tmp = tempfile.NamedTemporaryFile(suffix=".wcs.fits", delete=False)
632
+ tmp_path = tmp.name
633
+ tmp.write(r.content)
634
+ tmp.close()
635
+
636
+ try:
637
+ hdr = fits.getheader(tmp_path)
638
+ h2 = Header()
639
+ for k, v in dict(hdr).items():
640
+ if k not in ("COMMENT", "HISTORY", "END"):
641
+ h2[k] = v
642
+ return h2
643
+ finally:
644
+ try: os.remove(tmp_path)
645
+ except Exception as e:
646
+ import logging
647
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
648
+
649
+ except Exception as e:
650
+ print("[Astrometry] WCS download exception:", e)
651
+ return None
652
+
653
+
654
+ def _float01(arr: np.ndarray) -> np.ndarray:
655
+ a = np.asarray(arr)
656
+ if a.dtype.kind in "ui":
657
+ info = np.iinfo(a.dtype)
658
+ if info.max == 0: return a.astype(np.float32)
659
+ return (a.astype(np.float32) / float(info.max))
660
+ return np.clip(a.astype(np.float32), 0.0, 1.0)
661
+
662
+
663
+ def _normalize_for_astap(img: np.ndarray) -> np.ndarray:
664
+ """
665
+ Use migrated stretch functions when available.
666
+ Returns float32 in [0,1], 2D for mono or 3D for color.
667
+ Guaranteed to return something usable even if stretch funcs fail.
668
+ """
669
+ f01 = _float01(img)
670
+
671
+ # Mono
672
+ if f01.ndim == 2 or (f01.ndim == 3 and f01.shape[2] == 1):
673
+ if stretch_mono_image is not None:
674
+ try:
675
+ print("DEBUG stretching mono")
676
+ out = stretch_mono_image(f01, 0.1, False)
677
+ return np.clip(out.astype(np.float32), 0.0, 1.0)
678
+ except Exception as e:
679
+ print("DEBUG mono stretch failed, fallback:", e)
680
+ return np.clip(f01.astype(np.float32), 0.0, 1.0)
681
+
682
+ # Color
683
+ if stretch_color_image is not None:
684
+ try:
685
+ print("DEBUG stretching color")
686
+ out = stretch_color_image(f01, 0.1, False, False)
687
+ return np.clip(out.astype(np.float32), 0.0, 1.0)
688
+ except Exception as e:
689
+ print("DEBUG color stretch failed, fallback:", e)
690
+
691
+ return np.clip(f01.astype(np.float32), 0.0, 1.0)
692
+
693
+
694
+
695
+
696
+ def _first_float(v):
697
+ if v is None: return None
698
+ if isinstance(v, (int, float)): return float(v)
699
+ s = str(v)
700
+ m = re.search(r"[+-]?\d*\.?\d+(?:[eE][+-]?\d+)?", s)
701
+ return float(m.group(0)) if m else None
702
+
703
+
704
+ def _first_int(v):
705
+ if v is None: return None
706
+ if isinstance(v, int): return v
707
+ if isinstance(v, float): return int(round(v))
708
+ s = str(v)
709
+ m = re.search(r"[+-]?\d+", s)
710
+ return int(m.group(0)) if m else None
711
+
712
+
713
+ def _parse_ra_deg(h: Header) -> float | None:
714
+ ra = _first_float(h.get("CRVAL1"))
715
+ if ra is not None: return ra
716
+ ra = _first_float(h.get("RA"))
717
+ if ra is not None and 0.0 <= ra < 360.0: return ra
718
+ for key in ("OBJCTRA", "RA"):
719
+ s = h.get(key);
720
+ if not s: continue
721
+ s = str(s).strip()
722
+ parts = re.split(r"[:\s]+", s)
723
+ try:
724
+ if len(parts) >= 3:
725
+ hh, mm, ss = float(parts[0]), float(parts[1]), float(parts[2])
726
+ elif len(parts) == 2:
727
+ hh, mm, ss = float(parts[0]), float(parts[1]), 0.0
728
+ else:
729
+ x = float(parts[0]);
730
+ return x if x > 24 else x*15.0
731
+ return (abs(hh) + mm/60.0 + ss/3600.0) * 15.0
732
+ except Exception:
733
+ pass
734
+ return None
735
+
736
+
737
+ def _parse_dec_deg(h: Header) -> float | None:
738
+ dec = _first_float(h.get("CRVAL2"))
739
+ if dec is not None: return dec
740
+ dec = _first_float(h.get("DEC"))
741
+ if dec is not None and -90 <= dec <= 90: return dec
742
+ for key in ("OBJCTDEC","DEC"):
743
+ s = h.get(key);
744
+ if not s: continue
745
+ s = str(s).strip()
746
+ sign = -1.0 if s.startswith("-") else 1.0
747
+ s = s.lstrip("+-")
748
+ parts = re.split(r"[:\s]+", s)
749
+ try:
750
+ if len(parts) >= 3:
751
+ dd, mm, ss = float(parts[0]), float(parts[1]), float(parts[2])
752
+ elif len(parts) == 2:
753
+ dd, mm = float(parts[0]), float(parts[1]); ss = 0.0
754
+ else:
755
+ return sign * float(parts[0])
756
+ return sign * (abs(dd) + mm/60.0 + ss/3600.0)
757
+ except Exception:
758
+ pass
759
+ return None
760
+
761
+
762
+ def _compute_scale_arcsec_per_pix(h: Header) -> float | None:
763
+ """
764
+ Try to compute pixel scale from WCS / instrument metadata.
765
+ If the result is obviously insane, return None so we can fall back
766
+ to RA/Dec-only seeding.
767
+ """
768
+ def _sanity(val: float | None) -> float | None:
769
+ if val is None or not np.isfinite(val) or val <= 0:
770
+ return None
771
+ # Typical imaging: ~0.1"–100"/px. Allow up to ~1000"/px for very wide,
772
+ # but anything beyond that is almost certainly bogus.
773
+ if val > 1000.0:
774
+ return None
775
+ return float(val)
776
+
777
+ cd11 = _first_float(h.get("CD1_1"))
778
+ cd21 = _first_float(h.get("CD2_1"))
779
+ cdelt1 = _first_float(h.get("CDELT1"))
780
+ cdelt2 = _first_float(h.get("CDELT2"))
781
+
782
+ # 1) CD matrix
783
+ if cd11 is not None or cd21 is not None:
784
+ cd11 = cd11 or 0.0
785
+ cd21 = cd21 or 0.0
786
+ val = ((cd11**2 + cd21**2)**0.5) * 3600.0
787
+ val = _sanity(val)
788
+ if val is not None:
789
+ return val
790
+
791
+ # 2) CDELT
792
+ if cdelt1 is not None or cdelt2 is not None:
793
+ cdelt1 = cdelt1 or 0.0
794
+ cdelt2 = cdelt2 or 0.0
795
+ val = ((cdelt1**2 + cdelt2**2)**0.5) * 3600.0
796
+ val = _sanity(val)
797
+ if val is not None:
798
+ return val
799
+
800
+ # 3) Pixel size + focal length
801
+ px_um_x = _first_float(h.get("XPIXSZ"))
802
+ px_um_y = _first_float(h.get("YPIXSZ"))
803
+ focal_mm = _first_float(h.get("FOCALLEN"))
804
+ if focal_mm and (px_um_x or px_um_y):
805
+ px_um = px_um_x if (px_um_x and not px_um_y) else px_um_y if (px_um_y and not px_um_x) else None
806
+ if px_um is None:
807
+ px_um = (px_um_x + px_um_y) / 2.0
808
+ bx = _first_int(h.get("XBINNING")) or _first_int(h.get("XBIN")) or 1
809
+ by = _first_int(h.get("YBINNING")) or _first_int(h.get("YBIN")) or 1
810
+ bin_factor = (bx + by) / 2.0
811
+ px_um_eff = px_um * bin_factor
812
+ val = 206.264806 * px_um_eff / float(focal_mm)
813
+ val = _sanity(val)
814
+ if val is not None:
815
+ return val
816
+
817
+ return None
818
+
819
+
820
+ def _build_astap_seed_with_overrides(settings, header: Header | None, image: np.ndarray) -> tuple[list[str], str, float | None]:
821
+ """
822
+ Decide seed based on seed_mode:
823
+ - auto: derive from header (existing logic)
824
+ - manual: use user-provided RA/Dec/Scale
825
+ - none: return [], "blind"
826
+ Returns: (args, dbg, scale_arcsec)
827
+ """
828
+ mode = _get_seed_mode(settings)
829
+
830
+ if mode == "none":
831
+ return [], "seed disabled (blind)", None
832
+
833
+ if mode == "manual":
834
+ ra_s = _get_manual_ra(settings)
835
+ dec_s = _get_manual_dec(settings)
836
+ scl = _get_manual_scale(settings)
837
+ ra_deg = _parse_ra_input_to_deg(ra_s)
838
+ dec_deg = _parse_dec_input_to_deg(dec_s)
839
+ dbg = []
840
+ if ra_deg is None: dbg.append("RA?")
841
+ if dec_deg is None: dbg.append("Dec?")
842
+ if scl is None or not np.isfinite(scl) or scl <= 0: dbg.append("scale?")
843
+ if dbg:
844
+ return [], "manual seed invalid: " + ", ".join(dbg), None
845
+ ra_h = ra_deg / 15.0
846
+ spd = dec_deg + 90.0
847
+ args = ["-ra", f"{ra_h:.6f}", "-spd", f"{spd:.6f}", "-scale", f"{scl:.3f}"]
848
+ return args, f"manual RA={ra_h:.6f}h | SPD={spd:.6f}° | scale={scl:.3f}\"/px", float(scl)
849
+
850
+ # auto (default): from header
851
+ if isinstance(header, Header):
852
+ args, dbg = _build_astap_seed(header)
853
+ scl = None
854
+ if args:
855
+ try:
856
+ if "-scale" in args:
857
+ scl = float(args[args.index("-scale")+1])
858
+ except Exception:
859
+ scl = None
860
+ return args, "auto: " + dbg, scl
861
+
862
+ return [], "no header available for auto seed", None
863
+
864
+
865
+ def _build_astap_seed(h: Header) -> Tuple[list[str], str]:
866
+ """
867
+ Build ASTAP seed args from a header.
868
+ RA/Dec are REQUIRED. Scale is OPTIONAL and sanity-checked.
869
+ """
870
+ dbg = []
871
+ ra_deg = _parse_ra_deg(h)
872
+ dec_deg = _parse_dec_deg(h)
873
+
874
+ if ra_deg is None:
875
+ dbg.append("RA unknown")
876
+ if dec_deg is None:
877
+ dbg.append("Dec unknown")
878
+
879
+ # If we don't have RA/Dec, there's nothing useful to seed.
880
+ if ra_deg is None or dec_deg is None:
881
+ return [], " / ".join(dbg) if dbg else "RA/Dec unknown"
882
+
883
+ # Scale is now optional
884
+ scale = _estimate_scale_arcsec_from_header(h)
885
+ if scale is None:
886
+ dbg.append("scale unknown")
887
+
888
+ ra_h = ra_deg / 15.0
889
+ spd = dec_deg + 90.0
890
+
891
+ args = ["-ra", f"{ra_h:.6f}", "-spd", f"{spd:.6f}"]
892
+ if scale is not None:
893
+ args += ["-scale", f"{scale:.3f}"]
894
+
895
+ dbg_str = f"RA={ra_h:.6f} h | SPD={spd:.6f}°"
896
+ if scale is not None:
897
+ dbg_str += f" | scale={scale:.3f}\"/px"
898
+ else:
899
+ dbg_str += " | scale unknown"
900
+
901
+ return args, dbg_str
902
+
903
+
904
+
905
+ def _astrometry_login(settings, parent=None) -> str | None:
906
+ _set_status_ui(parent, "Status: Logging in to Astrometry.net…")
907
+ api_key = _get_astrometry_api_key(settings)
908
+ if not api_key:
909
+ from PyQt6.QtWidgets import QInputDialog
910
+ key, ok = QInputDialog.getText(None, "Astrometry.net API Key", "Enter your Astrometry.net API key:")
911
+ if not ok or not key:
912
+ _set_status_ui(parent, "Status: Login canceled (no API key).")
913
+ return None
914
+ _set_astrometry_api_key(settings, key)
915
+ api_key = key
916
+
917
+ base = _get_astrometry_api_url(settings)
918
+ resp = _astrometry_api_request(
919
+ "POST", base + "login",
920
+ data={'request-json': json.dumps({"apikey": api_key})},
921
+ parent=parent, stage="login"
922
+ )
923
+ if resp and resp.get("status") == "success":
924
+ _set_status_ui(parent, "Status: Login successful.")
925
+ return resp.get("session")
926
+ _set_status_ui(parent, "Status: Login failed.")
927
+ return None
928
+
929
+ def _astrometry_upload(settings, session: str, image_path: str, parent=None) -> int | None:
930
+ _set_status_ui(parent, "Status: Uploading image to Astrometry.net…")
931
+ base = _get_astrometry_api_url(settings)
932
+
933
+ try:
934
+ sz = os.path.getsize(image_path)
935
+ if sz < 1024: # fits headers alone are ~2880 bytes
936
+ print(f"[Astrometry] temp FITS too small ({sz} bytes): {image_path}")
937
+ _set_status_ui(parent, "Status: Upload failed (temp FITS empty).")
938
+ return None
939
+ except Exception:
940
+ pass
941
+
942
+ try:
943
+ with open(image_path, "rb") as f:
944
+ files = {"file": f}
945
+ data = {'request-json': json.dumps({
946
+ "publicly_visible": "y",
947
+ "allow_modifications": "d",
948
+ "session": session,
949
+ "allow_commercial_use": "d"
950
+ })}
951
+ resp = _astrometry_api_request(
952
+ "POST", base + "upload",
953
+ data=data, files=files,
954
+ timeout=(15, 180),
955
+ parent=parent, stage="upload"
956
+ )
957
+ if resp and resp.get("status") == "success":
958
+ _set_status_ui(parent, "Status: Upload complete.")
959
+ return int(resp["subid"])
960
+ except Exception as e:
961
+ print("Upload error:", e)
962
+
963
+ _set_status_ui(parent, "Status: Upload failed.")
964
+ return None
965
+
966
+
967
+
968
+ def _solve_with_local_solvefield(parent, settings, tmp_fit_path: str) -> tuple[bool, Header | str]:
969
+ solvefield = _get_solvefield_exe(settings)
970
+ if not solvefield or not os.path.exists(solvefield):
971
+ return False, "solve-field not configured."
972
+
973
+ args = [
974
+ "--overwrite",
975
+ "--no-remove-lines",
976
+ "--cpulimit", "300",
977
+ "--downsample", "2",
978
+ "--write-wcs", "wcs",
979
+ tmp_fit_path
980
+ ]
981
+ _set_status_ui(parent, "Status: Running local solve-field…")
982
+ print("Running solve-field:", solvefield, " ".join(args))
983
+ p = QProcess(parent)
984
+ p.start(solvefield, args)
985
+ if not p.waitForStarted(5000):
986
+ _set_status_ui(parent, "Status: solve-field failed to start.")
987
+ return False, f"Failed to start solve-field: {p.errorString()}"
988
+
989
+ if not _wait_process(p, 300000, parent=parent):
990
+ _set_status_ui(parent, "Status: solve-field timed out.")
991
+ return False, "solve-field timed out."
992
+
993
+ if p.exitCode() != 0:
994
+ out = bytes(p.readAllStandardOutput()).decode(errors="ignore")
995
+ err = bytes(p.readAllStandardError()).decode(errors="ignore")
996
+ _set_status_ui(parent, "Status: solve-field failed.")
997
+ print("solve-field failed.\nSTDOUT:\n", out, "\nSTDERR:\n", err)
998
+ return False, "solve-field returned non-zero exit."
999
+
1000
+ wcs_path = os.path.splitext(tmp_fit_path)[0] + ".wcs"
1001
+ new_path = os.path.splitext(tmp_fit_path)[0] + ".new"
1002
+
1003
+ if os.path.exists(wcs_path):
1004
+ d = _parse_astap_wcs_file(wcs_path)
1005
+ if d:
1006
+ d = _ensure_ctypes(_coerce_wcs_numbers(d))
1007
+ return True, Header({k: v for k, v in d.items()})
1008
+
1009
+ if os.path.exists(new_path):
1010
+ try:
1011
+ with fits.open(new_path, memmap=False) as hdul:
1012
+ h = Header()
1013
+ for k, v in dict(hdul[0].header).items():
1014
+ if k not in ("COMMENT","HISTORY","END"):
1015
+ h[k] = v
1016
+ return True, h
1017
+ except Exception as e:
1018
+ print("Failed reading .new FITS:", e)
1019
+
1020
+ return False, "solve-field produced no WCS."
1021
+
1022
+
1023
+ def _astrometry_poll_job(settings, subid: int, *, max_wait_s=900, parent=None) -> int | None:
1024
+ _set_status_ui(parent, "Status: Waiting for job assignment…")
1025
+ base = _get_astrometry_api_url(settings)
1026
+ t0 = time.time()
1027
+ while time.time() - t0 < max_wait_s:
1028
+ resp = _astrometry_api_request("GET", base + f"submissions/{subid}",
1029
+ parent=parent, stage="poll job")
1030
+ if resp:
1031
+ jobs = resp.get("jobs", [])
1032
+ if jobs and jobs[0] is not None:
1033
+ _set_status_ui(parent, f"Status: Job assigned (ID {jobs[0]}).")
1034
+ try: return int(jobs[0])
1035
+ except Exception as e:
1036
+ import logging
1037
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1038
+ _sleep_ui(1000)
1039
+ return None
1040
+
1041
+ def _astrometry_poll_calib(settings, job_id: int, *, max_wait_s=900, parent=None) -> dict | None:
1042
+ _set_status_ui(parent, "Status: Waiting for solution…")
1043
+ base = _get_astrometry_api_url(settings)
1044
+ t0 = time.time()
1045
+ while time.time() - t0 < max_wait_s:
1046
+ resp = _astrometry_api_request("GET", base + f"jobs/{job_id}/calibration/",
1047
+ parent=parent, stage="poll calib")
1048
+ if resp and all(k in resp for k in ("ra","dec","pixscale")):
1049
+ _set_status_ui(parent, "Status: Solution received.")
1050
+ return resp
1051
+ _sleep_ui(1500)
1052
+ return None
1053
+
1054
+ # ---- ASTAP seed controls ----
1055
+ # modes for radius: "auto" -> -r 0, "value" -> -r <user>, default "auto"
1056
+ def _get_astap_radius_mode(settings) -> str:
1057
+ return (settings.value("astap/seed_radius_mode", "auto", type=str) or "auto").lower()
1058
+
1059
+ def _get_astap_radius_value(settings) -> float:
1060
+ try:
1061
+ return float(settings.value("astap/seed_radius_value", 5.0, type=float))
1062
+ except Exception:
1063
+ return 5.0
1064
+
1065
+ # modes for fov: "auto" -> -fov 0, "compute" -> use computed FOV, "value" -> user number; default "compute"
1066
+ def _get_astap_fov_mode(settings) -> str:
1067
+ return (settings.value("astap/seed_fov_mode", "compute", type=str) or "compute").lower()
1068
+
1069
+ def _get_astap_fov_value(settings) -> float:
1070
+ try:
1071
+ return float(settings.value("astap/seed_fov_value", 0.0, type=float))
1072
+ except Exception:
1073
+ return 0.0
1074
+
1075
+
1076
+ def _read_header_from_fits(path: str) -> Dict[str, Any]:
1077
+ with fits.open(path, memmap=False) as hdul:
1078
+ d = dict(hdul[0].header)
1079
+ d.pop("COMMENT", None); d.pop("HISTORY", None); d.pop("END", None)
1080
+ return d
1081
+
1082
+
1083
+ def _header_from_text_block(s: str) -> Header:
1084
+ """Parse ASTAP .wcs or flattened blocks into a proper Header."""
1085
+ h = Header()
1086
+ if not s: return h
1087
+ lines = s.splitlines()
1088
+ if len(lines) <= 1:
1089
+ # single blob: split on KEY=
1090
+ lines = re.split(r"(?=(?:^|\s{2,})([A-Za-z][A-Za-z0-9_]+)\s*=)", s)
1091
+ lines = ["".join(lines[i:i+2]).strip() for i in range(1, len(lines), 2)]
1092
+ card_re = re.compile(r"^\s*([A-Za-z][A-Za-z0-9_]+)\s*=\s*(.*)$")
1093
+ num_re = re.compile(r"^[+-]?\d*\.?\d+(?:[eE][+-]?\d+)?$")
1094
+ for raw in lines:
1095
+ raw = raw.strip()
1096
+ if not raw or raw.upper().startswith(("COMMENT","HISTORY","END")):
1097
+ continue
1098
+ m = card_re.match(raw)
1099
+ if not m: continue
1100
+ key, rest = m.group(1).upper(), m.group(2).strip()
1101
+ if " /" in rest:
1102
+ val_str = rest.split(" /", 1)[0].strip()
1103
+ else:
1104
+ val_str = rest
1105
+ if (len(val_str) >= 2) and ((val_str[0] == "'" and val_str[-1] == "'") or (val_str[0] == '"' and val_str[-1] == '"')):
1106
+ val = val_str[1:-1].strip()
1107
+ else:
1108
+ try:
1109
+ if num_re.match(val_str):
1110
+ val = float(val_str)
1111
+ if re.match(r"^[+-]?\d+$", val_str): val = int(val)
1112
+ else:
1113
+ val = val_str
1114
+ except Exception:
1115
+ val = val_str
1116
+ try: h[key] = val
1117
+ except Exception as e:
1118
+ import logging
1119
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1120
+ if "A_ORDER" in h and "B_ORDER" not in h:
1121
+ h["B_ORDER"] = int(h["A_ORDER"])
1122
+ if "B_ORDER" in h and "A_ORDER" not in h:
1123
+ h["A_ORDER"] = int(h["B_ORDER"])
1124
+ return h
1125
+
1126
+ def _coerce_wcs_numbers(d: dict[str, Any]) -> dict[str, Any]:
1127
+ """
1128
+ Convert values for common WCS/SIP keys to int/float where appropriate.
1129
+ Mirrors SASv2 logic.
1130
+ """
1131
+ numeric_keys = {
1132
+ "CRPIX1", "CRPIX2", "CRVAL1", "CRVAL2", "CDELT1", "CDELT2",
1133
+ "CD1_1", "CD1_2", "CD2_1", "CD2_2", "CROTA1", "CROTA2",
1134
+ "EQUINOX", "WCSAXES", "A_ORDER", "B_ORDER", "AP_ORDER", "BP_ORDER",
1135
+ }
1136
+
1137
+ out = {}
1138
+ for k, v in d.items():
1139
+ key = k.upper()
1140
+ try:
1141
+ if key in numeric_keys or re.match(r"^(A|B|AP|BP)_\d+_\d+$", key):
1142
+ if isinstance(v, str):
1143
+ val = float(v.strip())
1144
+ if val.is_integer(): val = int(val)
1145
+ else:
1146
+ val = v
1147
+ out[key] = val
1148
+ else:
1149
+ out[key] = v
1150
+ except Exception:
1151
+ out[key] = v
1152
+ return out
1153
+
1154
+
1155
+ def _ensure_ctypes(d: dict[str, Any]) -> dict[str, Any]:
1156
+ """
1157
+ Ensure CTYPE1/2 exist and are proper strings. Fallback to TAN if missing.
1158
+ """
1159
+ if "CTYPE1" not in d:
1160
+ d["CTYPE1"] = "RA---TAN"
1161
+ if "CTYPE2" not in d:
1162
+ d["CTYPE2"] = "DEC--TAN"
1163
+ d["CTYPE1"] = str(d["CTYPE1"]).strip()
1164
+ d["CTYPE2"] = str(d["CTYPE2"]).strip()
1165
+ return d
1166
+
1167
+ def _merge_wcs_into_base_header(base_header: Header | None, wcs_header: Header | None) -> Header:
1168
+ """
1169
+ Merge a WCS/SIP solution into a base acquisition header.
1170
+
1171
+ - base_header: original FITS header with OBJECT, EXPTIME, GAIN, etc.
1172
+ - wcs_header: header containing CRPIX/CRVAL/CD/SIP/etc. from ASTAP or Astrometry.
1173
+
1174
+ Non-WCS cards in base_header are preserved.
1175
+ WCS/SIP/PLTSOLVD/etc. from wcs_header override any existing ones.
1176
+ """
1177
+ if not isinstance(base_header, Header):
1178
+ base_header = Header()
1179
+ # Always strip our internal meta keys from the acquisition header
1180
+ base_header = _strip_nonfits_meta_keys_from_header(base_header)
1181
+
1182
+ if not isinstance(wcs_header, Header):
1183
+ # nothing special to merge; just normalize the base and return it.
1184
+ d0 = _ensure_ctypes(_coerce_wcs_numbers(dict(base_header)))
1185
+ out = Header()
1186
+ for k, v in d0.items():
1187
+ try:
1188
+ out[k] = v
1189
+ except Exception:
1190
+ pass
1191
+ return out
1192
+
1193
+ # Start from a copy of the acquisition header (drop COMMENT/HISTORY from it)
1194
+ base = base_header.copy()
1195
+
1196
+
1197
+ # Start from a copy of the acquisition header (drop COMMENT/HISTORY from it)
1198
+ base = base_header.copy()
1199
+ for k in ("COMMENT", "HISTORY", "END"):
1200
+ if k in base:
1201
+ base.remove(k)
1202
+
1203
+ merged = dict(base)
1204
+
1205
+ # Only import *WCS-ish* keys from the solver, not things like BITPIX/NAXIS.
1206
+ wcs_prefixes = (
1207
+ "CRPIX", "CRVAL", "CDELT", "CD1_", "CD2_", "PC",
1208
+ "CTYPE", "CUNIT", "PV1_", "PV2_", "A_", "B_", "AP_", "BP_"
1209
+ )
1210
+ wcs_extras = {
1211
+ "WCSAXES", "LATPOLE", "LONPOLE", "EQUINOX",
1212
+ "PLTSOLVD", "WARNING", "RADESYS", "RADECSYS", "RADECSYS"
1213
+ }
1214
+
1215
+ for key, val in wcs_header.items():
1216
+ ku = key.upper()
1217
+ if ku.startswith(wcs_prefixes) or ku in wcs_extras:
1218
+ merged[ku] = val
1219
+
1220
+ # Coerce numeric types and ensure CTYPEs.
1221
+ merged = _ensure_ctypes(_coerce_wcs_numbers(merged))
1222
+
1223
+ # Ensure TAN-SIP if SIP terms exist.
1224
+ try:
1225
+ sip_present = any(re.match(r"^(A|B|AP|BP)_\d+_\d+$", k) for k in merged.keys())
1226
+ if sip_present:
1227
+ c1 = str(merged.get("CTYPE1", "RA---TAN"))
1228
+ c2 = str(merged.get("CTYPE2", "DEC--TAN"))
1229
+ if not c1.endswith("-SIP"):
1230
+ merged["CTYPE1"] = "RA---TAN-SIP"
1231
+ if not c2.endswith("-SIP"):
1232
+ merged["CTYPE2"] = "DEC--TAN-SIP"
1233
+ except Exception:
1234
+ pass
1235
+
1236
+ # CROTA from CD if missing.
1237
+ try:
1238
+ if ("CROTA1" not in merged or "CROTA2" not in merged) and \
1239
+ ("CD1_1" in merged and "CD1_2" in merged):
1240
+ rot = math.degrees(math.atan2(float(merged["CD1_2"]), float(merged["CD1_1"])))
1241
+ merged["CROTA1"] = rot
1242
+ merged["CROTA2"] = rot
1243
+ except Exception:
1244
+ pass
1245
+
1246
+ out = Header()
1247
+ for k, v in merged.items():
1248
+ try:
1249
+ out[k] = v
1250
+ except Exception:
1251
+ # Skip weird/invalid keys silently
1252
+ pass
1253
+ return out
1254
+
1255
+
1256
+ def _build_header_from_astap_outputs(
1257
+ tmp_fits: str,
1258
+ sidecar_wcs: Optional[str],
1259
+ base_header: Header | None
1260
+ ) -> Header:
1261
+ """
1262
+ Build final header as: base_header (acquisition) + WCS/SIP from .wcs.
1263
+ """
1264
+ _debug_dump_header("ASTAP: BASE_HEADER ARG INTO _build_header_from_astap_outputs", base_header)
1265
+ """
1266
+ Build final header as: base_header (acquisition) + WCS/SIP from .wcs.
1267
+
1268
+ tmp_fits is only used as a last-resort source if base_header is None.
1269
+ """
1270
+ # 1) Determine base header (acquisition)
1271
+ if isinstance(base_header, Header):
1272
+ base_hdr = base_header
1273
+ else:
1274
+ # Fallback: read whatever ASTAP wrote into the temp FITS.
1275
+ base_dict: Dict[str, Any] = {}
1276
+ try:
1277
+ with fits.open(tmp_fits, memmap=False) as hdul:
1278
+ base_dict = dict(hdul[0].header)
1279
+ for k in ("COMMENT", "HISTORY", "END"):
1280
+ base_dict.pop(k, None)
1281
+ except Exception as e:
1282
+ print("Failed reading temp FITS header:", e)
1283
+ base_hdr = Header()
1284
+ for k, v in base_dict.items():
1285
+ try:
1286
+ base_hdr[k] = v
1287
+ except Exception:
1288
+ pass
1289
+ _debug_dump_header("ASTAP: BASE_HDR (acquisition header after fallback)", base_hdr)
1290
+ # 2) Load WCS from sidecar
1291
+ wcs_hdr = Header()
1292
+ if sidecar_wcs and os.path.exists(sidecar_wcs):
1293
+ try:
1294
+ wcs_dict = _parse_astap_wcs_file(sidecar_wcs)
1295
+ for k, v in wcs_dict.items():
1296
+ if k not in ("COMMENT", "HISTORY", "END"):
1297
+ try:
1298
+ wcs_hdr[k] = v
1299
+ except Exception:
1300
+ pass
1301
+ except Exception as e:
1302
+ print("Error parsing .wcs file:", e)
1303
+ _debug_dump_header("ASTAP: WCS_HDR FROM SIDECAR .WCS", wcs_hdr)
1304
+ # 3) Merge WCS into base acquisition header (base wins for non-WCS keys)
1305
+ final_hdr = _merge_wcs_into_base_header(base_hdr, wcs_hdr)
1306
+
1307
+ _debug_dump_header("ASTAP: FINAL MERGED HEADER (base_hdr + wcs_hdr)", final_hdr)
1308
+
1309
+
1310
+ return final_hdr
1311
+
1312
+
1313
+ def _write_temp_fit_via_save_image(gray2d: np.ndarray, _header: Header | None) -> tuple[str, str]:
1314
+ """
1315
+ Write a 2-D mono float32 FITS using legacy.save_image(), return (fit_path, sidecar_wcs_path).
1316
+
1317
+ NOTE: We intentionally ignore the incoming header's axis cards and
1318
+ build a clean 2-axis header to avoid 'NAXISj out of range' errors.
1319
+ """
1320
+ # ensure 2-D float32 in [0,1]
1321
+ if gray2d.ndim != 2:
1322
+ raise ValueError("Expected a 2-D grayscale array for ASTAP temp FITS.")
1323
+ g = np.clip(gray2d.astype(np.float32), 0.0, 1.0)
1324
+
1325
+ H, W = int(g.shape[0]), int(g.shape[1])
1326
+
1327
+ # Build a *fresh* 2-axis header (no NAXIS3, no old WCS)
1328
+ clean_header = Header()
1329
+ clean_header["SIMPLE"] = True
1330
+ clean_header["BITPIX"] = -32
1331
+ clean_header["NAXIS"] = 2
1332
+ clean_header["NAXIS1"] = W
1333
+ clean_header["NAXIS2"] = H
1334
+ clean_header["BZERO"] = 0.0
1335
+ clean_header["BSCALE"] = 1.0
1336
+ clean_header.add_comment("Temp FITS written for ASTAP solve (mono 2-D).")
1337
+
1338
+ # Write using legacy.save_image (forces a valid 2-axis primary HDU)
1339
+ tmp = tempfile.NamedTemporaryFile(suffix=".fit", delete=False)
1340
+ tmp_path = tmp.name
1341
+ tmp.close()
1342
+
1343
+ save_image(
1344
+ img_array=g,
1345
+ filename=tmp_path,
1346
+ original_format="fit", # (our stack expects 'fit')
1347
+ bit_depth="32-bit floating point",
1348
+ original_header=clean_header,
1349
+ is_mono=True # <-- important: keep it 2-D/mono
1350
+ )
1351
+
1352
+ # Resolve the actual path in case save_image normalized the extension
1353
+ base, _ = os.path.splitext(tmp_path)
1354
+ candidates = [tmp_path, base + ".fit", base + ".fits", base + ".FIT", base + ".FITS"]
1355
+ fit_path = next((p for p in candidates if os.path.exists(p)), tmp_path)
1356
+
1357
+ print(f"Saved FITS image to: {fit_path}")
1358
+ return fit_path, os.path.splitext(fit_path)[0] + ".wcs"
1359
+
1360
+ def _solve_numpy_with_astrometry(
1361
+ parent,
1362
+ settings,
1363
+ image: np.ndarray,
1364
+ base_header: Header | None
1365
+ ) -> tuple[bool, Header | str]:
1366
+ """
1367
+ Try local solve-field first; if unavailable/failed, try astrometry.net web API.
1368
+
1369
+ WEB MODE:
1370
+ - keep ORIGINAL dimensions (no downsample)
1371
+ - stretch to non-linear for star detectability
1372
+ - quantize to 16-bit unsigned FITS to reduce upload size
1373
+ - prefer solved WCS file from astrometry.net (includes SIP)
1374
+ """
1375
+ import os
1376
+ import numpy as np
1377
+ from astropy.io.fits import Header
1378
+
1379
+ # Build full-res mono in [0,1], but NON-LINEAR (stretched) for detectability
1380
+ norm_full = _normalize_for_astap(image) # float32 [0,1], mono/color
1381
+ gray_full = _to_gray2d_unit(norm_full) # 2D float32 [0,1]
1382
+ Hfull, Wfull = int(gray_full.shape[0]), int(gray_full.shape[1])
1383
+
1384
+ # Always write a full-res temp for LOCAL solve-field (float32)
1385
+ tmp_fit_full, _unused_sidecar = _write_temp_fit_via_save_image(gray_full, None)
1386
+
1387
+ try:
1388
+ # 1) local solve-field path (full-res float FITS)
1389
+ ok, res = _solve_with_local_solvefield(parent, settings, tmp_fit_full)
1390
+ if ok:
1391
+ hdr = res if isinstance(res, Header) else None
1392
+ if hdr is not None:
1393
+ d = _ensure_ctypes(_coerce_wcs_numbers(dict(hdr)))
1394
+ if any(re.match(r"^(A|B|AP|BP)_\d+_\d+$", k) for k in d.keys()):
1395
+ if not str(d.get("CTYPE1","RA---TAN")).endswith("-SIP"):
1396
+ d["CTYPE1"] = "RA---TAN-SIP"
1397
+ if not str(d.get("CTYPE2","DEC--TAN")).endswith("-SIP"):
1398
+ d["CTYPE2"] = "DEC--TAN-SIP"
1399
+ hh = Header()
1400
+ for k, v in d.items():
1401
+ try: hh[k] = v
1402
+ except Exception as e:
1403
+ import logging
1404
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1405
+ return True, hh
1406
+ return False, "solve-field returned no header."
1407
+
1408
+ # 2) web API fallback (full-res, 16-bit upload)
1409
+ if requests is None:
1410
+ return False, "requests not available for astrometry.net API."
1411
+
1412
+ _set_status_ui(parent, "Status: Preparing full-res 16-bit FITS for web solve…")
1413
+
1414
+ tmp_fit_web = _write_temp_fit_web_16bit(gray_full)
1415
+
1416
+ # Verify web temp file isn't empty
1417
+ try:
1418
+ sz = os.path.getsize(tmp_fit_web)
1419
+ if sz < 3000:
1420
+ return False, f"Temp FITS for web upload is empty/tiny ({sz} bytes)."
1421
+ except Exception:
1422
+ pass
1423
+
1424
+ session = _astrometry_login(settings, parent=parent)
1425
+ if not session:
1426
+ return False, "Astrometry.net login failed."
1427
+
1428
+ subid = _astrometry_upload(settings, session, tmp_fit_web, parent=parent)
1429
+ if not subid:
1430
+ return False, "Astrometry.net upload failed."
1431
+
1432
+ job_id = _astrometry_poll_job(settings, subid, parent=parent)
1433
+ if not job_id:
1434
+ return False, "Astrometry.net job ID not received in time."
1435
+
1436
+ # Prefer full WCS file (includes SIP)
1437
+ hdr_wcs = _astrometry_download_wcs_file(settings, job_id, parent=parent)
1438
+
1439
+ if hdr_wcs is None:
1440
+ # fallback to calibration (no SIP)
1441
+ calib = _astrometry_poll_calib(settings, job_id, parent=parent)
1442
+ if not calib:
1443
+ return False, "Astrometry.net calibration not received in time."
1444
+
1445
+ _set_status_ui(parent, "Status: Building WCS header from calibration…")
1446
+ hdr_wcs = _wcs_header_from_astrometry_calib(calib, (Hfull, Wfull))
1447
+
1448
+ # Coerce & ensure TAN-SIP if SIP terms exist
1449
+ d = _ensure_ctypes(_coerce_wcs_numbers(dict(hdr_wcs)))
1450
+ if any(re.match(r"^(A|B|AP|BP)_\d+_\d+$", k) for k in d.keys()):
1451
+ if not str(d.get("CTYPE1","RA---TAN")).endswith("-SIP"):
1452
+ d["CTYPE1"] = "RA---TAN-SIP"
1453
+ if not str(d.get("CTYPE2","DEC--TAN")).endswith("-SIP"):
1454
+ d["CTYPE2"] = "DEC--TAN-SIP"
1455
+
1456
+ # Build a WCS-only Header from d
1457
+ wcs_hdr = Header()
1458
+ for k, v in d.items():
1459
+ try:
1460
+ wcs_hdr[k] = v
1461
+ except Exception:
1462
+ pass
1463
+
1464
+ # Merge with acquisition header (base_header)
1465
+ merged = _merge_wcs_into_base_header(base_header, wcs_hdr)
1466
+
1467
+ # clean temp web file ...
1468
+ try:
1469
+ if os.path.exists(tmp_fit_web):
1470
+ os.remove(tmp_fit_web)
1471
+ except Exception:
1472
+ pass
1473
+
1474
+ return True, merged
1475
+
1476
+ finally:
1477
+ # clean temp + solve-field byproducts next to tmp_fit_full
1478
+ try:
1479
+ base = os.path.splitext(tmp_fit_full)[0]
1480
+ for ext in (".fit",".fits",".wcs",".axy",".corr",".rdls",".solved",".new",".match",".ngc",".png",".ppm",".xyls"):
1481
+ p = base + ext
1482
+ if os.path.exists(p):
1483
+ os.remove(p)
1484
+ except Exception:
1485
+ pass
1486
+
1487
+
1488
+ def _solve_numpy_with_fallback(parent, settings, image: np.ndarray, seed_header: Header | None) -> tuple[bool, Header | str]:
1489
+ # Try ASTAP first
1490
+ _set_status_ui(parent, "Status: Solving with ASTAP…")
1491
+ ok, res = _solve_numpy_with_astap(parent, settings, image, seed_header)
1492
+ if ok:
1493
+ _set_status_ui(parent, "Status: Solved with ASTAP.")
1494
+ return True, res
1495
+
1496
+ # ASTAP failed → tell the user and fall back
1497
+ err_msg = str(res) if res is not None else "unknown error"
1498
+ print("ASTAP failed:", err_msg)
1499
+ _set_status_ui(parent, f"Status: ASTAP failed ({err_msg}). Falling back to Astrometry.net…")
1500
+ QApplication.processEvents()
1501
+
1502
+ # Fallback: astrometry.net (local solve-field first, then web API inside)
1503
+ ok2, res2 = _solve_numpy_with_astrometry(parent, settings, image, seed_header)
1504
+ if ok2:
1505
+ _set_status_ui(parent, "Status: Solved via Astrometry.net.")
1506
+ else:
1507
+ _set_status_ui(parent, f"Status: Astrometry.net failed ({res2}).")
1508
+
1509
+ return ok2, res2
1510
+
1511
+
1512
+ def _save_temp_fits_via_save_image(norm_img: np.ndarray, clean_header: Header, is_mono: bool) -> str:
1513
+ """
1514
+ Legacy helper used elsewhere. Make sure header axes match the data we write.
1515
+ If is_mono=True we force a 2-D primary HDU; otherwise we allow 3-D (H,W,C).
1516
+ """
1517
+ hdr = Header()
1518
+ # sanitize header/axes
1519
+ if is_mono:
1520
+ # force 2-axis
1521
+ if norm_img.ndim != 2:
1522
+ raise ValueError("Expected 2-D array for mono temp FITS.")
1523
+ H, W = int(norm_img.shape[0]), int(norm_img.shape[1])
1524
+ hdr["SIMPLE"] = True
1525
+ hdr["BITPIX"] = -32
1526
+ hdr["NAXIS"] = 2
1527
+ hdr["NAXIS1"] = W
1528
+ hdr["NAXIS2"] = H
1529
+ else:
1530
+ # allow color (H, W, C)
1531
+ if norm_img.ndim != 3 or norm_img.shape[2] < 3:
1532
+ raise ValueError("Expected 3-D array (H,W,C) for color temp FITS.")
1533
+ H, W, C = int(norm_img.shape[0]), int(norm_img.shape[1]), int(norm_img.shape[2])
1534
+ hdr["SIMPLE"] = True
1535
+ hdr["BITPIX"] = -32
1536
+ hdr["NAXIS"] = 3
1537
+ hdr["NAXIS1"] = W
1538
+ hdr["NAXIS2"] = H
1539
+ hdr["NAXIS3"] = C
1540
+
1541
+ hdr["BZERO"] = 0.0
1542
+ hdr["BSCALE"] = 1.0
1543
+ hdr.add_comment("Temp FITS written for ASTAP solve.")
1544
+
1545
+ # write
1546
+ tmp = tempfile.NamedTemporaryFile(suffix=".fit", delete=False)
1547
+ tmp_path = tmp.name
1548
+ tmp.close()
1549
+
1550
+ save_image(
1551
+ img_array=np.clip(norm_img.astype(np.float32), 0.0, 1.0),
1552
+ filename=tmp_path,
1553
+ original_format="fit",
1554
+ bit_depth="32-bit floating point",
1555
+ original_header=hdr,
1556
+ is_mono=is_mono
1557
+ )
1558
+
1559
+ return tmp_path
1560
+
1561
+
1562
+
1563
+ def _active_doc_from_parent(parent) -> object | None:
1564
+ """Try your helpers to get the active document."""
1565
+ if hasattr(parent, "_active_doc"):
1566
+ try:
1567
+ return parent._active_doc()
1568
+ except Exception:
1569
+ pass
1570
+ sw = getattr(parent, "mdi", None)
1571
+ if sw and hasattr(sw, "activeSubWindow"):
1572
+ asw = sw.activeSubWindow()
1573
+ if asw:
1574
+ w = asw.widget()
1575
+ return getattr(w, "document", None)
1576
+ return None
1577
+
1578
+ def _to_gray(arr: np.ndarray) -> np.ndarray:
1579
+ """Always produce a 2-D grayscale float32 in [0,1]."""
1580
+ a = np.asarray(arr)
1581
+ # normalize to 0..1 first
1582
+ if a.dtype.kind in "ui":
1583
+ info = np.iinfo(a.dtype)
1584
+ a = a.astype(np.float32) / max(float(info.max), 1.0)
1585
+ else:
1586
+ a = np.clip(a.astype(np.float32), 0.0, 1.0)
1587
+
1588
+ if a.ndim == 2:
1589
+ return a
1590
+ if a.ndim == 3:
1591
+ if a.shape[2] >= 3:
1592
+ return (0.2126*a[...,0] + 0.7152*a[...,1] + 0.0722*a[...,2]).astype(np.float32)
1593
+ return a[...,0].astype(np.float32)
1594
+ # anything else, just flatten safely
1595
+ return a.reshape(a.shape[0], -1).astype(np.float32)
1596
+
1597
+ def _to_gray2d_unit(arr: np.ndarray) -> np.ndarray:
1598
+ """
1599
+ Return a 2-D float32 array in [0,1].
1600
+ """
1601
+ a = np.asarray(arr)
1602
+ if a.dtype.kind in "ui":
1603
+ info = np.iinfo(a.dtype)
1604
+ a = a.astype(np.float32) / max(float(info.max), 1.0)
1605
+ else:
1606
+ a = np.clip(a.astype(np.float32), 0.0, 1.0)
1607
+
1608
+ if a.ndim == 2:
1609
+ return a
1610
+ if a.ndim == 3:
1611
+ # perceptual luminance → 2-D
1612
+ if a.shape[2] >= 3:
1613
+ return (0.2126*a[...,0] + 0.7152*a[...,1] + 0.0722*a[...,2]).astype(np.float32)
1614
+ return a[...,0].astype(np.float32)
1615
+ # last resort: collapse to (H, W)
1616
+ return a.reshape(a.shape[0], -1).astype(np.float32)
1617
+
1618
+
1619
+ # ---------------------------------------------------------------------
1620
+ # Core ASTAP solving for a numpy image + seed header
1621
+ # ---------------------------------------------------------------------
1622
+
1623
+ def _solve_numpy_with_astap(parent, settings, image: np.ndarray, seed_header: Header | None) -> Tuple[bool, Header | str]:
1624
+ """
1625
+ Normalize → write temp mono FITS → run ASTAP → return the EXACT FITS header ASTAP wrote.
1626
+ """
1627
+ astap_exe = _get_astap_exe(settings)
1628
+ if not astap_exe or not os.path.exists(astap_exe):
1629
+ return False, "ASTAP path is not set (see Preferences) or file not found."
1630
+
1631
+ # normalize and force 2-D luminance in [0,1]
1632
+ norm = _normalize_for_astap(image)
1633
+ #gray = _to_gray2d_unit(image)
1634
+ gray = _to_gray2d_unit(norm)
1635
+
1636
+ # build a clean temp header (strip old WCS but KEEP acquisition keys)
1637
+ if isinstance(seed_header, Header):
1638
+ clean_for_temp = _strip_wcs_keys(seed_header)
1639
+ base_for_merge = clean_for_temp # acquisition info lives here
1640
+ _debug_dump_header("ASTAP: CLEAN_FOR_TEMP (seed_header with WCS stripped)", clean_for_temp)
1641
+ _debug_dump_header("ASTAP: BASE_FOR_MERGE (acquisition header we expect to preserve)", base_for_merge)
1642
+ else:
1643
+ clean_for_temp = _minimal_header_for_gray2d(*gray.shape)
1644
+ base_for_merge = None
1645
+ _debug_dump_header("ASTAP: CLEAN_FOR_TEMP (minimal header, no seed)", clean_for_temp)
1646
+
1647
+ tmp_fit, sidecar_wcs = _write_temp_fit_via_save_image(gray, clean_for_temp)
1648
+ print(f"[ASTAP] Temp FITS: {tmp_fit}, sidecar WCS: {sidecar_wcs}")
1649
+
1650
+ # seed if possible; otherwise blind
1651
+ seed_args: list[str] = []
1652
+ scale_arcsec = None
1653
+ try:
1654
+ seed_args, dbg, scale_arcsec = _build_astap_seed_with_overrides(settings, seed_header, gray)
1655
+ if seed_args:
1656
+ # radius & fov modes (already implemented)
1657
+ radius_mode = _get_astap_radius_mode(settings) # "auto" or "value"
1658
+ fov_mode = _get_astap_fov_mode(settings) # "auto", "compute", "value"
1659
+
1660
+ # radius
1661
+ if radius_mode == "auto":
1662
+ r_arg = ["-r", "0"] # ASTAP auto
1663
+ r_dbg = "r=auto(0)"
1664
+ else:
1665
+ r_val = max(0.0, float(_get_astap_radius_value(settings)))
1666
+ r_arg = ["-r", f"{r_val:.3f}"]
1667
+ r_dbg = f"r={r_val:.3f}°"
1668
+
1669
+ # fov
1670
+ if fov_mode == "auto":
1671
+ fov_arg = ["-fov", "0"]
1672
+ f_dbg = "fov=auto(0)"
1673
+ elif fov_mode == "value":
1674
+ fv = max(0.0, float(_get_astap_fov_value(settings)))
1675
+ fov_arg = ["-fov", f"{fv:.4f}"]
1676
+ f_dbg = f"fov={fv:.4f}°"
1677
+ else: # "compute"
1678
+ fv = _compute_fov_deg(gray, scale_arcsec) or 0.0
1679
+ fov_arg = ["-fov", f"{fv:.4f}"]
1680
+ f_dbg = f"fov(computed)={fv:.4f}°"
1681
+
1682
+ seed_args = seed_args + r_arg + fov_arg
1683
+ print("ASTAP seed:", dbg, "|", r_dbg, "|", f_dbg)
1684
+ else:
1685
+ print("Seed disabled/invalid → blind:", dbg)
1686
+ except Exception as e:
1687
+ print("Seed build error:", e)
1688
+
1689
+ if not seed_args:
1690
+ seed_args = ["-r", "179", "-fov", "0", "-z", "0"]
1691
+ print("ASTAP BLIND: using arguments:", " ".join(seed_args))
1692
+
1693
+ args = ["-f", tmp_fit] + seed_args + ["-wcs", "-sip"]
1694
+ print("Running ASTAP with:", " ".join([astap_exe] + args))
1695
+
1696
+ proc = QProcess(parent)
1697
+ proc.start(astap_exe, args)
1698
+ if not proc.waitForStarted(5000):
1699
+ _set_status_ui(parent, "Status: ASTAP failed to start.")
1700
+ return False, f"Failed to start ASTAP: {proc.errorString()}"
1701
+
1702
+ _set_status_ui(parent, "Status: ASTAP solving…")
1703
+ if not _wait_process(proc, 300000, parent=parent):
1704
+ _set_status_ui(parent, "Status: ASTAP timed out.")
1705
+ return False, "ASTAP timed out."
1706
+
1707
+ if proc.exitCode() != 0:
1708
+ out = bytes(proc.readAllStandardOutput()).decode(errors="ignore")
1709
+ err = bytes(proc.readAllStandardError()).decode(errors="ignore")
1710
+ print("ASTAP failed.\nSTDOUT:\n", out, "\nSTDERR:\n", err)
1711
+ try: os.remove(tmp_fit)
1712
+ except Exception as e:
1713
+ import logging
1714
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1715
+ try:
1716
+ if os.path.exists(sidecar_wcs): os.remove(sidecar_wcs)
1717
+ except Exception as e:
1718
+ import logging
1719
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1720
+ return False, "ASTAP returned a non-zero exit code."
1721
+
1722
+ # >>> THIS is the key change: read the header **directly** from the FITS ASTAP wrote
1723
+ try:
1724
+ # Use acquisition header as base + WCS from .wcs
1725
+ hdr = _build_header_from_astap_outputs(tmp_fit, sidecar_wcs, base_for_merge)
1726
+ finally:
1727
+ try: os.remove(tmp_fit)
1728
+ except Exception as e:
1729
+ import logging
1730
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1731
+ try:
1732
+ if os.path.exists(sidecar_wcs): os.remove(sidecar_wcs)
1733
+ except Exception as e:
1734
+ import logging
1735
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1736
+
1737
+ # return a REAL fits.Header (no blobs/strings/dicts)
1738
+ return True, hdr
1739
+
1740
+
1741
+
1742
+ # ---------------------------------------------------------------------
1743
+ # Solve active doc in-place
1744
+ # ---------------------------------------------------------------------
1745
+
1746
+ # --- Debug helpers ---------------------------------------------------
1747
+ DEBUG_PLATESOLVE_HEADERS = False # set False to silence all header dumps
1748
+
1749
+
1750
+ def _debug_dump_header(label: str, hdr: Header | None):
1751
+ """Print a full FITS Header to the console for debugging."""
1752
+ if not DEBUG_PLATESOLVE_HEADERS:
1753
+ return
1754
+
1755
+ print(f"\n===== {label} =====")
1756
+ if hdr is None:
1757
+ print(" (None)")
1758
+ elif isinstance(hdr, Header):
1759
+ print(f" (#cards = {len(hdr)})")
1760
+ for k, v in hdr.items():
1761
+ print(f" {k:8s} = {v!r}")
1762
+ else:
1763
+ print(f" (not a Header: {type(hdr)!r})")
1764
+ print("========================================\n")
1765
+
1766
+ def _debug_dump_meta(label: str, meta: dict):
1767
+ if not DEBUG_PLATESOLVE_HEADERS:
1768
+ return
1769
+ print(f"\n===== {label} (meta keys) =====")
1770
+ for k in sorted(meta.keys()):
1771
+ v = meta[k]
1772
+ print(f" {k}: {type(v).__name__}")
1773
+ print("================================\n")
1774
+
1775
+
1776
+
1777
+ def plate_solve_doc_inplace(parent, doc, settings) -> Tuple[bool, Header | str]:
1778
+ img = getattr(doc, "image", None)
1779
+ if img is None:
1780
+ return False, "Active document has no image data."
1781
+
1782
+ # Make sure metadata is a dict we can mutate
1783
+ meta = getattr(doc, "metadata", {}) or {}
1784
+ if not isinstance(meta, dict):
1785
+ try:
1786
+ meta = dict(meta)
1787
+ except Exception:
1788
+ meta = {}
1789
+
1790
+ _debug_dump_meta("META BEFORE SOLVE", meta)
1791
+ _debug_dump_header("META['original_header'] BEFORE SOLVE", meta.get("original_header"))
1792
+
1793
+ seed_h = _seed_header_from_meta(meta)
1794
+ _debug_dump_header("SEED HEADER FROM META (seed_h)", seed_h)
1795
+
1796
+ # Keep a copy of acquisition header (no WCS) for merge
1797
+ # Prefer the true acquisition header if we have it, otherwise fall back.
1798
+ raw_acq = meta.get("original_header") or meta.get("fits_header")
1799
+
1800
+ acq_base: Header | None = None
1801
+ if isinstance(raw_acq, Header):
1802
+ # Use the original acquisition header (OBJECT, EXPTIME, GAIN, etc.)
1803
+ acq_base = _strip_wcs_keys(raw_acq.copy())
1804
+ _debug_dump_header("ACQ_BASE (original/fits header with WCS stripped)", acq_base)
1805
+ elif isinstance(seed_h, Header):
1806
+ # Fallback: use the seed header as our acquisition base
1807
+ acq_base = _strip_wcs_keys(seed_h.copy())
1808
+ _debug_dump_header("ACQ_BASE (seed_h with WCS stripped)", acq_base)
1809
+ else:
1810
+ acq_base = None
1811
+ _debug_dump_header("ACQ_BASE (none available)", None)
1812
+
1813
+ # Better debug: use our new scale estimator
1814
+ try:
1815
+ if isinstance(seed_h, Header):
1816
+ ra = seed_h.get("CRVAL1", None)
1817
+ dec = seed_h.get("CRVAL2", None)
1818
+ scale = _estimate_scale_arcsec_from_header(seed_h)
1819
+ print(f"[PlateSolve seed] CRVAL1={ra}, CRVAL2={dec}, scale≈{scale} \"/px")
1820
+ else:
1821
+ print("[PlateSolve seed] No valid seed header available.")
1822
+ except Exception as e:
1823
+ print("Seed: debug print failed:", e)
1824
+
1825
+ # Determine if we have inline status/log widgets; if not, show the popup.
1826
+ headless = not (
1827
+ (hasattr(parent, "status") and isinstance(getattr(parent, "status"), QLabel)) or
1828
+ (hasattr(parent, "log") and hasattr(getattr(parent, "log"), "append")) or
1829
+ (hasattr(parent, "findChild") and parent.findChild(QLabel, "status_label") is not None)
1830
+ )
1831
+ if headless:
1832
+ _status_popup_open(parent, "Status: Preparing plate solve…")
1833
+
1834
+ try:
1835
+ ok, res = _solve_numpy_with_fallback(parent, settings, img, seed_h)
1836
+ if not ok:
1837
+ return False, res
1838
+
1839
+ hdr: Header = res
1840
+ _debug_dump_header("SOLVER RAW HEADER (from _solve_numpy_with_fallback)", hdr)
1841
+
1842
+ # Final header = acquisition + new WCS (solver)
1843
+ if isinstance(acq_base, Header) and isinstance(hdr, Header):
1844
+ hdr_final = _merge_wcs_into_base_header(acq_base, hdr)
1845
+ else:
1846
+ hdr_final = hdr if isinstance(hdr, Header) else Header()
1847
+
1848
+ _debug_dump_header("FINAL MERGED HEADER (hdr_final)", hdr_final)
1849
+ # 🔹 NEW: stash pre-solve header ONCE so we never lose it
1850
+ try:
1851
+ if "original_header" in meta and "pre_solve_header" not in meta:
1852
+ old = meta["original_header"]
1853
+ if isinstance(old, Header):
1854
+ meta["pre_solve_header"] = old.copy()
1855
+ except Exception as e:
1856
+ print("plate_solve_doc_inplace: failed to stash pre_solve_header:", e)
1857
+
1858
+ # 🔹 Ensure doc.metadata is our updated dict
1859
+ doc.metadata = meta
1860
+
1861
+ # Store merged header as the current "original_header"
1862
+ doc.metadata["original_header"] = hdr_final
1863
+ _debug_dump_header("DOC.METADATA['original_header'] AFTER SOLVE", doc.metadata.get("original_header"))
1864
+
1865
+
1866
+ # Build WCS object from the same header we just stored
1867
+ try:
1868
+ wcs_obj = WCS(hdr_final)
1869
+ doc.metadata["wcs"] = wcs_obj
1870
+ except Exception as e:
1871
+ print("WCS build FAILED:", e)
1872
+
1873
+ # Notify UI
1874
+ if hasattr(doc, "changed"):
1875
+ try: doc.changed.emit()
1876
+ except Exception as e:
1877
+ import logging
1878
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1879
+
1880
+ if hasattr(parent, "header_viewer") and hasattr(parent.header_viewer, "set_document"):
1881
+ QTimer.singleShot(0, lambda: parent.header_viewer.set_document(doc))
1882
+ if hasattr(parent, "_refresh_header_viewer"):
1883
+ QTimer.singleShot(0, lambda: parent._refresh_header_viewer(doc))
1884
+ if hasattr(parent, "currentDocumentChanged"):
1885
+ QTimer.singleShot(0, lambda: parent.currentDocumentChanged.emit(doc))
1886
+
1887
+ _set_status_ui(parent, "Status: Plate solve completed.")
1888
+ _status_popup_close()
1889
+ return True, hdr
1890
+ finally:
1891
+ _status_popup_close()
1892
+
1893
+
1894
+
1895
+ def _estimate_scale_arcsec_from_header(hdr: Header) -> float | None:
1896
+ """
1897
+ Estimate pixel scale in arcsec/pixel from a FITS Header.
1898
+ Tries WCS, then CD matrix, then PC*CDELT, then PIXSCALE-style keys.
1899
+ Returns None if we can't get a sane value.
1900
+ """
1901
+ # Always work on a copy with our internal meta keys stripped
1902
+ hdr = _strip_nonfits_meta_keys_from_header(hdr)
1903
+
1904
+ # 1) Try astropy WCS, which handles CD vs PC*CDELT automatically
1905
+ try:
1906
+ w = WCS(hdr)
1907
+ from astropy.wcs.utils import proj_plane_pixel_scales
1908
+ scales_deg = proj_plane_pixel_scales(w) # degrees/pixel
1909
+ if scales_deg is not None and len(scales_deg) >= 2:
1910
+ s_deg = float(np.mean(scales_deg[:2]))
1911
+ scale = s_deg * 3600.0 # arcsec/pixel
1912
+ if 0 < scale < 10000:
1913
+ return scale
1914
+ except Exception as e:
1915
+ print("Seed: WCS->scale via proj_plane_pixel_scales failed:", e)
1916
+
1917
+ # 2) Try CD matrix directly
1918
+ cd11 = hdr.get("CD1_1")
1919
+ cd21 = hdr.get("CD2_1")
1920
+ try:
1921
+ if cd11 is not None or cd21 is not None:
1922
+ cd11 = float(cd11 or 0.0)
1923
+ cd21 = float(cd21 or 0.0)
1924
+ s_deg = (cd11 * cd11 + cd21 * cd21) ** 0.5
1925
+ scale = s_deg * 3600.0
1926
+ if 0 < scale < 10000:
1927
+ return scale
1928
+ except Exception as e:
1929
+ print("Seed: CD-based scale failed:", e)
1930
+
1931
+ # 3) Try PC * CDELT fallback
1932
+ try:
1933
+ cdelt1 = hdr.get("CDELT1")
1934
+ cdelt2 = hdr.get("CDELT2")
1935
+ pc11 = hdr.get("PC1_1")
1936
+ pc21 = hdr.get("PC2_1")
1937
+ if cdelt1 is not None and pc11 is not None:
1938
+ cd11 = float(cdelt1) * float(pc11)
1939
+ else:
1940
+ cd11 = None
1941
+ if cdelt2 is not None and pc21 is not None:
1942
+ cd21 = float(cdelt2) * float(pc21)
1943
+ else:
1944
+ cd21 = None
1945
+
1946
+ if cd11 is not None or cd21 is not None:
1947
+ s_deg = ( (cd11 or 0.0)**2 + (cd21 or 0.0)**2 ) ** 0.5
1948
+ scale = s_deg * 3600.0
1949
+ if 0 < scale < 10000:
1950
+ return scale
1951
+ except Exception as e:
1952
+ print("Seed: PC*CDELT-based scale failed:", e)
1953
+
1954
+ # 4) Fallback on explicit pixscale-like keywords, if present
1955
+ for key in ("PIXSCALE", "SECPIX"):
1956
+ if key in hdr:
1957
+ try:
1958
+ scale = float(hdr[key])
1959
+ if 0 < scale < 10000:
1960
+ return scale
1961
+ except Exception:
1962
+ pass
1963
+
1964
+ # If we get here, we couldn't find a sane scale
1965
+ return None
1966
+
1967
+ def _seed_header_from_meta(meta: dict) -> Header:
1968
+ """
1969
+ Build the header used for ASTAP seeding from doc.metadata.
1970
+
1971
+ Priority:
1972
+ 1. original_header (if present)
1973
+ 2. meta as a dict
1974
+ Then merge in any WCS info from:
1975
+ - meta['wcs_header'] (Header or string)
1976
+ - meta['wcs'] (WCS object)
1977
+ """
1978
+ # Base: original FITS header if present, otherwise treat meta dict as header
1979
+ base_src = meta.get("original_header") or meta.get("fits_header") or meta
1980
+ base = _as_header(base_src)
1981
+
1982
+ wcs_hdr: Header | None = None
1983
+
1984
+ # 1) Use explicit wcs_header if present
1985
+ raw_wcs = meta.get("wcs_header")
1986
+ if isinstance(raw_wcs, Header):
1987
+ wcs_hdr = raw_wcs
1988
+ elif isinstance(raw_wcs, str):
1989
+ # This is your case: stored as Header.tostring()
1990
+ try:
1991
+ # In real metadata this likely has newlines; sep='\n' handles that.
1992
+ wcs_hdr = fits.Header.fromstring(raw_wcs, sep='\n')
1993
+ except Exception as e:
1994
+ print("Seed: failed to parse wcs_header string:", e)
1995
+
1996
+ # 2) Fallback: derive from WCS object if we still don't have a header
1997
+ if wcs_hdr is None:
1998
+ wcs_obj = meta.get("wcs")
1999
+ if isinstance(wcs_obj, WCS):
2000
+ try:
2001
+ wcs_hdr = wcs_obj.to_header(relax=True)
2002
+ except Exception as e:
2003
+ print("Seed: failed to derive WCS header from WCS object:", e)
2004
+
2005
+ # 3) Merge WCS header into base header, with WCS keys winning
2006
+ if wcs_hdr is not None:
2007
+ if not isinstance(base, Header):
2008
+ base = Header()
2009
+ else:
2010
+ base = base.copy()
2011
+ for k, v in wcs_hdr.items():
2012
+ try:
2013
+ base[k] = v
2014
+ except Exception:
2015
+ pass
2016
+
2017
+ return _strip_nonfits_meta_keys_from_header(base)
2018
+
2019
+
2020
+ def _compute_fov_deg(image: np.ndarray, arcsec_per_px: float | None) -> float | None:
2021
+ if arcsec_per_px is None or not np.isfinite(arcsec_per_px) or arcsec_per_px <= 0:
2022
+ return None
2023
+ H = int(image.shape[0]) if image.ndim >= 2 else 0
2024
+ if H <= 0:
2025
+ return None
2026
+ return (H * arcsec_per_px) / 3600.0 # vertical FOV in degrees
2027
+
2028
+ def plate_solve_active_document(parent, settings) -> tuple[bool, Header | str]:
2029
+ """
2030
+ Convenience wrapper:
2031
+ - Finds the active document from the given parent (main window, ImagePeeker, etc.)
2032
+ - Calls plate_solve_doc_inplace(...)
2033
+
2034
+ Returns (ok, Header | error_message).
2035
+ """
2036
+ doc = _active_doc_from_parent(parent)
2037
+ if doc is None:
2038
+ return False, "No active document to plate-solve."
2039
+
2040
+ return plate_solve_doc_inplace(parent, doc, settings)
2041
+
2042
+ # ---------------------------------------------------------------------
2043
+ # Dialog UI with Active/File and Batch modes
2044
+ # ---------------------------------------------------------------------
2045
+
2046
+ class PlateSolverDialog(QDialog):
2047
+ """
2048
+ Plate-solve either:
2049
+ - Active View (default)
2050
+ - Single File (via load_image/save_image)
2051
+ - Batch (directory → directory)
2052
+ Uses settings key: 'paths/astap' or 'astap/exe_path' for ASTAP executable.
2053
+ """
2054
+ def __init__(self, settings, parent=None, icon: QIcon | None = None):
2055
+ super().__init__(parent)
2056
+ self.settings = settings
2057
+ self.setWindowTitle("Plate Solver")
2058
+ self.setMinimumWidth(560)
2059
+ self.setWindowFlag(Qt.WindowType.Window, True)
2060
+ self.setModal(False)
2061
+ #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
2062
+
2063
+ # ---------------- Main containers ----------------
2064
+ main = QVBoxLayout(self)
2065
+ main.setContentsMargins(10, 10, 10, 10)
2066
+ main.setSpacing(10)
2067
+
2068
+ # ---- Top row: Mode selector ----
2069
+ top = QHBoxLayout()
2070
+ top.addWidget(QLabel("Mode:", self))
2071
+ self.mode_combo = QComboBox(self)
2072
+ self.mode_combo.addItems(["Active View", "File", "Batch"])
2073
+ top.addWidget(self.mode_combo, 1)
2074
+ top.addStretch(1)
2075
+ main.addLayout(top)
2076
+
2077
+ # ---- Seeding group (shared) ----
2078
+ from PyQt6.QtWidgets import QGroupBox, QFormLayout
2079
+ seed_box = QGroupBox("Seeding & Constraints", self)
2080
+ seed_form = QFormLayout(seed_box)
2081
+ seed_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
2082
+ seed_form.setHorizontalSpacing(8)
2083
+ seed_form.setVerticalSpacing(6)
2084
+
2085
+ # Seed mode
2086
+ self.cb_seed_mode = QComboBox(seed_box)
2087
+ self.cb_seed_mode.addItems(["Auto (from header)", "Manual", "None (blind)"])
2088
+ seed_form.addRow("Seed mode:", self.cb_seed_mode)
2089
+
2090
+ # Manual RA/Dec/Scale row
2091
+ manual_row = QHBoxLayout()
2092
+ self.le_ra = QLineEdit(seed_box); self.le_ra.setPlaceholderText("RA (e.g. 22:32:14 or 338.1385)")
2093
+ self.le_dec = QLineEdit(seed_box); self.le_dec.setPlaceholderText("Dec (e.g. +40:42:43 or 40.7123)")
2094
+ self.le_scale = QLineEdit(seed_box); self.le_scale.setPlaceholderText('Scale [" / px] (e.g. 1.46)')
2095
+ manual_row.addWidget(self.le_ra, 1)
2096
+ manual_row.addWidget(self.le_dec, 1)
2097
+ manual_row.addWidget(self.le_scale, 1)
2098
+ seed_form.addRow("Manual RA/Dec/Scale:", manual_row)
2099
+
2100
+ # Search radius (-r)
2101
+ rad_row = QHBoxLayout()
2102
+ self.cb_radius_mode = QComboBox(seed_box)
2103
+ self.cb_radius_mode.addItems(["Auto (-r 0)", "Value (deg)"])
2104
+ self.le_radius_val = QLineEdit(seed_box); self.le_radius_val.setPlaceholderText("e.g. 5.0")
2105
+ self.le_radius_val.setFixedWidth(120)
2106
+ rad_row.addWidget(self.cb_radius_mode)
2107
+ rad_row.addWidget(self.le_radius_val)
2108
+ rad_row.addStretch(1)
2109
+ seed_form.addRow("Search radius:", rad_row)
2110
+
2111
+ # FOV (-fov)
2112
+ fov_row = QHBoxLayout()
2113
+ self.cb_fov_mode = QComboBox(seed_box)
2114
+ self.cb_fov_mode.addItems(["Compute from scale", "Auto (-fov 0)", "Value (deg)"])
2115
+ self.le_fov_val = QLineEdit(seed_box); self.le_fov_val.setPlaceholderText("e.g. 1.80")
2116
+ self.le_fov_val.setFixedWidth(120)
2117
+ fov_row.addWidget(self.cb_fov_mode)
2118
+ fov_row.addWidget(self.le_fov_val)
2119
+ fov_row.addStretch(1)
2120
+ seed_form.addRow("FOV:", fov_row)
2121
+
2122
+ # Tooltips
2123
+ self.cb_seed_mode.setToolTip("Use FITS header, your manual RA/Dec/scale, or blind solve.")
2124
+ self.le_scale.setToolTip('Pixel scale in arcseconds/pixel (e.g., 1.46).')
2125
+ self.cb_radius_mode.setToolTip("ASTAP -r. Auto lets ASTAP choose; Value forces a cone radius.")
2126
+ self.cb_fov_mode.setToolTip("ASTAP -fov. Compute uses image height × scale; Auto lets ASTAP infer.")
2127
+
2128
+ main.addWidget(seed_box)
2129
+
2130
+ # ---------------- Stacked pages ----------------
2131
+ self.stack = QStackedWidget(self)
2132
+ main.addWidget(self.stack, 1)
2133
+
2134
+ # Page 0: Active View
2135
+ p0 = QWidget(self); l0 = QVBoxLayout(p0)
2136
+ l0.addWidget(QLabel("Solve the currently active image view.", p0))
2137
+ l0.addStretch(1)
2138
+ self.stack.addWidget(p0)
2139
+
2140
+ # Page 1: File picker
2141
+ p1 = QWidget(self); l1 = QVBoxLayout(p1)
2142
+ file_row = QHBoxLayout()
2143
+ self.le_path = QLineEdit(p1); self.le_path.setPlaceholderText("Choose an image…")
2144
+ btn_browse = QPushButton("Browse…", p1)
2145
+ file_row.addWidget(self.le_path, 1); file_row.addWidget(btn_browse)
2146
+ l1.addLayout(file_row); l1.addStretch(1)
2147
+ self.stack.addWidget(p1)
2148
+
2149
+ # Page 2: Batch
2150
+ p2 = QWidget(self); l2 = QVBoxLayout(p2)
2151
+ in_row = QHBoxLayout(); out_row = QHBoxLayout()
2152
+ self.le_in = QLineEdit(p2); self.le_in.setPlaceholderText("Input directory")
2153
+ self.le_out = QLineEdit(p2); self.le_out.setPlaceholderText("Output directory")
2154
+ b_in = QPushButton("Browse Input…", p2)
2155
+ b_out = QPushButton("Browse Output…", p2)
2156
+ in_row.addWidget(self.le_in, 1); in_row.addWidget(b_in)
2157
+ out_row.addWidget(self.le_out, 1); out_row.addWidget(b_out)
2158
+ self.log = QTextEdit(p2); self.log.setReadOnly(True); self.log.setMinimumHeight(160)
2159
+ l2.addLayout(in_row); l2.addLayout(out_row); l2.addWidget(QLabel("Status:", p2)); l2.addWidget(self.log, 1)
2160
+ self.stack.addWidget(p2)
2161
+
2162
+ # ---------------- Status + buttons ----------------
2163
+ self.status = QLabel("", self)
2164
+ self.status.setMinimumHeight(20)
2165
+ main.addWidget(self.status)
2166
+
2167
+ btn_row = QHBoxLayout()
2168
+ btn_row.addStretch(1)
2169
+ self.btn_go = QPushButton("Start", self)
2170
+ self.btn_close = QPushButton("Close", self)
2171
+ btn_row.addWidget(self.btn_go)
2172
+ btn_row.addWidget(self.btn_close)
2173
+ main.addLayout(btn_row)
2174
+
2175
+ # ---------------- Connections ----------------
2176
+ self.mode_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
2177
+ btn_browse.clicked.connect(self._browse_file)
2178
+ b_in.clicked.connect(self._browse_in)
2179
+ b_out.clicked.connect(self._browse_out)
2180
+ self.btn_go.clicked.connect(self._run)
2181
+ self.btn_close.clicked.connect(self.close)
2182
+
2183
+ # ---------------- Load settings & init UI ----------------
2184
+ mode_map = {"auto": 0, "manual": 1, "none": 2}
2185
+ self.cb_seed_mode.setCurrentIndex(mode_map.get(_get_seed_mode(self.settings), 0))
2186
+ self.le_ra.setText(_get_manual_ra(self.settings))
2187
+ self.le_dec.setText(_get_manual_dec(self.settings))
2188
+ scl = _get_manual_scale(self.settings)
2189
+ self.le_scale.setText("" if scl is None else str(scl))
2190
+
2191
+ self.cb_radius_mode.setCurrentIndex(0 if _get_astap_radius_mode(self.settings) == "auto" else 1)
2192
+ self.le_radius_val.setText(str(_get_astap_radius_value(self.settings)))
2193
+
2194
+ fov_mode = _get_astap_fov_mode(self.settings)
2195
+ self.cb_fov_mode.setCurrentIndex(1 if fov_mode == "auto" else (2 if fov_mode == "value" else 0))
2196
+ self.le_fov_val.setText(str(_get_astap_fov_value(self.settings)))
2197
+
2198
+ def _update_visibility():
2199
+ manual = (self.cb_seed_mode.currentIndex() == 1)
2200
+ self.le_ra.setEnabled(manual)
2201
+ self.le_dec.setEnabled(manual)
2202
+ self.le_scale.setEnabled(manual)
2203
+ self.le_radius_val.setEnabled(self.cb_radius_mode.currentIndex() == 1)
2204
+ self.le_fov_val.setEnabled(self.cb_fov_mode.currentIndex() == 2)
2205
+
2206
+ self.cb_seed_mode.currentIndexChanged.connect(_update_visibility)
2207
+ self.cb_radius_mode.currentIndexChanged.connect(_update_visibility)
2208
+ self.cb_fov_mode.currentIndexChanged.connect(_update_visibility)
2209
+ _update_visibility()
2210
+
2211
+ if icon:
2212
+ self.setWindowIcon(icon)
2213
+
2214
+ self.status.setObjectName("status_label")
2215
+ # if batch page exists:
2216
+ self.log.setObjectName("batch_log")
2217
+
2218
+ # ---------- file/batch pickers ----------
2219
+ def _browse_file(self):
2220
+ f, _ = QFileDialog.getOpenFileName(
2221
+ self, "Choose Image",
2222
+ "", "Images (*.fits *.fit *.xisf *.tif *.tiff *.png *.jpg *.jpeg);;All files (*)"
2223
+ )
2224
+ if f:
2225
+ self.le_path.setText(f)
2226
+
2227
+ def _browse_in(self):
2228
+ d = QFileDialog.getExistingDirectory(self, "Choose input directory")
2229
+ if d: self.le_in.setText(d)
2230
+
2231
+ def _browse_out(self):
2232
+ d = QFileDialog.getExistingDirectory(self, "Choose output directory")
2233
+ if d: self.le_out.setText(d)
2234
+
2235
+ # ---------- actions ----------
2236
+ def _run(self):
2237
+ astap_exe = _get_astap_exe(self.settings)
2238
+ if not astap_exe or not os.path.exists(astap_exe):
2239
+ self.status.setText("ASTAP path missing. Set Preferences → ASTAP executable.")
2240
+ QMessageBox.warning(self, "Plate Solver", "ASTAP path missing.\nSet it in Preferences → ASTAP executable.")
2241
+ return
2242
+
2243
+ idx = self.cb_seed_mode.currentIndex()
2244
+ _set_seed_mode(self.settings, "auto" if idx == 0 else ("manual" if idx == 1 else "none"))
2245
+ # manual values
2246
+ try:
2247
+ manual_scale = float(self.le_scale.text().strip()) if self.le_scale.text().strip() else None
2248
+ except Exception:
2249
+ manual_scale = None
2250
+ _set_manual_seed(self.settings, self.le_ra.text().strip(), self.le_dec.text().strip(), manual_scale)
2251
+ # radius
2252
+ self.settings.setValue("astap/seed_radius_mode", "auto" if self.cb_radius_mode.currentIndex()==0 else "value")
2253
+ try:
2254
+ self.settings.setValue("astap/seed_radius_value", float(self.le_radius_val.text().strip()))
2255
+ except Exception:
2256
+ pass
2257
+ # fov
2258
+ self.settings.setValue("astap/seed_fov_mode",
2259
+ "compute" if self.cb_fov_mode.currentIndex()==0 else ("auto" if self.cb_fov_mode.currentIndex()==1 else "value"))
2260
+ try:
2261
+ self.settings.setValue("astap/seed_fov_value", float(self.le_fov_val.text().strip()))
2262
+ except Exception:
2263
+ pass
2264
+
2265
+ mode = self.stack.currentIndex()
2266
+ if mode == 0:
2267
+ # Active view
2268
+ doc = _active_doc_from_parent(self.parent())
2269
+ if not doc:
2270
+ QMessageBox.information(self, "Plate Solver", "No active image view.")
2271
+ return
2272
+ ok, res = plate_solve_doc_inplace(self, doc, self.settings)
2273
+ if ok:
2274
+ self.status.setText("Solved with ASTAP (WCS + SIP applied to active doc).")
2275
+ QTimer.singleShot(0, self.accept) # close when done
2276
+ else:
2277
+ self.status.setText(str(res))
2278
+ elif mode == 1:
2279
+ # Single file
2280
+ path = self.le_path.text().strip()
2281
+ if not path:
2282
+ QMessageBox.information(self, "Plate Solver", "Choose a file to solve.")
2283
+ return
2284
+ if not os.path.exists(path):
2285
+ QMessageBox.warning(self, "Plate Solver", "Selected file does not exist.")
2286
+ return
2287
+ self._solve_file(path)
2288
+ else:
2289
+ self._run_batch()
2290
+
2291
+ def _solve_file(self, path: str):
2292
+ # Load using legacy.load_image()
2293
+ try:
2294
+ image_data, original_header, bit_depth, is_mono = load_image(path)
2295
+ except Exception as e:
2296
+ QMessageBox.warning(self, "Plate Solver", f"Cannot read image:\n{e}")
2297
+ return
2298
+ if image_data is None:
2299
+ QMessageBox.warning(self, "Plate Solver", "Unsupported or unreadable image.")
2300
+ return
2301
+
2302
+ # Seed header from original_header
2303
+ seed_h = _as_header(original_header) if isinstance(original_header, (dict, Header)) else None
2304
+
2305
+ # Acquisition base for final merge (strip old WCS)
2306
+ acq_base: Header | None = None
2307
+ if isinstance(seed_h, Header):
2308
+ acq_base = _strip_wcs_keys(seed_h)
2309
+
2310
+ # Solve
2311
+ ok, res = _solve_numpy_with_fallback(self, self.settings, image_data, seed_h)
2312
+ if not ok:
2313
+ self.status.setText(str(res))
2314
+ return
2315
+ solver_hdr: Header = res
2316
+
2317
+ # Merge solver WCS into acquisition header
2318
+ if isinstance(acq_base, Header) and isinstance(solver_hdr, Header):
2319
+ hdr_final = _merge_wcs_into_base_header(acq_base, solver_hdr)
2320
+ else:
2321
+ hdr_final = solver_hdr if isinstance(solver_hdr, Header) else Header()
2322
+
2323
+ # Save-as using legacy.save_image() with ORIGINAL pixels (not normalized)
2324
+ save_path, _ = QFileDialog.getSaveFileName(
2325
+ self,
2326
+ "Save Plate-Solved FITS",
2327
+ "",
2328
+ "FITS files (*.fits *.fit)"
2329
+ )
2330
+ if save_path:
2331
+ try:
2332
+ # never persist 'file_path' inside FITS
2333
+ h2 = Header()
2334
+ for k in hdr_final.keys():
2335
+ if k.upper() != "FILE_PATH":
2336
+ h2[k] = hdr_final[k]
2337
+
2338
+ save_image(
2339
+ img_array=image_data,
2340
+ filename=save_path,
2341
+ original_format="fit",
2342
+ bit_depth="32-bit floating point",
2343
+ original_header=h2,
2344
+ is_mono=is_mono
2345
+ )
2346
+ self.status.setText(f"Solved FITS saved:\n{save_path}")
2347
+ QTimer.singleShot(0, self.accept)
2348
+ except Exception as e:
2349
+ QMessageBox.critical(self, "Save Error", f"Failed to save: {e}")
2350
+ else:
2351
+ self.status.setText("Solved (not saved).")
2352
+
2353
+
2354
+ def _run_batch(self):
2355
+ in_dir = self.le_in.text().strip()
2356
+ out_dir = self.le_out.text().strip()
2357
+ if not in_dir or not os.path.isdir(in_dir):
2358
+ QMessageBox.warning(self, "Batch", "Please choose a valid input directory.")
2359
+ return
2360
+ if not out_dir or not os.path.isdir(out_dir):
2361
+ QMessageBox.warning(self, "Batch", "Please choose a valid output directory.")
2362
+ return
2363
+
2364
+ exts = {".xisf", ".fits", ".fit", ".tif", ".tiff", ".png", ".jpg", ".jpeg"}
2365
+ files = [
2366
+ os.path.join(in_dir, f)
2367
+ for f in os.listdir(in_dir)
2368
+ if os.path.splitext(f)[1].lower() in exts
2369
+ ]
2370
+ if not files:
2371
+ QMessageBox.information(self, "Batch", "No acceptable image files found.")
2372
+ return
2373
+
2374
+ self.log.clear()
2375
+ self.log.append(f"Found {len(files)} files. Starting batch…")
2376
+ QApplication.processEvents()
2377
+
2378
+ for path in files:
2379
+ base = os.path.splitext(os.path.basename(path))[0]
2380
+ out = os.path.join(out_dir, base + "_plate_solved.fits")
2381
+ self.log.append(f"▶ {path}")
2382
+ QApplication.processEvents()
2383
+
2384
+ try:
2385
+ # Load using legacy.load_image()
2386
+ image_data, original_header, bit_depth, is_mono = load_image(path)
2387
+ if image_data is None:
2388
+ self.log.append(" ❌ Failed to load")
2389
+ continue
2390
+
2391
+ # Seed header from original_header
2392
+ seed_h = _as_header(original_header) if isinstance(original_header, (dict, Header)) else None
2393
+
2394
+ # Acquisition base for final merge (strip old WCS)
2395
+ acq_base: Header | None = None
2396
+ if isinstance(seed_h, Header):
2397
+ acq_base = _strip_wcs_keys(seed_h)
2398
+
2399
+ # Solve
2400
+ ok, res = _solve_numpy_with_fallback(self, self.settings, image_data, seed_h)
2401
+ if not ok:
2402
+ self.log.append(f" ❌ {res}")
2403
+ continue
2404
+ hdr: Header = res
2405
+
2406
+ # Merge solver WCS into acquisition header
2407
+ if isinstance(acq_base, Header) and isinstance(hdr, Header):
2408
+ hdr_final = _merge_wcs_into_base_header(acq_base, hdr)
2409
+ else:
2410
+ hdr_final = hdr if isinstance(hdr, Header) else Header()
2411
+
2412
+ # Build header to save (and strip FILE_PATH)
2413
+ h2 = Header()
2414
+ for k in hdr_final.keys():
2415
+ if k.upper() != "FILE_PATH":
2416
+ h2[k] = hdr_final[k]
2417
+
2418
+ # Save using original pixels
2419
+ save_image(
2420
+ img_array=image_data,
2421
+ filename=out,
2422
+ original_format="fit",
2423
+ bit_depth="32-bit floating point",
2424
+ original_header=h2,
2425
+ is_mono=is_mono
2426
+ )
2427
+ self.log.append(" ✔ saved: " + out)
2428
+
2429
+ except Exception as e:
2430
+ self.log.append(" ❌ error: " + str(e))
2431
+
2432
+ QApplication.processEvents()
2433
+
2434
+ self.log.append("Batch plate solving completed.")
2435
+