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