setiastrosuitepro 1.6.7__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 (394) 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/acv_icon.png +0 -0
  24. setiastro/images/andromedatry.png +0 -0
  25. setiastro/images/andromedatry_satellited.png +0 -0
  26. setiastro/images/annotated.png +0 -0
  27. setiastro/images/aperture.png +0 -0
  28. setiastro/images/astrosuite.ico +0 -0
  29. setiastro/images/astrosuite.png +0 -0
  30. setiastro/images/astrosuitepro.icns +0 -0
  31. setiastro/images/astrosuitepro.ico +0 -0
  32. setiastro/images/astrosuitepro.png +0 -0
  33. setiastro/images/background.png +0 -0
  34. setiastro/images/background2.png +0 -0
  35. setiastro/images/benchmark.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  37. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  38. setiastro/images/blaster.png +0 -0
  39. setiastro/images/blink.png +0 -0
  40. setiastro/images/clahe.png +0 -0
  41. setiastro/images/collage.png +0 -0
  42. setiastro/images/colorwheel.png +0 -0
  43. setiastro/images/contsub.png +0 -0
  44. setiastro/images/convo.png +0 -0
  45. setiastro/images/copyslot.png +0 -0
  46. setiastro/images/cosmic.png +0 -0
  47. setiastro/images/cosmicsat.png +0 -0
  48. setiastro/images/crop1.png +0 -0
  49. setiastro/images/cropicon.png +0 -0
  50. setiastro/images/curves.png +0 -0
  51. setiastro/images/cvs.png +0 -0
  52. setiastro/images/debayer.png +0 -0
  53. setiastro/images/denoise_cnn_custom.png +0 -0
  54. setiastro/images/denoise_cnn_graph.png +0 -0
  55. setiastro/images/disk.png +0 -0
  56. setiastro/images/dse.png +0 -0
  57. setiastro/images/exoicon.png +0 -0
  58. setiastro/images/eye.png +0 -0
  59. setiastro/images/first_quarter.png +0 -0
  60. setiastro/images/fliphorizontal.png +0 -0
  61. setiastro/images/flipvertical.png +0 -0
  62. setiastro/images/font.png +0 -0
  63. setiastro/images/freqsep.png +0 -0
  64. setiastro/images/full_moon.png +0 -0
  65. setiastro/images/functionbundle.png +0 -0
  66. setiastro/images/graxpert.png +0 -0
  67. setiastro/images/green.png +0 -0
  68. setiastro/images/gridicon.png +0 -0
  69. setiastro/images/halo.png +0 -0
  70. setiastro/images/hdr.png +0 -0
  71. setiastro/images/histogram.png +0 -0
  72. setiastro/images/hubble.png +0 -0
  73. setiastro/images/imagecombine.png +0 -0
  74. setiastro/images/invert.png +0 -0
  75. setiastro/images/isophote.png +0 -0
  76. setiastro/images/isophote_demo_figure.png +0 -0
  77. setiastro/images/isophote_demo_image.png +0 -0
  78. setiastro/images/isophote_demo_model.png +0 -0
  79. setiastro/images/isophote_demo_residual.png +0 -0
  80. setiastro/images/jwstpupil.png +0 -0
  81. setiastro/images/last_quarter.png +0 -0
  82. setiastro/images/linearfit.png +0 -0
  83. setiastro/images/livestacking.png +0 -0
  84. setiastro/images/mask.png +0 -0
  85. setiastro/images/maskapply.png +0 -0
  86. setiastro/images/maskcreate.png +0 -0
  87. setiastro/images/maskremove.png +0 -0
  88. setiastro/images/morpho.png +0 -0
  89. setiastro/images/mosaic.png +0 -0
  90. setiastro/images/multiscale_decomp.png +0 -0
  91. setiastro/images/nbtorgb.png +0 -0
  92. setiastro/images/neutral.png +0 -0
  93. setiastro/images/new_moon.png +0 -0
  94. setiastro/images/nuke.png +0 -0
  95. setiastro/images/openfile.png +0 -0
  96. setiastro/images/pedestal.png +0 -0
  97. setiastro/images/pen.png +0 -0
  98. setiastro/images/pixelmath.png +0 -0
  99. setiastro/images/platesolve.png +0 -0
  100. setiastro/images/ppp.png +0 -0
  101. setiastro/images/pro.png +0 -0
  102. setiastro/images/project.png +0 -0
  103. setiastro/images/psf.png +0 -0
  104. setiastro/images/redo.png +0 -0
  105. setiastro/images/redoicon.png +0 -0
  106. setiastro/images/rescale.png +0 -0
  107. setiastro/images/rgbalign.png +0 -0
  108. setiastro/images/rgbcombo.png +0 -0
  109. setiastro/images/rgbextract.png +0 -0
  110. setiastro/images/rotate180.png +0 -0
  111. setiastro/images/rotatearbitrary.png +0 -0
  112. setiastro/images/rotateclockwise.png +0 -0
  113. setiastro/images/rotatecounterclockwise.png +0 -0
  114. setiastro/images/satellite.png +0 -0
  115. setiastro/images/script.png +0 -0
  116. setiastro/images/selectivecolor.png +0 -0
  117. setiastro/images/simbad.png +0 -0
  118. setiastro/images/slot0.png +0 -0
  119. setiastro/images/slot1.png +0 -0
  120. setiastro/images/slot2.png +0 -0
  121. setiastro/images/slot3.png +0 -0
  122. setiastro/images/slot4.png +0 -0
  123. setiastro/images/slot5.png +0 -0
  124. setiastro/images/slot6.png +0 -0
  125. setiastro/images/slot7.png +0 -0
  126. setiastro/images/slot8.png +0 -0
  127. setiastro/images/slot9.png +0 -0
  128. setiastro/images/spcc.png +0 -0
  129. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  130. setiastro/images/spinner.gif +0 -0
  131. setiastro/images/stacking.png +0 -0
  132. setiastro/images/staradd.png +0 -0
  133. setiastro/images/staralign.png +0 -0
  134. setiastro/images/starnet.png +0 -0
  135. setiastro/images/starregistration.png +0 -0
  136. setiastro/images/starspike.png +0 -0
  137. setiastro/images/starstretch.png +0 -0
  138. setiastro/images/statstretch.png +0 -0
  139. setiastro/images/supernova.png +0 -0
  140. setiastro/images/uhs.png +0 -0
  141. setiastro/images/undoicon.png +0 -0
  142. setiastro/images/upscale.png +0 -0
  143. setiastro/images/viewbundle.png +0 -0
  144. setiastro/images/waning_crescent_1.png +0 -0
  145. setiastro/images/waning_crescent_2.png +0 -0
  146. setiastro/images/waning_crescent_3.png +0 -0
  147. setiastro/images/waning_crescent_4.png +0 -0
  148. setiastro/images/waning_crescent_5.png +0 -0
  149. setiastro/images/waning_gibbous_1.png +0 -0
  150. setiastro/images/waning_gibbous_2.png +0 -0
  151. setiastro/images/waning_gibbous_3.png +0 -0
  152. setiastro/images/waning_gibbous_4.png +0 -0
  153. setiastro/images/waning_gibbous_5.png +0 -0
  154. setiastro/images/waxing_crescent_1.png +0 -0
  155. setiastro/images/waxing_crescent_2.png +0 -0
  156. setiastro/images/waxing_crescent_3.png +0 -0
  157. setiastro/images/waxing_crescent_4.png +0 -0
  158. setiastro/images/waxing_crescent_5.png +0 -0
  159. setiastro/images/waxing_gibbous_1.png +0 -0
  160. setiastro/images/waxing_gibbous_2.png +0 -0
  161. setiastro/images/waxing_gibbous_3.png +0 -0
  162. setiastro/images/waxing_gibbous_4.png +0 -0
  163. setiastro/images/waxing_gibbous_5.png +0 -0
  164. setiastro/images/whitebalance.png +0 -0
  165. setiastro/images/wimi_icon_256x256.png +0 -0
  166. setiastro/images/wimilogo.png +0 -0
  167. setiastro/images/wims.png +0 -0
  168. setiastro/images/wrench_icon.png +0 -0
  169. setiastro/images/xisfliberator.png +0 -0
  170. setiastro/qml/ResourceMonitor.qml +128 -0
  171. setiastro/saspro/__init__.py +20 -0
  172. setiastro/saspro/__main__.py +964 -0
  173. setiastro/saspro/_generated/__init__.py +7 -0
  174. setiastro/saspro/_generated/build_info.py +3 -0
  175. setiastro/saspro/abe.py +1379 -0
  176. setiastro/saspro/abe_preset.py +196 -0
  177. setiastro/saspro/aberration_ai.py +910 -0
  178. setiastro/saspro/aberration_ai_preset.py +224 -0
  179. setiastro/saspro/accel_installer.py +218 -0
  180. setiastro/saspro/accel_workers.py +30 -0
  181. setiastro/saspro/acv_exporter.py +379 -0
  182. setiastro/saspro/add_stars.py +627 -0
  183. setiastro/saspro/astrobin_exporter.py +1010 -0
  184. setiastro/saspro/astrospike.py +153 -0
  185. setiastro/saspro/astrospike_python.py +1841 -0
  186. setiastro/saspro/autostretch.py +198 -0
  187. setiastro/saspro/backgroundneutral.py +639 -0
  188. setiastro/saspro/batch_convert.py +328 -0
  189. setiastro/saspro/batch_renamer.py +522 -0
  190. setiastro/saspro/blemish_blaster.py +494 -0
  191. setiastro/saspro/blink_comparator_pro.py +3149 -0
  192. setiastro/saspro/bundles.py +61 -0
  193. setiastro/saspro/bundles_dock.py +114 -0
  194. setiastro/saspro/cheat_sheet.py +213 -0
  195. setiastro/saspro/clahe.py +371 -0
  196. setiastro/saspro/comet_stacking.py +1442 -0
  197. setiastro/saspro/common_tr.py +107 -0
  198. setiastro/saspro/config.py +38 -0
  199. setiastro/saspro/config_bootstrap.py +40 -0
  200. setiastro/saspro/config_manager.py +316 -0
  201. setiastro/saspro/continuum_subtract.py +1620 -0
  202. setiastro/saspro/convo.py +1403 -0
  203. setiastro/saspro/convo_preset.py +414 -0
  204. setiastro/saspro/copyastro.py +190 -0
  205. setiastro/saspro/cosmicclarity.py +1593 -0
  206. setiastro/saspro/cosmicclarity_preset.py +407 -0
  207. setiastro/saspro/crop_dialog_pro.py +1005 -0
  208. setiastro/saspro/crop_preset.py +189 -0
  209. setiastro/saspro/curve_editor_pro.py +2608 -0
  210. setiastro/saspro/curves_preset.py +375 -0
  211. setiastro/saspro/debayer.py +673 -0
  212. setiastro/saspro/debug_utils.py +29 -0
  213. setiastro/saspro/dnd_mime.py +35 -0
  214. setiastro/saspro/doc_manager.py +2727 -0
  215. setiastro/saspro/exoplanet_detector.py +2258 -0
  216. setiastro/saspro/file_utils.py +284 -0
  217. setiastro/saspro/fitsmodifier.py +748 -0
  218. setiastro/saspro/fix_bom.py +32 -0
  219. setiastro/saspro/free_torch_memory.py +48 -0
  220. setiastro/saspro/frequency_separation.py +1352 -0
  221. setiastro/saspro/function_bundle.py +1596 -0
  222. setiastro/saspro/generate_translations.py +3092 -0
  223. setiastro/saspro/ghs_dialog_pro.py +728 -0
  224. setiastro/saspro/ghs_preset.py +284 -0
  225. setiastro/saspro/graxpert.py +638 -0
  226. setiastro/saspro/graxpert_preset.py +287 -0
  227. setiastro/saspro/gui/__init__.py +0 -0
  228. setiastro/saspro/gui/main_window.py +8928 -0
  229. setiastro/saspro/gui/mixins/__init__.py +33 -0
  230. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  231. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  232. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  233. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  234. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  235. setiastro/saspro/gui/mixins/menu_mixin.py +391 -0
  236. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  237. setiastro/saspro/gui/mixins/toolbar_mixin.py +1824 -0
  238. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  239. setiastro/saspro/gui/mixins/view_mixin.py +477 -0
  240. setiastro/saspro/gui/statistics_dialog.py +47 -0
  241. setiastro/saspro/halobgon.py +492 -0
  242. setiastro/saspro/header_viewer.py +448 -0
  243. setiastro/saspro/headless_utils.py +88 -0
  244. setiastro/saspro/histogram.py +760 -0
  245. setiastro/saspro/history_explorer.py +941 -0
  246. setiastro/saspro/i18n.py +168 -0
  247. setiastro/saspro/image_combine.py +421 -0
  248. setiastro/saspro/image_peeker_pro.py +1608 -0
  249. setiastro/saspro/imageops/__init__.py +37 -0
  250. setiastro/saspro/imageops/mdi_snap.py +292 -0
  251. setiastro/saspro/imageops/scnr.py +36 -0
  252. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  253. setiastro/saspro/imageops/stretch.py +236 -0
  254. setiastro/saspro/isophote.py +1186 -0
  255. setiastro/saspro/layers.py +208 -0
  256. setiastro/saspro/layers_dock.py +714 -0
  257. setiastro/saspro/lazy_imports.py +193 -0
  258. setiastro/saspro/legacy/__init__.py +2 -0
  259. setiastro/saspro/legacy/image_manager.py +2360 -0
  260. setiastro/saspro/legacy/numba_utils.py +3676 -0
  261. setiastro/saspro/legacy/xisf.py +1213 -0
  262. setiastro/saspro/linear_fit.py +537 -0
  263. setiastro/saspro/live_stacking.py +1854 -0
  264. setiastro/saspro/log_bus.py +5 -0
  265. setiastro/saspro/logging_config.py +460 -0
  266. setiastro/saspro/luminancerecombine.py +510 -0
  267. setiastro/saspro/main_helpers.py +201 -0
  268. setiastro/saspro/mask_creation.py +1090 -0
  269. setiastro/saspro/masks_core.py +56 -0
  270. setiastro/saspro/mdi_widgets.py +353 -0
  271. setiastro/saspro/memory_utils.py +666 -0
  272. setiastro/saspro/metadata_patcher.py +75 -0
  273. setiastro/saspro/mfdeconv.py +3909 -0
  274. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  275. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  276. setiastro/saspro/mfdeconvsport.py +2459 -0
  277. setiastro/saspro/minorbodycatalog.py +567 -0
  278. setiastro/saspro/morphology.py +411 -0
  279. setiastro/saspro/multiscale_decomp.py +1751 -0
  280. setiastro/saspro/nbtorgb_stars.py +541 -0
  281. setiastro/saspro/numba_utils.py +3145 -0
  282. setiastro/saspro/numba_warmup.py +141 -0
  283. setiastro/saspro/ops/__init__.py +9 -0
  284. setiastro/saspro/ops/command_help_dialog.py +623 -0
  285. setiastro/saspro/ops/command_runner.py +217 -0
  286. setiastro/saspro/ops/commands.py +1594 -0
  287. setiastro/saspro/ops/script_editor.py +1105 -0
  288. setiastro/saspro/ops/scripts.py +1476 -0
  289. setiastro/saspro/ops/settings.py +637 -0
  290. setiastro/saspro/parallel_utils.py +554 -0
  291. setiastro/saspro/pedestal.py +121 -0
  292. setiastro/saspro/perfect_palette_picker.py +1105 -0
  293. setiastro/saspro/pipeline.py +110 -0
  294. setiastro/saspro/pixelmath.py +1604 -0
  295. setiastro/saspro/plate_solver.py +2480 -0
  296. setiastro/saspro/project_io.py +797 -0
  297. setiastro/saspro/psf_utils.py +136 -0
  298. setiastro/saspro/psf_viewer.py +631 -0
  299. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  300. setiastro/saspro/remove_green.py +331 -0
  301. setiastro/saspro/remove_stars.py +1599 -0
  302. setiastro/saspro/remove_stars_preset.py +446 -0
  303. setiastro/saspro/resources.py +570 -0
  304. setiastro/saspro/rgb_combination.py +208 -0
  305. setiastro/saspro/rgb_extract.py +19 -0
  306. setiastro/saspro/rgbalign.py +727 -0
  307. setiastro/saspro/runtime_imports.py +7 -0
  308. setiastro/saspro/runtime_torch.py +754 -0
  309. setiastro/saspro/save_options.py +73 -0
  310. setiastro/saspro/selective_color.py +1614 -0
  311. setiastro/saspro/sfcc.py +1530 -0
  312. setiastro/saspro/shortcuts.py +3125 -0
  313. setiastro/saspro/signature_insert.py +1106 -0
  314. setiastro/saspro/stacking_suite.py +19069 -0
  315. setiastro/saspro/star_alignment.py +7383 -0
  316. setiastro/saspro/star_alignment_preset.py +329 -0
  317. setiastro/saspro/star_metrics.py +49 -0
  318. setiastro/saspro/star_spikes.py +769 -0
  319. setiastro/saspro/star_stretch.py +542 -0
  320. setiastro/saspro/stat_stretch.py +554 -0
  321. setiastro/saspro/status_log_dock.py +78 -0
  322. setiastro/saspro/subwindow.py +3523 -0
  323. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  324. setiastro/saspro/swap_manager.py +134 -0
  325. setiastro/saspro/torch_backend.py +89 -0
  326. setiastro/saspro/torch_rejection.py +434 -0
  327. setiastro/saspro/translations/all_source_strings.json +4726 -0
  328. setiastro/saspro/translations/ar_translations.py +4096 -0
  329. setiastro/saspro/translations/de_translations.py +3728 -0
  330. setiastro/saspro/translations/es_translations.py +4169 -0
  331. setiastro/saspro/translations/fr_translations.py +4090 -0
  332. setiastro/saspro/translations/hi_translations.py +3803 -0
  333. setiastro/saspro/translations/integrate_translations.py +271 -0
  334. setiastro/saspro/translations/it_translations.py +4728 -0
  335. setiastro/saspro/translations/ja_translations.py +3834 -0
  336. setiastro/saspro/translations/pt_translations.py +3847 -0
  337. setiastro/saspro/translations/ru_translations.py +3082 -0
  338. setiastro/saspro/translations/saspro_ar.qm +0 -0
  339. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  340. setiastro/saspro/translations/saspro_de.qm +0 -0
  341. setiastro/saspro/translations/saspro_de.ts +14548 -0
  342. setiastro/saspro/translations/saspro_es.qm +0 -0
  343. setiastro/saspro/translations/saspro_es.ts +16202 -0
  344. setiastro/saspro/translations/saspro_fr.qm +0 -0
  345. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  346. setiastro/saspro/translations/saspro_hi.qm +0 -0
  347. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  348. setiastro/saspro/translations/saspro_it.qm +0 -0
  349. setiastro/saspro/translations/saspro_it.ts +19046 -0
  350. setiastro/saspro/translations/saspro_ja.qm +0 -0
  351. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  352. setiastro/saspro/translations/saspro_pt.qm +0 -0
  353. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  354. setiastro/saspro/translations/saspro_ru.qm +0 -0
  355. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  356. setiastro/saspro/translations/saspro_sw.qm +0 -0
  357. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  358. setiastro/saspro/translations/saspro_uk.qm +0 -0
  359. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  360. setiastro/saspro/translations/saspro_zh.qm +0 -0
  361. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  362. setiastro/saspro/translations/sw_translations.py +3897 -0
  363. setiastro/saspro/translations/uk_translations.py +3929 -0
  364. setiastro/saspro/translations/zh_translations.py +3910 -0
  365. setiastro/saspro/versioning.py +77 -0
  366. setiastro/saspro/view_bundle.py +1558 -0
  367. setiastro/saspro/wavescale_hdr.py +648 -0
  368. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  369. setiastro/saspro/wavescalede.py +683 -0
  370. setiastro/saspro/wavescalede_preset.py +230 -0
  371. setiastro/saspro/wcs_update.py +374 -0
  372. setiastro/saspro/whitebalance.py +540 -0
  373. setiastro/saspro/widgets/__init__.py +48 -0
  374. setiastro/saspro/widgets/common_utilities.py +306 -0
  375. setiastro/saspro/widgets/graphics_views.py +122 -0
  376. setiastro/saspro/widgets/image_utils.py +518 -0
  377. setiastro/saspro/widgets/minigame/game.js +991 -0
  378. setiastro/saspro/widgets/minigame/index.html +53 -0
  379. setiastro/saspro/widgets/minigame/style.css +241 -0
  380. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  381. setiastro/saspro/widgets/resource_monitor.py +313 -0
  382. setiastro/saspro/widgets/spinboxes.py +290 -0
  383. setiastro/saspro/widgets/themed_buttons.py +13 -0
  384. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  385. setiastro/saspro/wimi.py +7367 -0
  386. setiastro/saspro/wims.py +588 -0
  387. setiastro/saspro/window_shelf.py +185 -0
  388. setiastro/saspro/xisf.py +1213 -0
  389. setiastrosuitepro-1.6.7.dist-info/METADATA +279 -0
  390. setiastrosuitepro-1.6.7.dist-info/RECORD +394 -0
  391. setiastrosuitepro-1.6.7.dist-info/WHEEL +4 -0
  392. setiastrosuitepro-1.6.7.dist-info/entry_points.txt +6 -0
  393. setiastrosuitepro-1.6.7.dist-info/licenses/LICENSE +674 -0
  394. setiastrosuitepro-1.6.7.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,2258 @@
1
+ # ExoPlanet Detector (SASpro) — standalone plate solving, no WIMI
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ import time
9
+ from typing import List, Tuple, Set
10
+ import webbrowser
11
+ import multiprocessing
12
+ from concurrent.futures import ThreadPoolExecutor, as_completed
13
+ from typing import Optional
14
+ from urllib.parse import quote
15
+ from types import SimpleNamespace
16
+ import math
17
+ import numpy as np
18
+ import pandas as pd
19
+ import sep
20
+ import pyqtgraph as pg
21
+ import matplotlib.pyplot as plt
22
+ from types import SimpleNamespace
23
+ from astropy.io import fits
24
+ from astropy.time import Time
25
+ from astropy.stats import sigma_clipped_stats
26
+ from astropy.coordinates import SkyCoord
27
+ import astropy.units as u
28
+ from astropy.wcs import WCS
29
+ from astropy.timeseries import LombScargle, BoxLeastSquares
30
+ import re
31
+
32
+ from astroquery.simbad import Simbad
33
+ from astroquery.vizier import Vizier
34
+
35
+ from astroquery.mast import Tesscut
36
+
37
+ from lightkurve import TessTargetPixelFile
38
+
39
+ import lightkurve as lk
40
+
41
+ # ---- project-local imports (adjust paths if needed) --------------------
42
+ from setiastro.saspro.legacy.numba_utils import bin2x2_numba, apply_flat_division_numba
43
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
44
+
45
+ from setiastro.saspro.plate_solver import plate_solve_doc_inplace
46
+ from setiastro.saspro.star_alignment import (
47
+ StarRegistrationWorker,
48
+ StarRegistrationThread,
49
+ IDENTITY_2x3,
50
+ )
51
+ from setiastro.saspro.legacy.image_manager import load_image, save_image, get_valid_header # adjust if different
52
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
53
+
54
+ # ------------------------------------------------------------------------
55
+ from setiastro.saspro.xisf import XISF
56
+ from PyQt6.QtCore import Qt, QTimer, QSettings, QRectF, QPoint, QPointF
57
+ from PyQt6.QtGui import QIcon, QColor, QBrush, QPen, QPainter, QImage, QPixmap
58
+ from PyQt6.QtWidgets import (
59
+ QAbstractItemView, QButtonGroup, QComboBox, QDialog, QDialogButtonBox, QApplication, QGraphicsView, QGraphicsPixmapItem,
60
+ QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QListWidget, QGraphicsView, QGraphicsScene, QGraphicsEllipseItem,
61
+ QListWidgetItem, QMessageBox, QPushButton, QProgressBar, QRadioButton, QSpinBox, QDoubleSpinBox,
62
+ QSlider, QToolButton, QVBoxLayout, QInputDialog, QLineEdit
63
+ )
64
+ import pyqtgraph as pg
65
+
66
+ import warnings
67
+ from astropy.utils.exceptions import AstropyWarning
68
+ warnings.filterwarnings("ignore", category=AstropyWarning, message=".*more axes.*")
69
+
70
+ def _extract_ra_dec_from_header(h: fits.Header):
71
+ """Return (ra_deg, dec_deg) if found, else (None, None)."""
72
+ if not isinstance(h, fits.Header):
73
+ return None, None
74
+
75
+ # 1) If WCS is already present, use its center
76
+ try:
77
+ w = WCS(h)
78
+ if w.has_celestial:
79
+ # center of the current pixel grid
80
+ nx = h.get("NAXIS1"); ny = h.get("NAXIS2")
81
+ if nx and ny:
82
+ sky = w.pixel_to_world(nx/2, ny/2)
83
+ return float(sky.ra.deg), float(sky.dec.deg)
84
+ except Exception:
85
+ pass
86
+
87
+ # 2) Common RA/DEC keyword pairs
88
+ pairs = [
89
+ ("OBJCTRA", "OBJCTDEC"), # PixInsight/ASCOM style (strings)
90
+ ("RA", "DEC"),
91
+ ("TELRA", "TELDEC"),
92
+ ("RA_OBJ", "DEC_OBJ"),
93
+ ("CAT-RA", "CAT-DEC"),
94
+ ("RA_DEG", "DEC_DEG"), # degrees already
95
+ ]
96
+
97
+ for rak, deck in pairs:
98
+ if rak in h and deck in h:
99
+ ra_raw, dec_raw = h[rak], h[deck]
100
+
101
+ # Try a few parse paths
102
+ for parser in (
103
+ lambda r,d: SkyCoord(r, d, unit=(u.hourangle, u.deg)),
104
+ lambda r,d: SkyCoord(float(r)*u.deg, float(d)*u.deg),
105
+ lambda r,d: SkyCoord(r, d, unit=(u.deg, u.deg)),
106
+ ):
107
+ try:
108
+ c = parser(ra_raw, dec_raw)
109
+ return float(c.ra.deg), float(c.dec.deg)
110
+ except Exception:
111
+ pass
112
+
113
+ return None, None
114
+
115
+
116
+ def _estimate_scale_arcsec_per_pix(h: fits.Header):
117
+ """Return pixel scale (arcsec/pix) if derivable, else None."""
118
+ if not isinstance(h, fits.Header):
119
+ return None
120
+
121
+ # Direct scale keywords (various conventions)
122
+ for k in ("PIXSCALE", "PIXSCL", "SECPIX", "SECPIX1"):
123
+ if k in h:
124
+ try:
125
+ val = float(h[k])
126
+ if val > 0:
127
+ return val
128
+ except Exception:
129
+ pass
130
+
131
+ # Derive from pixel size & focal length:
132
+ # scale["/pix] ≈ 206.265 * pixel_size_μm / focal_length_mm
133
+ px_um = None
134
+ for k in ("XPIXSZ", "PIXSIZE1", "PIXSIZE"): # μm
135
+ if k in h:
136
+ try:
137
+ px_um = float(h[k])
138
+ break
139
+ except Exception:
140
+ pass
141
+
142
+ foc_mm = None
143
+ for k in ("FOCALLEN", "FOCLEN", "FOCALLENGTH"):
144
+ if k in h:
145
+ try:
146
+ foc_mm = float(h[k])
147
+ break
148
+ except Exception:
149
+ pass
150
+
151
+ if px_um and foc_mm and foc_mm > 0:
152
+ return 206.265 * px_um / foc_mm
153
+
154
+ return None
155
+
156
+ _TZ_RE = re.compile(r'([+-])(\d{2})(\d{2})$') # -0700 -> -07:00
157
+
158
+ def _fix_iso_tz(s: str) -> str:
159
+ s = s.strip()
160
+ m = _TZ_RE.search(s)
161
+ if m:
162
+ s = s[:m.start()] + f"{m.group(1)}{m.group(2)}:{m.group(3)}"
163
+ return s
164
+
165
+ def _parse_obs_time_from_header(hdr) -> Time | None:
166
+ # hdr can be fits.Header or your dict-ish header
167
+ def _get(key):
168
+ try:
169
+ return hdr.get(key)
170
+ except Exception:
171
+ return None
172
+
173
+ # 1) Prefer UT-OBS if present (already “UTC-ish”)
174
+ for key in ("UT-OBS", "DATE-OBS", "DATE-END"):
175
+ v = _get(key)
176
+ if isinstance(v, str) and v.strip():
177
+ try:
178
+ return Time(_fix_iso_tz(v), format="isot", scale="utc")
179
+ except Exception:
180
+ pass
181
+
182
+ # 2) MJD-OBS is super reliable
183
+ v = _get("MJD-OBS")
184
+ if v is not None:
185
+ try:
186
+ return Time(float(v), format="mjd", scale="utc")
187
+ except Exception:
188
+ pass
189
+
190
+ return None
191
+
192
+ def _estimate_fov_deg(img_shape, scale_arcsec):
193
+ """Rough FOV (deg) from image size and scale (max of X/Y)."""
194
+ try:
195
+ h, w = img_shape[:2]
196
+ if scale_arcsec and h and w:
197
+ fov_x = (w * scale_arcsec) / 3600.0
198
+ fov_y = (h * scale_arcsec) / 3600.0
199
+ return float(max(fov_x, fov_y))
200
+ except Exception:
201
+ pass
202
+ return None
203
+
204
+
205
+ def _build_astrometry_hints(hdr: fits.Header, plane: np.ndarray):
206
+ """Compose a hints dict for the solver."""
207
+ ra_deg, dec_deg = _extract_ra_dec_from_header(hdr)
208
+ scale = _estimate_scale_arcsec_per_pix(hdr)
209
+ fov = _estimate_fov_deg(plane.shape, scale)
210
+
211
+ # A generous search radius: ≥1°, or 3×FOV if we have it
212
+ radius = None
213
+ if fov is not None:
214
+ radius = max(1.0, 3.0 * fov)
215
+
216
+ hints = {}
217
+ if ra_deg is not None and dec_deg is not None:
218
+ hints["ra_deg"] = ra_deg
219
+ hints["dec_deg"] = dec_deg
220
+ if scale is not None:
221
+ hints["pixel_scale_arcsec"] = scale
222
+ if fov is not None:
223
+ hints["fov_deg"] = fov
224
+ if radius is not None:
225
+ hints["search_radius_deg"] = radius
226
+
227
+ # Optional: parity if you know you’re mirrored or not (None = let solver decide)
228
+ # hints["parity"] = +1 # or -1
229
+
230
+ return hints
231
+
232
+ class OverlayView(QGraphicsView):
233
+ def __init__(self, parent=None):
234
+ super().__init__(parent)
235
+ # disable built-in hand drag
236
+ self.setDragMode(QGraphicsView.DragMode.NoDrag)
237
+ # always arrow cursor
238
+ self.viewport().setCursor(Qt.CursorShape.ArrowCursor)
239
+ self._panning = False
240
+ self._last_pos = QPoint()
241
+
242
+ def mousePressEvent(self, event):
243
+ if event.button() == Qt.MouseButton.LeftButton:
244
+ scene_pt = self.mapToScene(event.pos())
245
+ # if we clicked an ellipse, let it handle the event
246
+ for it in self.scene().items(scene_pt):
247
+ if isinstance(it, ClickableEllipseItem):
248
+ super().mousePressEvent(event)
249
+ return
250
+ # else: start panning
251
+ self._panning = True
252
+ self._last_pos = event.pos()
253
+ event.accept()
254
+ return
255
+ super().mousePressEvent(event)
256
+
257
+ def mouseMoveEvent(self, event):
258
+ if self._panning:
259
+ delta = event.pos() - self._last_pos
260
+ self._last_pos = event.pos()
261
+ self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - delta.x())
262
+ self.verticalScrollBar().setValue(self.verticalScrollBar().value() - delta.y())
263
+ event.accept()
264
+ return
265
+ super().mouseMoveEvent(event)
266
+
267
+ def mouseReleaseEvent(self, event):
268
+ if event.button() == Qt.MouseButton.LeftButton and self._panning:
269
+ self._panning = False
270
+ event.accept()
271
+ return
272
+ super().mouseReleaseEvent(event)
273
+
274
+ class ClickableEllipseItem(QGraphicsEllipseItem):
275
+ def __init__(self, rect: QRectF, index: int, callback):
276
+ super().__init__(rect)
277
+ self.index = index
278
+ self.callback = callback
279
+ self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
280
+ self.setAcceptHoverEvents(True)
281
+
282
+ def mousePressEvent(self, ev):
283
+ if ev.button() == Qt.MouseButton.LeftButton:
284
+ shift = bool(ev.modifiers() & Qt.KeyboardModifier.ShiftModifier)
285
+ self.callback(self.index, shift)
286
+ super().mousePressEvent(ev)
287
+
288
+
289
+ class ReferenceOverlayDialog(QDialog):
290
+ def __init__(self, plane: np.ndarray, positions: List[Tuple], target_median: float, parent=None):
291
+ super().__init__(parent)
292
+ self.setWindowTitle(self.tr("Reference Frame: Stars Overlay"))
293
+ self.plane = plane.astype(np.float32)
294
+ self.positions = positions
295
+ self.target_median = target_median
296
+ self.autostretch = True
297
+
298
+ # pens for normal vs selected
299
+ self._normal_pen = QPen(QColor('lightblue'), 3) # for normal state
300
+ self._dip_pen = QPen(QColor('yellow'), 3) # flagged by threshold
301
+ self._selected_pen = QPen(QColor('red'), 4) # when selected
302
+
303
+ # store ellipses here
304
+ self.ellipse_items: dict[int, ClickableEllipseItem] = {}
305
+ self.flagged_stars: Set[int] = set() # updated by apply_threshold
306
+
307
+ self._build_ui()
308
+ self._init_graphics()
309
+
310
+ # wire up list‐clicks in the parent to recolor
311
+ if parent and hasattr(parent, 'star_list'):
312
+ parent.star_list.itemSelectionChanged.connect(self._update_highlights)
313
+
314
+ # after show, reset zoom so 1px == 1screen-px
315
+ QTimer.singleShot(0, self._fit_to_100pct)
316
+
317
+ def _build_ui(self):
318
+ self.view = OverlayView(self)
319
+ self.view.setRenderHints(
320
+ QPainter.RenderHint.Antialiasing |
321
+ QPainter.RenderHint.SmoothPixmapTransform
322
+ )
323
+ self.scene = QGraphicsScene(self)
324
+ self.view.setScene(self.scene)
325
+
326
+ btns = QHBoxLayout()
327
+ for txt, slot in [
328
+ ("Zoom In", lambda: self.view.scale(1.2, 1.2)),
329
+ ("Zoom Out", lambda: self.view.scale(1/1.2, 1/1.2)),
330
+ ("Reset Zoom", self._fit_to_100pct),
331
+ ("Fit to Window", self._fit_to_window),
332
+ ("Toggle Stretch", self._toggle_autostretch),
333
+ ]:
334
+ b = QPushButton(txt)
335
+ b.clicked.connect(slot)
336
+ btns.addWidget(b)
337
+ btns.addStretch()
338
+
339
+ lay = QVBoxLayout(self)
340
+ lay.addWidget(self.view)
341
+ lay.addLayout(btns)
342
+ self.resize(800, 600)
343
+
344
+ def _init_graphics(self):
345
+ # draw the image...
346
+ img = self.plane if not self.autostretch else stretch_mono_image(self.plane, target_median=0.3)
347
+ arr8 = (np.clip(img,0,1) * 255).astype(np.uint8)
348
+ h, w = img.shape
349
+ qimg = QImage(arr8.data, w, h, w, QImage.Format.Format_Grayscale8)
350
+ pix = QPixmap.fromImage(qimg)
351
+
352
+ self.scene.clear()
353
+ self.ellipse_items.clear()
354
+ self.scene.addItem(QGraphicsPixmapItem(pix))
355
+
356
+ # add one ellipse per star
357
+ radius = max(2, int(math.ceil(1.2*self.target_median)))
358
+ for idx, (x, y) in enumerate(self.positions):
359
+ r = QRectF(x-radius, y-radius, 2*radius, 2*radius)
360
+ ell = ClickableEllipseItem(r, idx, self._on_star_clicked)
361
+ ell.setPen(self._normal_pen)
362
+ ell.setBrush(QBrush(Qt.BrushStyle.NoBrush))
363
+ self.scene.addItem(ell)
364
+ self.ellipse_items[idx] = ell
365
+
366
+ def _fit_to_100pct(self):
367
+ self.view.resetTransform()
368
+ rect = self.scene.itemsBoundingRect()
369
+ self.view.setSceneRect(rect)
370
+ # scroll so that scene center ends up in the view’s center
371
+ self.view.centerOn(rect.center())
372
+
373
+ def _fit_to_window(self):
374
+ rect = self.scene.itemsBoundingRect()
375
+ self.view.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)
376
+
377
+ def _toggle_autostretch(self):
378
+ self.autostretch = not self.autostretch
379
+ self._init_graphics()
380
+
381
+ def _on_star_clicked(self, index: int, shift: bool):
382
+ """Star‐circle was clicked; update list selection then recolor."""
383
+ parent = self.parent()
384
+ if not parent or not hasattr(parent, 'star_list'):
385
+ return
386
+
387
+ lst = parent.star_list
388
+ item = lst.item(index)
389
+ if not item:
390
+ return
391
+
392
+ if shift:
393
+ item.setSelected(not item.isSelected())
394
+ else:
395
+ lst.clearSelection()
396
+ item.setSelected(True)
397
+
398
+ lst.scrollToItem(item)
399
+ self._update_highlights()
400
+
401
+ def _update_highlights(self):
402
+ """Recolor all ellipses according to star_list selection and dip flags."""
403
+ parent = self.parent()
404
+ if not parent or not hasattr(parent, 'star_list'):
405
+ return
406
+
407
+ sel = {item.data(Qt.ItemDataRole.UserRole)
408
+ for item in parent.star_list.selectedItems()}
409
+
410
+ for idx, ell in self.ellipse_items.items():
411
+ if idx in sel:
412
+ ell.setPen(self._selected_pen)
413
+ elif idx in self.flagged_stars:
414
+ ell.setPen(self._dip_pen)
415
+ else:
416
+ ell.setPen(self._normal_pen)
417
+
418
+ def update_dip_flags(self, flagged_indices: Set[int]):
419
+ """Update the visual color of stars flagged by threshold dips."""
420
+ self.flagged_stars = flagged_indices
421
+ self._update_highlights()
422
+
423
+
424
+ class ExoPlanetWindow(QDialog):
425
+ def __init__(self, parent=None, wrench_path=None):
426
+ super().__init__(parent)
427
+ self.setWindowTitle(self.tr("Exoplanet Transit Detector"))
428
+
429
+ self.resize(900, 600)
430
+ self.wrench_path = wrench_path
431
+ # State
432
+ self.image_paths = []
433
+ self._cached_images = []
434
+ self._cached_headers = [] # parallel to _cached_images
435
+ self.times = None # astropy Time array
436
+ self.star_positions = []
437
+ self.fluxes = None # stars × frames
438
+ self.flags = None
439
+ self.median_fwhm = None
440
+ self.master_dark = None
441
+ self.master_flat = None
442
+ self.exposure_time = None
443
+ self._last_ensemble = []
444
+ self.ensemble_map = {}
445
+
446
+ # --- new settings ---
447
+ self.sep_threshold = 5.0 # SEP σ
448
+ self.border_fraction = 0.10 # ignore border fraction
449
+ self.ensemble_k = 10 # ensemble companions
450
+
451
+ # Analysis
452
+ self.ls_min_frequency = 0.01
453
+ self.ls_max_frequency = 10.0
454
+ self.ls_samples_per_peak = 10
455
+ self.bls_min_period = 0.05
456
+ self.bls_max_period = 2.0
457
+ self.bls_n_periods = 1000
458
+ self.bls_duration_min_frac= 0.01
459
+ self.bls_duration_max_frac= 0.5
460
+ self.bls_n_durations = 20
461
+
462
+ # WCS (standalone; no WIMI)
463
+ self._wcs = None
464
+ self.wcs_ra = None
465
+ self.wcs_dec= None
466
+
467
+ # — Mode selector —
468
+ mode_layout = QHBoxLayout()
469
+ mode_layout.addWidget(QLabel(self.tr("Mode:")))
470
+ self.aligned_mode_rb = QRadioButton(self.tr("Aligned Subs"))
471
+ self.raw_mode_rb = QRadioButton(self.tr("Raw Subs"))
472
+ self.aligned_mode_rb.setChecked(True)
473
+ mg = QButtonGroup(self)
474
+ mg.addButton(self.aligned_mode_rb); mg.addButton(self.raw_mode_rb)
475
+ mg.buttonToggled.connect(self.on_mode_changed)
476
+ mode_layout.addWidget(self.aligned_mode_rb)
477
+ mode_layout.addWidget(self.raw_mode_rb)
478
+ mode_layout.addStretch()
479
+ self.wrench_button = QToolButton()
480
+ self.wrench_button.setIcon(QIcon(self.wrench_path))
481
+ self.wrench_button.setToolTip("Settings…")
482
+ self.wrench_button.setStyleSheet("""
483
+ QToolButton {
484
+ background-color: #FF4500;
485
+ color: white;
486
+ padding: 4px;
487
+ border-radius: 4px;
488
+ }
489
+ QToolButton:hover {
490
+ background-color: #FF6347;
491
+ }
492
+ """)
493
+ self.wrench_button.clicked.connect(self.open_settings)
494
+ mode_layout.addWidget(self.wrench_button)
495
+
496
+ # — Calibration controls (hidden in Aligned) —
497
+ cal_layout = QHBoxLayout()
498
+ self.load_darks_btn = QPushButton(self.tr("Load Master Dark…"))
499
+ self.load_flats_btn = QPushButton(self.tr("Load Master Flat…"))
500
+ for w in (self.load_darks_btn, self.load_flats_btn):
501
+ w.clicked.connect(self.load_masters)
502
+ w.hide()
503
+ cal_layout.addWidget(w)
504
+ self.dark_status_label = QLabel("Dark: ❌"); self.dark_status_label.hide()
505
+ self.flat_status_label = QLabel("Flat: ❌"); self.flat_status_label.hide()
506
+ cal_layout.addWidget(self.dark_status_label)
507
+ cal_layout.addWidget(self.flat_status_label)
508
+ cal_layout.addStretch()
509
+
510
+ # — Status & Progress —
511
+ self.status_label = QLabel("Ready")
512
+ self.progress_bar = QProgressBar()
513
+ self.progress_bar.setVisible(False)
514
+
515
+ # — Top controls —
516
+ top_layout = QHBoxLayout()
517
+ self.load_raw_btn = QPushButton(self.tr("1: Load Raw Subs…"))
518
+ self.load_aligned_btn = QPushButton(self.tr("Load, Measure && Photometry…"))
519
+ self.calibrate_btn = QPushButton(self.tr("1a: Calibrate && Align Subs"))
520
+ self.measure_btn = QPushButton(self.tr("2: Measure && Photometry"))
521
+ self.load_raw_btn. clicked.connect(self.load_raw_subs)
522
+ self.load_aligned_btn.clicked.connect(self.load_and_measure_subs)
523
+ self.calibrate_btn.clicked.connect(self.calibrate_and_align)
524
+ self.measure_btn. clicked.connect(self.detect_stars)
525
+ self.detrend_combo = QComboBox()
526
+ self.detrend_combo.addItems(["No Detrend", "Linear", "Quadratic"])
527
+ self.save_aligned_btn = QPushButton("Save Aligned Frames…")
528
+ self.save_aligned_btn.clicked.connect(self.save_aligned_frames)
529
+
530
+ top_layout.addWidget(self.load_raw_btn)
531
+ top_layout.addWidget(self.load_aligned_btn)
532
+ top_layout.addWidget(self.calibrate_btn)
533
+ top_layout.addWidget(self.measure_btn)
534
+ top_layout.addStretch()
535
+ top_layout.addWidget(QLabel("Detrend:"))
536
+ top_layout.addWidget(self.detrend_combo)
537
+ top_layout.addWidget(self.save_aligned_btn)
538
+
539
+ # — Star list & Plot —
540
+ middle = QHBoxLayout()
541
+ self.star_list = QListWidget()
542
+ self.star_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
543
+ self.star_list.itemSelectionChanged.connect(self.update_plot_for_selection)
544
+ self.star_list.setStyleSheet("""
545
+ QListWidget::item:selected {
546
+ background: #3399ff;
547
+ color: white;
548
+ }
549
+ """)
550
+ middle.addWidget(self.star_list, 2)
551
+ self.plot_widget = pg.PlotWidget(title="Light Curves")
552
+ self.plot_widget.addLegend()
553
+ middle.addWidget(self.plot_widget, 5)
554
+
555
+ # — Bottom rows —
556
+ row1 = QHBoxLayout()
557
+ row1.addWidget(QLabel("Dip threshold (ppt):"))
558
+ self.threshold_slider = QSlider(Qt.Orientation.Horizontal)
559
+ self.threshold_slider.setRange(0, 100)
560
+ self.threshold_slider.setValue(20)
561
+ row1.addWidget(self.threshold_slider)
562
+ self.threshold_value_label = QLabel(f"{self.threshold_slider.value()} ppt")
563
+ row1.addWidget(self.threshold_value_label)
564
+ row1.addStretch()
565
+ self.identify_btn = QPushButton("Identify Star…")
566
+ self.identify_btn.clicked.connect(self.on_identify_star)
567
+ row1.addWidget(self.identify_btn)
568
+ self.show_ensemble_btn = QPushButton("Show Ensemble Members")
569
+ self.show_ensemble_btn.clicked.connect(self.show_ensemble_members)
570
+ row1.addWidget(self.show_ensemble_btn)
571
+ self.analyze_btn = QPushButton("Analyze Star…")
572
+ self.analyze_btn.clicked.connect(self.on_analyze)
573
+ row1.addWidget(self.analyze_btn)
574
+
575
+ row2 = QHBoxLayout()
576
+ self.fetch_tesscut_btn = QPushButton("Query TESScut Light Curve")
577
+ self.fetch_tesscut_btn.setEnabled(False)
578
+ self.fetch_tesscut_btn.clicked.connect(self.query_tesscut)
579
+ row2.addWidget(self.fetch_tesscut_btn)
580
+ self.export_btn = QPushButton("Export CSV/FITS")
581
+ self.export_btn.clicked.connect(self.export_data)
582
+ row2.addWidget(self.export_btn)
583
+ self.export_aavso_btn = QPushButton("Export → AAVSO")
584
+ self.export_aavso_btn.clicked.connect(self.export_to_aavso)
585
+ row2.addWidget(self.export_aavso_btn)
586
+
587
+ # — Assemble —
588
+ main = QVBoxLayout(self)
589
+ main.addLayout(mode_layout)
590
+ main.addLayout(cal_layout)
591
+ main.addLayout(top_layout)
592
+ main.addLayout(middle)
593
+ main.addLayout(row1)
594
+ main.addLayout(row2)
595
+ statlay = QHBoxLayout()
596
+ statlay.addWidget(self.status_label)
597
+ statlay.addWidget(self.progress_bar)
598
+ main.addLayout(statlay)
599
+
600
+ # init
601
+ self.on_mode_changed(self.aligned_mode_rb, True)
602
+ self.detrend_combo.setCurrentIndex(2)
603
+ self.on_detrend_changed(2)
604
+ self.threshold_slider.valueChanged.connect(self._on_threshold_changed)
605
+ self.analyze_btn.setEnabled(False)
606
+ self.calibrate_btn.hide()
607
+
608
+ # ---------------- UI wiring ----------------
609
+
610
+ def _on_threshold_changed(self, v: int):
611
+ self.threshold_value_label.setText(f"{v} ppt")
612
+ self.apply_threshold(v)
613
+ if hasattr(self, '_ref_overlay'):
614
+ self._ref_overlay._update_highlights()
615
+
616
+ def open_settings(self):
617
+ dlg = QDialog(self)
618
+ dlg.setWindowTitle("Photometry & Analysis Settings")
619
+ layout = QVBoxLayout(dlg)
620
+
621
+ photo_box = QGroupBox("Photometry")
622
+ fb = QFormLayout(photo_box)
623
+ self.sep_spin = QDoubleSpinBox(); self.sep_spin.setRange(1.0, 20.0); self.sep_spin.setSingleStep(0.5); self.sep_spin.setValue(self.sep_threshold)
624
+ fb.addRow("SEP detection σ:", self.sep_spin)
625
+ self.border_spin = QDoubleSpinBox(); self.border_spin.setRange(0.0, 0.5); self.border_spin.setSingleStep(0.01); self.border_spin.setValue(self.border_fraction)
626
+ fb.addRow("Border fraction:", self.border_spin)
627
+ layout.addWidget(photo_box)
628
+
629
+ ens_box = QGroupBox("Ensemble Normalization")
630
+ ef = QFormLayout(ens_box)
631
+ self.ensemble_spin = QSpinBox(); self.ensemble_spin.setRange(1, 50); self.ensemble_spin.setValue(self.ensemble_k)
632
+ ef.addRow("Comparison stars (k):", self.ensemble_spin)
633
+ layout.addWidget(ens_box)
634
+
635
+ ana_box = QGroupBox("Analysis (period search)")
636
+ form = QFormLayout(ana_box)
637
+ self.ls_samp_spin = QSpinBox(); self.ls_samp_spin.setRange(1, 100); self.ls_samp_spin.setValue(self.ls_samples_per_peak)
638
+ form.addRow("LS samples / peak:", self.ls_samp_spin)
639
+ self.bls_min_spin = QDoubleSpinBox(); self.bls_min_spin.setRange(0.01, 10.0); self.bls_min_spin.setValue(self.bls_min_period)
640
+ form.addRow("BLS min period [d]:", self.bls_min_spin)
641
+ self.bls_max_spin = QDoubleSpinBox(); self.bls_max_spin.setRange(0.01, 10.0); self.bls_max_spin.setValue(self.bls_max_period)
642
+ form.addRow("BLS max period [d]:", self.bls_max_spin)
643
+ self.bls_nper_spin = QSpinBox(); self.bls_nper_spin.setRange(10, 20000); self.bls_nper_spin.setValue(self.bls_n_periods)
644
+ form.addRow("BLS # periods:", self.bls_nper_spin)
645
+ self.bls_min_frac_spin = QDoubleSpinBox(); self.bls_min_frac_spin.setRange(0.0001, 1.0); self.bls_min_frac_spin.setSingleStep(0.001); self.bls_min_frac_spin.setValue(self.bls_duration_min_frac)
646
+ form.addRow("BLS min dur frac:", self.bls_min_frac_spin)
647
+ self.bls_max_frac_spin = QDoubleSpinBox(); self.bls_max_frac_spin.setRange(0.01, 1.0); self.bls_max_frac_spin.setSingleStep(0.01); self.bls_max_frac_spin.setValue(self.bls_duration_max_frac)
648
+ form.addRow("BLS max dur frac:", self.bls_max_frac_spin)
649
+ self.bls_ndur_spin = QSpinBox(); self.bls_ndur_spin.setRange(1, 200); self.bls_ndur_spin.setValue(self.bls_n_durations)
650
+ form.addRow("BLS # durations:", self.bls_ndur_spin)
651
+ layout.addWidget(ana_box)
652
+
653
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
654
+ btns.accepted.connect(dlg.accept)
655
+ btns.rejected.connect(dlg.reject)
656
+ layout.addWidget(btns)
657
+
658
+ if dlg.exec() == QDialog.DialogCode.Accepted:
659
+ self.sep_threshold = self.sep_spin.value()
660
+ self.border_fraction = self.border_spin.value()
661
+ self.ensemble_k = self.ensemble_spin.value()
662
+ self.ls_samples_per_peak = self.ls_samp_spin.value()
663
+ self.bls_min_period = self.bls_min_spin.value()
664
+ self.bls_max_period = self.bls_max_spin.value()
665
+ self.bls_n_periods = self.bls_nper_spin.value()
666
+ self.bls_duration_min_frac = self.bls_min_frac_spin.value()
667
+ self.bls_duration_max_frac = self.bls_max_frac_spin.value()
668
+ self.bls_n_durations = self.bls_ndur_spin.value()
669
+
670
+ def on_mode_changed(self, button, checked):
671
+ is_raw = checked and (button is self.raw_mode_rb)
672
+ for w in (
673
+ self.load_raw_btn,
674
+ self.load_darks_btn,
675
+ self.load_flats_btn,
676
+ self.dark_status_label,
677
+ self.flat_status_label,
678
+ self.calibrate_btn,
679
+ ):
680
+ w.setVisible(is_raw)
681
+ self.load_aligned_btn.setVisible(not is_raw)
682
+ self.measure_btn.setVisible(is_raw)
683
+
684
+ def load_and_measure_subs(self):
685
+ before = len(getattr(self, "image_paths", []))
686
+ self.load_aligned_subs()
687
+ after = len(getattr(self, "image_paths", []))
688
+ if after == 0 or after == before and not self._cached_images:
689
+ return
690
+ self.detect_stars()
691
+ # --------------- I/O + Calibration ----------------
692
+
693
+ def load_raw_subs(self):
694
+ settings = QSettings()
695
+ start_dir = settings.value("ExoPlanet/lastRawFolder", os.path.expanduser("~"), type=str)
696
+ paths, _ = QFileDialog.getOpenFileNames(self, "Select Raw Frames", start_dir, "FITS, TIFF or XISF (*.fit *.fits *.tif *.tiff *.xisf)")
697
+ if not paths: return
698
+ settings.setValue("ExoPlanet/lastRawFolder", os.path.dirname(paths[0]))
699
+
700
+ self.status_label.setText("Reading headers…")
701
+ self.progress_bar.setVisible(True)
702
+ self.progress_bar.setMaximum(len(paths))
703
+ self.progress_bar.setValue(0)
704
+ QApplication.processEvents()
705
+
706
+ datelist = []
707
+ for i, p in enumerate(paths, start=1):
708
+ ext = os.path.splitext(p)[1].lower()
709
+ ds = None
710
+ if ext == '.xisf':
711
+ try:
712
+ xisf = XISF(p)
713
+ img_meta = xisf.get_images_metadata()[0].get('FITSKeywords', {})
714
+ if 'DATE-OBS' in img_meta:
715
+ ds = img_meta['DATE-OBS'][0]['value']
716
+ except:
717
+ ds = None
718
+ elif ext in ('.fit', '.fits', '.fz'):
719
+ try:
720
+ hdr0, _ = get_valid_header(p)
721
+ ds = hdr0.get('DATE-OBS')
722
+ except:
723
+ ds = None
724
+
725
+ # Use robust header time parsing (UT-OBS -> DATE-OBS -> MJD-OBS fallback)
726
+ t = None
727
+ try:
728
+ if ext == ".xisf":
729
+ # Build a tiny dict-like header for the helper
730
+ hdr_like = {}
731
+ try:
732
+ xisf = XISF(p)
733
+ img_meta = xisf.get_images_metadata()[0]
734
+ kw = img_meta.get("FITSKeywords", {}) or {}
735
+ # XISF FITSKeywords layout: key -> [ {value: ...}, ... ]
736
+ for k in ("UT-OBS", "DATE-OBS", "DATE-END", "MJD-OBS"):
737
+ if k in kw and kw[k]:
738
+ hdr_like[k] = kw[k][0].get("value")
739
+ except Exception:
740
+ hdr_like = {}
741
+ t = _parse_obs_time_from_header(hdr_like)
742
+
743
+ elif ext in (".fit", ".fits", ".fz"):
744
+ hdr0, _ = get_valid_header(p)
745
+ t = _parse_obs_time_from_header(hdr0)
746
+
747
+ else:
748
+ # TIFF etc. may not have FITS-like time headers; leave None
749
+ t = None
750
+
751
+ except Exception as e:
752
+ print(f"[DEBUG] Failed to parse obs time for {p}: {e}")
753
+
754
+ datelist.append((p, t))
755
+
756
+ self.progress_bar.setValue(i)
757
+ QApplication.processEvents()
758
+
759
+ datelist.sort(key=lambda x: (x[1] is None, x[1] or x[0]))
760
+ sorted_paths = [p for p, _ in datelist]
761
+
762
+ self.image_paths = sorted_paths
763
+ self._cached_images = []
764
+ self._cached_headers = []
765
+ self.airmasses = []
766
+ self.star_list.clear()
767
+ self.plot_widget.clear()
768
+
769
+ self.status_label.setText("Loading raw frames…")
770
+ self.progress_bar.setMaximum(len(sorted_paths))
771
+ self.progress_bar.setValue(0)
772
+ QApplication.processEvents()
773
+
774
+ for i, p in enumerate(sorted_paths, start=1):
775
+ self.status_label.setText(f"Loading raw frame {i}/{len(sorted_paths)}…")
776
+ QApplication.processEvents()
777
+ img, hdr, bit_depth, is_mono = load_image(p)
778
+ if img is None:
779
+ QMessageBox.warning(self, "Load Error", f"Failed to load raw frame:\n{os.path.basename(p)}")
780
+ self._cached_images.append(None)
781
+ self._cached_headers.append(None)
782
+ am = 1.0
783
+ else:
784
+ img_binned = bin2x2_numba(img)
785
+ self._cached_images.append(img_binned)
786
+ self._cached_headers.append(hdr)
787
+
788
+ if self.exposure_time is None:
789
+ if isinstance(hdr, fits.Header):
790
+ self.exposure_time = hdr.get('EXPOSURE', hdr.get('EXPTIME', None))
791
+ elif isinstance(hdr, dict):
792
+ img_meta = hdr.get('image_meta', {}) or {}
793
+ fits_kw = img_meta.get('FITSKeywords', {})
794
+ val = None
795
+ if 'EXPOSURE' in fits_kw: val = fits_kw['EXPOSURE'][0].get('value')
796
+ elif 'EXPTIME' in fits_kw: val = fits_kw['EXPTIME'][0].get('value')
797
+ try:
798
+ self.exposure_time = float(val)
799
+ except:
800
+ print(f"[DEBUG] Could not parse exposure_time={val!r}")
801
+
802
+ am = None
803
+ if isinstance(hdr, fits.Header):
804
+ if 'AIRMASS' in hdr:
805
+ try: am = float(hdr['AIRMASS'])
806
+ except: am = None
807
+ if am is None:
808
+ alt = (hdr.get('OBJCTALT') or hdr.get('ALT') or hdr.get('ALTITUDE') or hdr.get('EL'))
809
+ try: am = self.estimate_airmass_from_altitude(float(alt))
810
+ except: am = 1.0
811
+ elif isinstance(hdr, dict):
812
+ img_meta = hdr.get('image_meta', {}) or {}
813
+ fits_kw = img_meta.get('FITSKeywords', {})
814
+ if 'AIRMASS' in fits_kw:
815
+ try: am = float(fits_kw['AIRMASS'][0]['value'])
816
+ except: am = None
817
+ if am is None:
818
+ for key in ('OBJCTALT','ALT','ALTITUDE','EL'):
819
+ ent = fits_kw.get(key)
820
+ if ent:
821
+ try:
822
+ am = self.estimate_airmass_from_altitude(float(ent[0]['value']))
823
+ break
824
+ except Exception:
825
+ pass # Ignore airmass estimation errors
826
+ else:
827
+ am = 1.0
828
+ if am is None:
829
+ am = 1.0
830
+
831
+ self.airmasses.append(am)
832
+ self.progress_bar.setValue(i)
833
+ QApplication.processEvents()
834
+
835
+ # Keep full timestamps (DO NOT truncate to date-only)
836
+ tlist = [t for _, t in datelist if t is not None]
837
+ if tlist:
838
+ self.times = Time(tlist) # already utc scale from helper
839
+ else:
840
+ self.times = None
841
+
842
+ self.progress_bar.setVisible(False)
843
+ loaded = sum(1 for im in self._cached_images if im is not None)
844
+ self.status_label.setText(f"Loaded {loaded}/{len(sorted_paths)} raw frames")
845
+
846
+ def load_aligned_subs(self) -> bool:
847
+ settings = QSettings()
848
+ start_dir = settings.value("ExoPlanet/lastAlignedFolder", os.path.expanduser("~"), type=str)
849
+ paths, _ = QFileDialog.getOpenFileNames(
850
+ self, "Select Aligned Frames", start_dir,
851
+ "FITS or TIFF (*.fit *.fits *.tif *.tiff *.xisf)"
852
+ )
853
+ if not paths:
854
+ self.status_label.setText("Load canceled.")
855
+ return False
856
+
857
+ settings.setValue("ExoPlanet/lastAlignedFolder", os.path.dirname(paths[0]))
858
+
859
+ self.status_label.setText("Reading metadata from aligned frames…")
860
+ self.progress_bar.setVisible(True)
861
+ self.progress_bar.setMaximum(len(paths))
862
+ self.progress_bar.setValue(0)
863
+ QApplication.processEvents()
864
+
865
+ datelist = []
866
+ for i, p in enumerate(paths, start=1):
867
+ ext = os.path.splitext(p)[1].lower(); ds = None
868
+ if ext == '.xisf':
869
+ try:
870
+ xisf = XISF(p)
871
+ img_meta = xisf.get_images_metadata()[0]
872
+ kw = img_meta.get('FITSKeywords', {})
873
+ if 'DATE-OBS' in kw: ds = kw['DATE-OBS'][0]['value']
874
+ except: ds = None
875
+ elif ext in ('.fit', '.fits', '.fz'):
876
+ try:
877
+ hdr0, _ = get_valid_header(p)
878
+ ds = hdr0.get('DATE-OBS')
879
+ except: ds = None
880
+ # Use robust header time parsing (UT-OBS -> DATE-OBS -> MJD-OBS fallback)
881
+ t = None
882
+ try:
883
+ if ext == ".xisf":
884
+ # Build a tiny dict-like header for the helper
885
+ hdr_like = {}
886
+ try:
887
+ xisf = XISF(p)
888
+ img_meta = xisf.get_images_metadata()[0]
889
+ kw = img_meta.get("FITSKeywords", {}) or {}
890
+ # XISF FITSKeywords layout: key -> [ {value: ...}, ... ]
891
+ for k in ("UT-OBS", "DATE-OBS", "DATE-END", "MJD-OBS"):
892
+ if k in kw and kw[k]:
893
+ hdr_like[k] = kw[k][0].get("value")
894
+ except Exception:
895
+ hdr_like = {}
896
+ t = _parse_obs_time_from_header(hdr_like)
897
+
898
+ elif ext in (".fit", ".fits", ".fz"):
899
+ hdr0, _ = get_valid_header(p)
900
+ t = _parse_obs_time_from_header(hdr0)
901
+
902
+ else:
903
+ # TIFF etc. may not have FITS-like time headers; leave None
904
+ t = None
905
+
906
+ except Exception as e:
907
+ print(f"[DEBUG] Failed to parse obs time for {p}: {e}")
908
+
909
+ datelist.append((p, t))
910
+ self.progress_bar.setValue(i)
911
+ QApplication.processEvents()
912
+
913
+ datelist.sort(key=lambda x: (x[1] is None, x[1] or x[0]))
914
+ sorted_paths = [p for p, _ in datelist]
915
+
916
+ self.image_paths = sorted_paths
917
+ self._cached_images = []
918
+ self._cached_headers = []
919
+ self.airmasses = []
920
+
921
+ self.status_label.setText("Loading aligned frames…")
922
+ self.progress_bar.setMaximum(len(sorted_paths))
923
+ self.progress_bar.setValue(0)
924
+ QApplication.processEvents()
925
+
926
+ for i, p in enumerate(sorted_paths, start=1):
927
+ self.status_label.setText(f"Loading frame {i}/{len(sorted_paths)}…")
928
+ QApplication.processEvents()
929
+ img, hdr, bit_depth, is_mono = load_image(p)
930
+ if img is None:
931
+ QMessageBox.warning(self, "Load Error", f"Failed to load aligned frame:\n{os.path.basename(p)}")
932
+ self._cached_images.append(None)
933
+ self._cached_headers.append(None)
934
+ am = 1.0
935
+ else:
936
+ img_binned = bin2x2_numba(img)
937
+ self._cached_images.append(img_binned)
938
+ self._cached_headers.append(hdr)
939
+
940
+ if self.exposure_time is None:
941
+ if isinstance(hdr, fits.Header):
942
+ self.exposure_time = hdr.get('EXPOSURE', hdr.get('EXPTIME', None))
943
+ elif isinstance(hdr, dict):
944
+ img_meta = hdr.get('image_meta', {}) or {}
945
+ fits_kw = img_meta.get('FITSKeywords', {})
946
+ val = None
947
+ if 'EXPOSURE' in fits_kw: val = fits_kw['EXPOSURE'][0].get('value')
948
+ elif 'EXPTIME' in fits_kw: val = fits_kw['EXPTIME'][0].get('value')
949
+ try: self.exposure_time = float(val)
950
+ except: print(f"[DEBUG] Could not parse exposure_time={val!r}")
951
+
952
+ am = None
953
+ if isinstance(hdr, fits.Header):
954
+ if 'AIRMASS' in hdr:
955
+ try: am = float(hdr['AIRMASS'])
956
+ except: am = None
957
+ if am is None:
958
+ alt = (hdr.get('OBJCTALT') or hdr.get('ALT') or hdr.get('ALTITUDE') or hdr.get('EL'))
959
+ try: am = self.estimate_airmass_from_altitude(float(alt))
960
+ except: am = 1.0
961
+ elif isinstance(hdr, dict):
962
+ img_meta = hdr.get('image_meta', {}) or {}
963
+ fits_kw = img_meta.get('FITSKeywords', {})
964
+ if 'AIRMASS' in fits_kw:
965
+ try: am = float(fits_kw['AIRMASS'][0]['value'])
966
+ except: am = None
967
+ if am is None:
968
+ for key in ('OBJCTALT','ALT','ALTITUDE','EL'):
969
+ ent = fits_kw.get(key)
970
+ if ent:
971
+ try:
972
+ am = self.estimate_airmass_from_altitude(float(ent[0]['value']))
973
+ break
974
+ except Exception:
975
+ pass # Ignore airmass estimation errors
976
+ else:
977
+ am = 1.0
978
+ else:
979
+ am = 1.0
980
+
981
+ self.airmasses.append(am)
982
+ self.progress_bar.setValue(i)
983
+ QApplication.processEvents()
984
+
985
+ # Keep full timestamps (DO NOT truncate to date-only)
986
+ tlist = [t for _, t in datelist if t is not None]
987
+ if tlist:
988
+ self.times = Time(tlist) # already utc scale from helper
989
+ else:
990
+ self.times = None
991
+
992
+ self.progress_bar.setVisible(False)
993
+ loaded = sum(1 for im in self._cached_images if im is not None)
994
+ self.status_label.setText(f"Loaded {loaded}/{len(sorted_paths)} aligned frames")
995
+ return loaded > 0
996
+
997
+ def load_masters(self):
998
+ settings = QSettings()
999
+ last_master_dir = settings.value("ExoPlanet/lastMasterFolder", os.path.expanduser("~"), type=str)
1000
+ sender = self.sender()
1001
+ dlg = QFileDialog(self, "Select Master File", last_master_dir, "FITS, TIFF or XISF (*.fit *.fits *.tif *.tiff *.xisf)")
1002
+ dlg.setFileMode(QFileDialog.FileMode.ExistingFile)
1003
+ if not dlg.exec(): return
1004
+ path = dlg.selectedFiles()[0]
1005
+ settings.setValue("ExoPlanet/lastMasterFolder", os.path.dirname(path))
1006
+
1007
+ img, hdr, bit_depth, is_mono = load_image(path)
1008
+ if img is None:
1009
+ QMessageBox.warning(self, "Load Error", f"Failed to load master file:\n{path}")
1010
+ return
1011
+
1012
+ img = img.astype(np.float32)
1013
+ binned = bin2x2_numba(img)
1014
+
1015
+ if "Dark" in sender.text():
1016
+ if self.master_flat is not None and not self._shapes_compatible(binned, self.master_flat):
1017
+ QMessageBox.warning(self, "Shape Mismatch", "This master dark (binned) doesn’t match your existing flat.")
1018
+ return
1019
+ self.master_dark = binned
1020
+ self.dark_status_label.setText("Dark: ✅")
1021
+ self.dark_status_label.setStyleSheet("color: #00cc66; font-weight: bold;")
1022
+ else:
1023
+ if self.master_dark is not None and not self._shapes_compatible(self.master_dark, binned):
1024
+ QMessageBox.warning(self, "Shape Mismatch", "This master flat (binned) doesn’t match your existing dark.")
1025
+ return
1026
+ self.master_flat = binned
1027
+ self.flat_status_label.setText("Flat: ✅")
1028
+ self.flat_status_label.setStyleSheet("color: #00cc66; font-weight: bold;")
1029
+
1030
+ def _shapes_compatible(self, master: np.ndarray, other: np.ndarray) -> bool:
1031
+ if master.shape == other.shape:
1032
+ return True
1033
+ if master.ndim == 2 and other.ndim == 3 and other.shape[:2] == master.shape:
1034
+ return True
1035
+ if other.ndim == 2 and master.ndim == 3 and master.shape[:2] == other.shape:
1036
+ return True
1037
+ return False
1038
+
1039
+ def calibrate_and_align(self):
1040
+ if not self._cached_images:
1041
+ QMessageBox.warning(self, "Calibrate", "Load raw subs first.")
1042
+ return
1043
+ self.status_label.setText("Calibrating & aligning frames…")
1044
+ self.progress_bar.setVisible(True)
1045
+ n = len(self._cached_images)
1046
+ self.progress_bar.setMaximum(n)
1047
+
1048
+ reference_image_2d = None
1049
+ for i, (img, hdr) in enumerate(zip(self._cached_images, self._cached_headers), start=1):
1050
+ if self.master_dark is not None:
1051
+ img = img.astype(np.float32) - self.master_dark
1052
+ if self.master_flat is not None:
1053
+ img = apply_flat_division_numba(img, self.master_flat)
1054
+ if img.ndim == 2:
1055
+ img = np.stack([img, img, img], axis=2)
1056
+
1057
+ plane = img if img.ndim == 2 else img.mean(axis=2)
1058
+
1059
+ if reference_image_2d is None:
1060
+ reference_image_2d = plane.copy()
1061
+
1062
+ delta = StarRegistrationWorker.compute_affine_transform_astroalign(plane, reference_image_2d)
1063
+ if delta is None:
1064
+ delta = IDENTITY_2x3
1065
+
1066
+ img_aligned = StarRegistrationThread.apply_affine_transform_static(img, delta)
1067
+ self._cached_images[i-1] = img_aligned
1068
+ self.progress_bar.setValue(i)
1069
+ QApplication.processEvents()
1070
+
1071
+ self.progress_bar.setVisible(False)
1072
+ self.status_label.setText("Calibration & alignment complete")
1073
+
1074
+ def save_aligned_frames(self):
1075
+ if not self._cached_images:
1076
+ QMessageBox.warning(self, "Save Aligned Frames", "No images to save. Run Calibrate & Align first.")
1077
+ return
1078
+ out_dir = QFileDialog.getExistingDirectory(self, "Choose Output Folder")
1079
+ if not out_dir:
1080
+ return
1081
+ for i, orig_path in enumerate(self.image_paths):
1082
+ img = self._cached_images[i]
1083
+ ext = os.path.splitext(orig_path)[1].lstrip(".").lower()
1084
+ fmt = ext if ext in ("fits","fit","tiff","tif","xisf","png","jpg","jpeg") else "fits"
1085
+ hdr = self._cached_headers[i] if hasattr(self, "_cached_headers") and i < len(self._cached_headers) else None
1086
+ base = os.path.splitext(os.path.basename(orig_path))[0]
1087
+ out_name = f"{base}_aligned.{fmt}"
1088
+ out_path = os.path.join(out_dir, out_name)
1089
+ save_image(
1090
+ img_array=img, filename=out_path, original_format=fmt, bit_depth=None,
1091
+ original_header=hdr, is_mono=(img.ndim==2), image_meta=None, file_meta=None
1092
+ )
1093
+ QMessageBox.information(self, "Save Complete", f"Saved {len(self._cached_images)} aligned frames to:\n{out_dir}")
1094
+
1095
+ # --------------- Detection + Photometry ----------------
1096
+ def _seed_header_for_astap(self, ref_idx: int) -> fits.Header | None:
1097
+ """
1098
+ Build a *real* FITS header to seed ASTAP, harvested from the reference
1099
+ frame's original header. We preserve RA/Dec (OBJCTRA/OBJCTDEC or CRVAL1/2),
1100
+ size (NAXIS*), basic camera hints (PIXSZ, BINNING, FOCALLEN) if present.
1101
+ """
1102
+ if not (0 <= ref_idx < len(self._cached_headers)):
1103
+ return None
1104
+
1105
+ src = self._cached_headers[ref_idx]
1106
+ H = fits.Header()
1107
+
1108
+ # try to copy directly if it's already a FITS Header
1109
+ if isinstance(src, fits.Header):
1110
+ H = src.copy()
1111
+ elif isinstance(src, dict):
1112
+ # Could be a nested XISF-like dict. Look for FITSKeywords first.
1113
+ kw = None
1114
+ if "image_meta" in src and isinstance(src["image_meta"], dict):
1115
+ kw = src["image_meta"].get("FITSKeywords", None)
1116
+ if kw is None:
1117
+ kw = src.get("FITSKeywords", None)
1118
+ if isinstance(kw, dict):
1119
+ for k, v in kw.items():
1120
+ try:
1121
+ # XISF stores [{'value':...}], FITS-like dicts store raw
1122
+ if isinstance(v, list):
1123
+ vv = v[0].get("value", None)
1124
+ else:
1125
+ vv = v
1126
+ if vv is not None:
1127
+ H[k] = vv
1128
+ except Exception:
1129
+ pass
1130
+ else:
1131
+ # best-effort: flat dict of scalars
1132
+ for k, v in src.items():
1133
+ try:
1134
+ H[k] = v
1135
+ except Exception:
1136
+ pass
1137
+
1138
+ # be sure image size is present (ASTAP likes NAXIS1/2)
1139
+ if self._cached_images and self._cached_images[ref_idx] is not None:
1140
+ img = self._cached_images[ref_idx]
1141
+ h, w = (img.shape if img.ndim == 2 else img.shape[:2])
1142
+ H.setdefault("NAXIS", 2)
1143
+ H["NAXIS1"] = w
1144
+ H["NAXIS2"] = h
1145
+
1146
+ # If we only have OBJCTRA/OBJCTDEC strings, just leave them — your
1147
+ # _build_astap_seed() handles these. If we *also* have CRVAL*, keep them.
1148
+ # Do NOT inject WCS—we want ASTAP to solve, not be constrained by stale WCS.
1149
+ # (_solve_numpy_with_astap will strip WCS keys before writing temp FITS)
1150
+
1151
+ # optional: exposure time
1152
+ if self.exposure_time is not None:
1153
+ H.setdefault("EXPTIME", float(self.exposure_time))
1154
+
1155
+ return H if len(H) else None
1156
+
1157
+ def _coerce_seed_header(self, hdr_in, plane) -> fits.Header:
1158
+ """
1159
+ Make a real fits.Header usable by _build_astap_seed():
1160
+ - copy fields from FITS, XISF-like dicts, or flat dicts
1161
+ - ensure NAXIS/NAXIS1/NAXIS2
1162
+ - try to expose RA/Dec in a form the seeder can use
1163
+ (OBJCTRA/OBJCTDEC strings and/or CRVAL1/CRVAL2 degrees).
1164
+ """
1165
+ H = fits.Header()
1166
+
1167
+ # 1) copy what we can
1168
+ if isinstance(hdr_in, fits.Header):
1169
+ H = hdr_in.copy()
1170
+ elif isinstance(hdr_in, dict):
1171
+ # XISF-style nested?
1172
+ kw = None
1173
+ if "image_meta" in hdr_in and isinstance(hdr_in["image_meta"], dict):
1174
+ kw = hdr_in["image_meta"].get("FITSKeywords")
1175
+ if kw is None:
1176
+ kw = hdr_in.get("FITSKeywords")
1177
+ if isinstance(kw, dict):
1178
+ for k, v in kw.items():
1179
+ try:
1180
+ vv = v[0]["value"] if isinstance(v, list) else v
1181
+ if vv is not None:
1182
+ H[k] = vv
1183
+ except Exception:
1184
+ pass
1185
+ else:
1186
+ # flat dict of scalars
1187
+ for k, v in hdr_in.items():
1188
+ try:
1189
+ H[k] = v
1190
+ except Exception:
1191
+ pass
1192
+
1193
+ # 2) ensure image size
1194
+ h, w = (plane.shape if plane.ndim == 2 else plane.shape[:2])
1195
+ H.setdefault("NAXIS", 2)
1196
+ H["NAXIS1"] = int(w)
1197
+ H["NAXIS2"] = int(h)
1198
+
1199
+ # 3) try to normalize RA/Dec
1200
+ # If OBJCTRA/OBJCTDEC are present as strings, keep them.
1201
+ # Otherwise, if we find numeric RA/DEC/CRVAL*, ensure CRVAL1/2 are set.
1202
+ def _try_deg(val):
1203
+ try:
1204
+ return float(val)
1205
+ except Exception:
1206
+ return None
1207
+
1208
+ ra_deg = None
1209
+ dec_deg = None
1210
+
1211
+ # prefer CRVAL1/2 if they seem finite
1212
+ ra_deg = _try_deg(H.get("CRVAL1"))
1213
+ dec_deg = _try_deg(H.get("CRVAL2"))
1214
+
1215
+ if ra_deg is None or dec_deg is None:
1216
+ # common alternates
1217
+ for rakey in ("RA", "OBJCTRA", "OBJRA"):
1218
+ if rakey in H:
1219
+ try:
1220
+ # could be sexagesimal string
1221
+ ra_deg = SkyCoord(H[rakey], H.get("OBJCTDEC", None) or H.get("DEC", None), unit=(u.hourangle, u.deg)).ra.deg
1222
+ dec_deg = SkyCoord(H[rakey], H.get("OBJCTDEC", None) or H.get("DEC", None), unit=(u.hourangle, u.deg)).dec.deg
1223
+ break
1224
+ except Exception:
1225
+ pass
1226
+ if ra_deg is None and "RA" in H and "DEC" in H:
1227
+ ra_deg = _try_deg(H["RA"])
1228
+ dec_deg = _try_deg(H["DEC"])
1229
+
1230
+ # Set CRVAL* if we have clean degrees
1231
+ if ra_deg is not None and dec_deg is not None:
1232
+ H["CRVAL1"] = float(ra_deg)
1233
+ H["CRVAL2"] = float(dec_deg)
1234
+
1235
+ # Also supply OBJCTRA/OBJCTDEC sexagesimal (helps some ASTAP setups)
1236
+ try:
1237
+ c = SkyCoord(ra_deg*u.deg, dec_deg*u.deg, frame="icrs")
1238
+ H.setdefault("OBJCTRA", c.ra.to_string(unit=u.hour, sep=":", precision=2, pad=True))
1239
+ H.setdefault("OBJCTDEC", c.dec.to_string(unit=u.deg, sep=":", precision=1, pad=True, alwayssign=True))
1240
+ except Exception:
1241
+ pass
1242
+
1243
+ # Optional: if you have pixel size / focal length / binning in the original
1244
+ # header, leaving them in place is good; _build_astap_seed will use them.
1245
+
1246
+ return H
1247
+
1248
+
1249
+ def detect_stars(self):
1250
+ self.status_label.setText("Measuring frames…")
1251
+ self.progress_bar.setVisible(True)
1252
+ self.progress_bar.setMaximum(len(self.image_paths))
1253
+ self.progress_bar.setValue(0)
1254
+
1255
+ # 0) ensure frames are cached
1256
+ if not hasattr(self, "_cached_images") or len(self._cached_images) != len(self.image_paths):
1257
+ self._cached_images = [load_image(p)[0] for p in self.image_paths]
1258
+
1259
+ n_frames = len(self._cached_images)
1260
+ self.progress_bar.setMaximum(n_frames)
1261
+ self.progress_bar.setValue(0)
1262
+
1263
+ # --- PASS 1: per-frame background & SEP stats (parallel) ---
1264
+ def _process_frame(idx, img):
1265
+ plane = img.mean(axis=2) if img.ndim == 3 else img
1266
+ mean, med, std = sigma_clipped_stats(plane)
1267
+ zeroed = plane - med
1268
+ bkg = sep.Background(zeroed)
1269
+ bkgmap = bkg.back()
1270
+ rmsmap = bkg.rms()
1271
+ data_sub = zeroed - bkgmap
1272
+
1273
+ # keep arrays tight for SEP
1274
+ data_sub = np.ascontiguousarray(data_sub.astype(np.float32, copy=False))
1275
+ rmsmap = np.ascontiguousarray(rmsmap.astype(np.float32, copy=False))
1276
+
1277
+ try:
1278
+ objs = sep.extract(
1279
+ data_sub, thresh=self.sep_threshold, err=rmsmap,
1280
+ minarea=16, deblend_nthresh=32, clean=True
1281
+ )
1282
+ except Exception:
1283
+ objs = None
1284
+
1285
+ if objs is None or len(objs) == 0:
1286
+ sc = 0; avg_fwhm = 0.0; avg_ecc = 0.0
1287
+ else:
1288
+ sc = len(objs)
1289
+ a = np.clip(objs['a'], 1e-3, None)
1290
+ b = np.clip(objs['b'], 1e-3, None)
1291
+ fwhm_vals = 2.3548 * np.sqrt(a * b)
1292
+ ecc_vals = np.sqrt(1.0 - np.clip(b / a, 0, 1)**2)
1293
+ avg_fwhm = float(np.nanmean(fwhm_vals))
1294
+ avg_ecc = float(np.nanmean(ecc_vals))
1295
+
1296
+ stats = {"star_count": sc, "eccentricity": avg_ecc,
1297
+ "mean": float(np.mean(plane)), "fwhm": avg_fwhm}
1298
+ return idx, data_sub, objs, rmsmap, stats
1299
+
1300
+ cpu_cnt = multiprocessing.cpu_count()
1301
+ n_workers = max(1, int(cpu_cnt * 0.8))
1302
+
1303
+ frame_data = {}
1304
+ stats_map = {}
1305
+ with ThreadPoolExecutor(max_workers=n_workers) as exe:
1306
+ futures = [exe.submit(_process_frame, idx, img)
1307
+ for idx, img in enumerate(self._cached_images)]
1308
+ done = 0
1309
+ for fut in as_completed(futures):
1310
+ idx, data_sub, objs, rmsmap, stats = fut.result()
1311
+ frame_data[idx] = (data_sub, objs, rmsmap)
1312
+ stats_map[idx] = stats
1313
+ done += 1
1314
+ self.progress_bar.setValue(done)
1315
+ self.status_label.setText(f"Measured frame {done}/{n_frames}")
1316
+
1317
+ # pick best reference
1318
+ def quality(i):
1319
+ s = stats_map[i]
1320
+ return s["star_count"] / (s["fwhm"] * s["mean"] + 1e-8)
1321
+ ref_idx = max(stats_map.keys(), key=quality)
1322
+ ref_stats = stats_map[ref_idx]
1323
+
1324
+ # --- Solve WCS on reference (unchanged) ---
1325
+ self.ref_idx = ref_idx
1326
+ plane = self._cached_images[ref_idx]
1327
+ hdr = self._cached_headers[ref_idx]
1328
+ self._solve_reference(plane, hdr)
1329
+
1330
+ # --- SEP catalog on reference ---
1331
+ data_ref, objs_ref, rms_ref = frame_data[ref_idx]
1332
+ if objs_ref is None or len(objs_ref) == 0:
1333
+ QMessageBox.warning(self, "No Stars", "No stars found in reference frame.")
1334
+ self.progress_bar.setVisible(False)
1335
+ return
1336
+
1337
+ xs = objs_ref['x']; ys = objs_ref['y']
1338
+ h, w = data_ref.shape
1339
+ bf = self.border_fraction
1340
+ keep_border = ((xs > w*bf) & (xs < w*(1-bf)) & (ys > h*bf) & (ys < h*(1-bf)))
1341
+ xs = np.ascontiguousarray(xs[keep_border].astype(np.float32, copy=False))
1342
+ ys = np.ascontiguousarray(ys[keep_border].astype(np.float32, copy=False))
1343
+
1344
+ self.median_fwhm = ref_stats["fwhm"]
1345
+ aper_r = float(max(2.5, 1.5 * self.median_fwhm))
1346
+
1347
+ # --- PASS 2: aperture sums on all frames (parallel, reusing PASS-1 background) ---
1348
+ n_stars = len(xs)
1349
+ n_frames = len(self._cached_images)
1350
+ raw_flux = np.empty((n_stars, n_frames), dtype=np.float32)
1351
+ raw_flux_err = np.empty((n_stars, n_frames), dtype=np.float32)
1352
+ flags = np.zeros((n_stars, n_frames), dtype=np.int16)
1353
+
1354
+ self.status_label.setText("Computing aperture sums…")
1355
+ self.progress_bar.setMaximum(n_frames)
1356
+ self.progress_bar.setValue(0)
1357
+
1358
+ def _sum_frame(t: int):
1359
+ data_sub, _objs, rmsmap = frame_data[t]
1360
+ # soft floor: clamp extreme negatives from over-subtraction
1361
+ ds = np.maximum(data_sub, -1.0 * rmsmap)
1362
+ fl, ferr, flg = sep.sum_circle(ds, xs, ys, aper_r, err=rmsmap)
1363
+ return t, fl.astype(np.float32, copy=False), ferr.astype(np.float32, copy=False), flg
1364
+
1365
+ done = 0
1366
+ with ThreadPoolExecutor(max_workers=n_workers) as exe:
1367
+ for t, fl, ferr, flg in exe.map(_sum_frame, range(n_frames)):
1368
+ raw_flux[:, t] = fl
1369
+ raw_flux_err[:, t] = ferr
1370
+ flags[:, t] = flg
1371
+ done += 1
1372
+ if (done % 4) == 0 or done == n_frames:
1373
+ self.progress_bar.setValue(done)
1374
+
1375
+ # --- ENSEMBLE NORMALIZATION (safe masks + unit-median renorm) ---
1376
+ n_stars, n_frames = raw_flux.shape
1377
+ star_refs = np.nanmedian(raw_flux, axis=1)
1378
+ rel_flux = np.full_like(raw_flux, np.nan, dtype=np.float32)
1379
+ rel_err = np.full_like(raw_flux_err, np.nan, dtype=np.float32)
1380
+
1381
+ k = int(self.ensemble_k)
1382
+ k = max(1, min(k, max(1, n_stars - 1))) # keep in range
1383
+ self.ensemble_map = {}
1384
+
1385
+ for i in range(n_stars):
1386
+ diffs = np.abs(star_refs - star_refs[i])
1387
+ diffs[i] = np.inf
1388
+ neigh = np.argpartition(diffs, k)[:k]
1389
+ self.ensemble_map[i] = list(np.asarray(neigh, dtype=int))
1390
+
1391
+ ens_flux = np.nanmedian(raw_flux[neigh, :], axis=0)
1392
+ ens_err = np.sqrt(np.nansum(raw_flux_err[neigh, :]**2, axis=0)) / np.sqrt(len(neigh))
1393
+
1394
+ mask = (raw_flux[i] > 0) & (ens_flux > 0) & np.isfinite(raw_flux[i]) & np.isfinite(ens_flux)
1395
+ if not np.any(mask):
1396
+ continue
1397
+
1398
+ rel_flux[i, mask] = raw_flux[i, mask] / ens_flux[mask]
1399
+ with np.errstate(divide='ignore', invalid='ignore'):
1400
+ term1 = raw_flux_err[i, mask] / raw_flux[i, mask]
1401
+ term2 = ens_err[mask] / ens_flux[mask]
1402
+ rel_err[i, mask] = rel_flux[i, mask] * np.sqrt(term1**2 + term2**2)
1403
+
1404
+ # unit-median renorm so curves are centered ~1.0
1405
+ meds = np.nanmedian(rel_flux, axis=1)
1406
+ good = (meds > 0) & np.isfinite(meds)
1407
+ rel_flux[good] /= meds[good, None]
1408
+ rel_err[good] /= meds[good, None]
1409
+
1410
+ self.fluxes = rel_flux
1411
+ self.flux_errors = rel_err
1412
+ self.flags = flags
1413
+
1414
+ # --- detrend (then re-center) ---
1415
+ if self.detrend_degree is not None:
1416
+ n_stars = rel_flux.shape[0]
1417
+ self.status_label.setText("Detrending curves…")
1418
+ self.progress_bar.setVisible(True)
1419
+ self.progress_bar.setMaximum(n_stars)
1420
+ self.progress_bar.setValue(0)
1421
+ for i in range(n_stars):
1422
+ curve = rel_flux[i].copy()
1423
+ goodm = np.isfinite(curve) & (curve > 0)
1424
+ rel_flux[i] = self._detrend_curve(curve, self.detrend_degree, mask=goodm)
1425
+ self.progress_bar.setValue(i+1)
1426
+ self.progress_bar.setVisible(False)
1427
+ self.status_label.setText("Detrending complete")
1428
+
1429
+ meds = np.nanmedian(rel_flux, axis=1)
1430
+ good = (meds > 0) & np.isfinite(meds)
1431
+ rel_flux[good] /= meds[good, None]
1432
+
1433
+ # --- robust per-star outlier flagging ---
1434
+ for i in range(n_stars):
1435
+ curve = rel_flux[i, :]
1436
+ med_i = np.nanmedian(curve)
1437
+ mad_i = np.nanmedian(np.abs(curve - med_i))
1438
+ sigma_i = 1.4826 * mad_i if mad_i > 0 else np.nanstd(curve)
1439
+ if sigma_i > 0:
1440
+ outlier_mask = np.abs(curve - med_i) > 2 * sigma_i
1441
+ flags[i, outlier_mask] = 1
1442
+
1443
+ # --- drop stars with too many flagged frames ---
1444
+ good_counts = np.sum(flags == 0, axis=1)
1445
+ keep = good_counts >= (0.75 * n_frames)
1446
+ xs, ys = xs[keep], ys[keep]
1447
+ rel_flux = rel_flux[keep, :]
1448
+ flags = flags[keep, :]
1449
+
1450
+ self.star_positions = list(zip(xs, ys))
1451
+ self.fluxes = rel_flux.copy()
1452
+ self.flags = flags
1453
+
1454
+ # list uses median rel flux, not the first frame
1455
+ self.star_list.clear()
1456
+ for i, (x, y) in enumerate(self.star_positions):
1457
+ fmed = np.nanmedian(rel_flux[i])
1458
+ ftxt = f"{fmed:.3f}" if np.isfinite(fmed) else "na"
1459
+ item = QListWidgetItem(
1460
+ f"#{i}: x={x:.1f}, y={y:.1f} RelFlux≈{ftxt} FWHM={self.median_fwhm:.2f}"
1461
+ )
1462
+ item.setData(Qt.ItemDataRole.UserRole, i)
1463
+ self.star_list.addItem(item)
1464
+
1465
+ # overlay & finish
1466
+ self._show_reference_with_circles(data_ref, self.star_positions)
1467
+ self.status_label.setText("Ready")
1468
+ self.progress_bar.setVisible(False)
1469
+ self.analyze_btn.setEnabled(True)
1470
+
1471
+ # refresh dip-highlights using MA-based thresholding
1472
+ self._on_threshold_changed(self.threshold_slider.value())
1473
+
1474
+ def _solve_reference(self, plane, hdr):
1475
+ """
1476
+ Standalone plate-solve via pro.plate_solver.plate_solve_doc_inplace.
1477
+ We pass a *seed FITS header* in doc.metadata["original_header"], because
1478
+ plate_solve_doc_inplace -> _solve_numpy_with_astap() pulls the seed from there.
1479
+ """
1480
+ # 1) coerce header for seeding
1481
+ seed_hdr = self._coerce_seed_header(hdr if hdr is not None else {}, plane)
1482
+
1483
+ # 2) create a minimal "doc" the solver expects
1484
+ doc = SimpleNamespace(
1485
+ image=plane,
1486
+ metadata={"original_header": seed_hdr}, # <-- THIS is what your solver reads
1487
+ )
1488
+
1489
+ # 3) call the in-place solver (no extra kwargs; it ignores them)
1490
+ settings = getattr(self.parent(), "settings", None)
1491
+ from setiastro.saspro.plate_solver import plate_solve_doc_inplace
1492
+ ok, res = plate_solve_doc_inplace(self, doc, settings)
1493
+ if not ok:
1494
+ QMessageBox.critical(self, "Plate Solve", f"Plate solving failed:\n{res}")
1495
+ self._wcs = None
1496
+ self.fetch_tesscut_btn.setEnabled(False)
1497
+ return
1498
+
1499
+ # 4) grab solved WCS from metadata (plate_solve_doc_inplace stores it)
1500
+ self._wcs = doc.metadata.get("wcs", None)
1501
+ if self._wcs is None or not getattr(self._wcs, "has_celestial", False):
1502
+ QMessageBox.warning(self, "Plate Solve", "Solver finished but no usable WCS was found.")
1503
+ self.fetch_tesscut_btn.setEnabled(False)
1504
+ self._wcs = None
1505
+ return
1506
+
1507
+ # 5) expose center RA/Dec for UI
1508
+ H, W = plane.shape[:2]
1509
+ try:
1510
+ center = self._wcs.pixel_to_world(W/2, H/2)
1511
+ self.wcs_ra = float(center.ra.deg)
1512
+ self.wcs_dec = float(center.dec.deg)
1513
+ except Exception:
1514
+ self.wcs_ra = self.wcs_dec = None
1515
+
1516
+ ra_str = "nan" if self.wcs_ra is None else f"{self.wcs_ra:.5f}"
1517
+ dec_str = "nan" if self.wcs_dec is None else f"{self.wcs_dec:.5f}"
1518
+ self.status_label.setText(f"WCS solved: RA={ra_str}, Dec={dec_str}")
1519
+ self.fetch_tesscut_btn.setEnabled(True)
1520
+
1521
+
1522
+ # ---------------- Plotting & helpers ----------------
1523
+
1524
+ def show_ensemble_members(self):
1525
+ sels = self.star_list.selectedItems()
1526
+ if len(sels) != 1: return
1527
+ target = sels[0].data(Qt.ItemDataRole.UserRole)
1528
+ members = self.ensemble_map.get(target, [])
1529
+ for idx in self._last_ensemble:
1530
+ item = self.star_list.item(idx)
1531
+ if item:
1532
+ color = item.background().color()
1533
+ if color == QColor('lightblue'):
1534
+ item.setBackground(QBrush())
1535
+ for idx in members:
1536
+ item = self.star_list.item(idx)
1537
+ if item and item.background().color() != QColor('yellow'):
1538
+ item.setBackground(QBrush(QColor('lightblue')))
1539
+ self._last_ensemble = members
1540
+
1541
+ def on_detrend_changed(self, idx: int):
1542
+ # idx==0 → No Detrend, 1 → Linear, 2 → Quadratic
1543
+ mapping = {0: None, 1: 1, 2: 2}
1544
+ self.detrend_degree = mapping[idx]
1545
+ if getattr(self, 'fluxes', None) is not None:
1546
+ self.update_plot_for_selection()
1547
+
1548
+ @staticmethod
1549
+ def _detrend_curve(curve: np.ndarray, deg: int, mask: Optional[np.ndarray] = None) -> np.ndarray:
1550
+ x = np.arange(curve.size)
1551
+ if mask is None:
1552
+ mask = np.isfinite(curve) & (curve > 0)
1553
+ n_good = int(mask.sum())
1554
+ if n_good < 2:
1555
+ return curve
1556
+ fit_deg = min(deg, n_good - 1)
1557
+ if fit_deg < 1:
1558
+ return curve
1559
+ try:
1560
+ coeffs = np.polyfit(x[mask], curve[mask], fit_deg)
1561
+ except Exception:
1562
+ return curve
1563
+ trend = np.polyval(coeffs, x)
1564
+ trend[trend == 0] = 1.0
1565
+ return curve / trend
1566
+
1567
+ def _show_reference_with_circles(self, plane, positions):
1568
+ dlg = ReferenceOverlayDialog(
1569
+ plane=plane,
1570
+ positions=positions,
1571
+ target_median=self.median_fwhm,
1572
+ parent=self
1573
+ )
1574
+ self._ref_overlay = dlg
1575
+ dlg.show()
1576
+
1577
+ def update_plot_for_selection(self):
1578
+ """Redraw light curves for the selected stars."""
1579
+ # 1) sanity
1580
+ if self.fluxes is None:
1581
+ QMessageBox.warning(self, "No Photometry", "Please run photometry before selecting a star.")
1582
+ return
1583
+
1584
+ # 2) X axis: hours since start if we have times, else frame index
1585
+ try:
1586
+ import astropy.units as u
1587
+ x_all = (self.times - self.times[0]).to(u.hour).value
1588
+ bottom_label = "Hours since start"
1589
+ except Exception:
1590
+ x_all = np.arange(self.fluxes.shape[1])
1591
+ bottom_label = "Frame"
1592
+
1593
+ # 3) prep plot
1594
+ self.plot_widget.clear()
1595
+ self.plot_widget.addLegend()
1596
+ self.plot_widget.setLabel('bottom', bottom_label)
1597
+ self.plot_widget.setLabel('left', 'Relative Flux')
1598
+
1599
+ # 4) what to draw?
1600
+ inds = [it.data(Qt.ItemDataRole.UserRole) for it in self.star_list.selectedItems()]
1601
+ if not inds:
1602
+ return
1603
+
1604
+ n_stars = self.fluxes.shape[0]
1605
+ medians = np.nanmedian(self.fluxes, axis=1)
1606
+ max_gap = 1.0 # hours (or frames if no time axis)
1607
+
1608
+ for idx in inds:
1609
+ f = self.fluxes[idx]
1610
+ flags_star = self.flags[idx] if (self.flags is not None and idx < self.flags.shape[0]) else np.zeros_like(f, int)
1611
+
1612
+ mask = np.isfinite(f) & (f > 0) & (flags_star == 0)
1613
+ if mask.sum() < 2:
1614
+ continue
1615
+
1616
+ rel = f[mask] / medians[idx]
1617
+ x = x_all[mask]
1618
+
1619
+ # moving average (window=5)
1620
+ ma = self.moving_average(rel, window=5) if hasattr(self, "moving_average") else np.convolve(np.pad(rel, 2, mode="edge"), np.ones(5)/5, mode="valid")
1621
+
1622
+ # split segments across large gaps
1623
+ dt = np.diff(x)
1624
+ breaks = np.where(dt > max_gap)[0]
1625
+ segments = np.split(np.arange(len(x)), breaks+1)
1626
+
1627
+ color = pg.intColor(idx, hues=n_stars)
1628
+ dull = QColor(color); dull.setAlpha(60)
1629
+ dull_pen = pg.mkPen(color=dull, width=1)
1630
+ dull_brush = pg.mkBrush(color=dull)
1631
+ dash_pen = pg.mkPen(color=color, width=2, style=Qt.PenStyle.DashLine)
1632
+
1633
+ for seg in segments:
1634
+ xs, ys, mas = x[seg], rel[seg], ma[seg]
1635
+ # raw points
1636
+ self.plot_widget.plot(xs, ys, pen=dull_pen, symbol='o', symbolBrush=dull_brush, name=f"Star #{idx}")
1637
+ # moving average
1638
+ self.plot_widget.plot(xs, mas, pen=dash_pen, name="MA (w=5)")
1639
+
1640
+
1641
+ def apply_threshold(self, ppt_threshold: int, sigma_upper: float = 3.0):
1642
+ """
1643
+ Flag stars whose light curve shows dips >= ppt_threshold (parts-per-thousand)
1644
+ BELOW a centered moving average. Upward spikes > sigma_upper are marked as
1645
+ flagged points (not used for dip logic). Uses window=5 MA.
1646
+ """
1647
+ if not hasattr(self, 'fluxes') or self.fluxes is None:
1648
+ return
1649
+
1650
+ rel = self.fluxes # stars × frames (already unit-median)
1651
+ n_stars, n_frames = rel.shape
1652
+
1653
+ # moving averages per star (centered)
1654
+ ma = np.array([self.moving_average(rel[i], window=5) for i in range(n_stars)])
1655
+
1656
+ # robust σ for the UPPER side only, per star
1657
+ diffs = rel - ma
1658
+ pos = diffs.copy()
1659
+ pos[pos < 0] = np.nan
1660
+ sigma_up = 1.4826 * np.nanmedian(
1661
+ np.abs(pos - np.nanmedian(pos, axis=1)[:, None]),
1662
+ axis=1
1663
+ )
1664
+
1665
+ # dips relative to MA (ppt)
1666
+ dips = np.maximum((ma - rel) * 1000.0, 0.0)
1667
+
1668
+ flagged = set()
1669
+ for i in range(n_stars):
1670
+ # (a) dips ≥ threshold (based on MA, not single-point baseline)
1671
+ dip_mask = dips[i] >= ppt_threshold
1672
+
1673
+ # hysteresis: require at least 2 consecutive dip points
1674
+ consec = np.convolve(dip_mask.astype(int), [1, 1], mode='valid') == 2
1675
+ dip_hyst = np.concatenate([[False], consec, [False]])
1676
+ if np.any(dip_hyst):
1677
+ flagged.add(i)
1678
+
1679
+ # (b) mark upward spikes as flagged (so they don't pollute plots/exports)
1680
+ if np.isfinite(sigma_up[i]) and sigma_up[i] > 0:
1681
+ spike_mask = rel[i] > (ma[i] + sigma_upper * sigma_up[i])
1682
+ if np.any(spike_mask):
1683
+ self.flags[i, spike_mask] = 1
1684
+
1685
+ # update overlay(s)
1686
+ for dlg in self.findChildren(ReferenceOverlayDialog):
1687
+ dlg.update_dip_flags(flagged)
1688
+
1689
+ # highlight in the star list (yellow for dips)
1690
+ for row in range(self.star_list.count()):
1691
+ item = self.star_list.item(row)
1692
+ item.setBackground(QBrush())
1693
+ for idx in flagged:
1694
+ item = self.star_list.item(idx)
1695
+ if item:
1696
+ item.setBackground(QBrush(QColor('yellow')))
1697
+
1698
+ self.status_label.setText(f"{len(flagged)} star(s) dip ≥ {ppt_threshold} ppt")
1699
+
1700
+ def moving_average(self, curve, window=5):
1701
+ pad = window//2
1702
+ ext = np.pad(curve, pad, mode="edge")
1703
+ kernel = np.ones(window)/window
1704
+ ma = np.convolve(ext, kernel, mode="valid")
1705
+ return ma
1706
+
1707
+ # ---------------- Analysis + Identify ----------------
1708
+
1709
+ def on_analyze(self):
1710
+ sels = self.star_list.selectedItems()
1711
+ if len(sels) != 1:
1712
+ QMessageBox.information(self, "Analyze", "Please select exactly one star.")
1713
+ return
1714
+ idx = sels[0].data(Qt.ItemDataRole.UserRole)
1715
+
1716
+ t_all = self.times.mjd
1717
+ t_rel = t_all - t_all[0]
1718
+ f_all = self.fluxes[idx]
1719
+ good = np.isfinite(f_all) & (self.flags[idx]==0)
1720
+ t0, f0 = t_rel[good], f_all[good]
1721
+ if len(t0) < 10:
1722
+ QMessageBox.warning(self, "Analyze", "Not enough good points to analyze.")
1723
+ return
1724
+
1725
+ ls = LombScargle(t0, f0)
1726
+ Tspan = np.ptp(t0)
1727
+ dt = np.median(np.diff(np.sort(t0)))
1728
+ min_f = 1.0 / Tspan
1729
+ max_f = 0.5 / dt
1730
+ freq, power_ls = ls.autopower(minimum_frequency=min_f, maximum_frequency=max_f, samples_per_peak=self.ls_samples_per_peak)
1731
+ mask = (freq>0) & np.isfinite(power_ls)
1732
+ freq, power_ls = freq[mask], power_ls[mask]
1733
+ periods = 1.0/freq
1734
+ order = np.argsort(periods)
1735
+ periods, power_ls = periods[order], power_ls[order]
1736
+ best_period = periods[np.argmax(power_ls)]
1737
+
1738
+ bls = BoxLeastSquares(t0 * u.day, f0)
1739
+ per_grid = np.linspace(self.bls_min_period, self.bls_max_period, self.bls_n_periods) * u.day
1740
+ min_p = per_grid.min().value
1741
+ durations = np.linspace(self.bls_duration_min_frac * min_p, self.bls_duration_max_frac * min_p, self.bls_n_durations) * u.day
1742
+ res = bls.power(per_grid, durations)
1743
+ power = res.power
1744
+ flat_idx = np.nanargmax(power)
1745
+ if power.ndim == 2:
1746
+ pi, di = np.unravel_index(flat_idx, power.shape)
1747
+ P_bls = res.period[pi]
1748
+ D_bls = durations[di]
1749
+ T0_bls = res.transit_time[pi, di]
1750
+ else:
1751
+ pi, di = flat_idx, 0
1752
+ P_bls = res.period[pi]
1753
+ D_bls = durations[0]
1754
+ T0_bls = res.transit_time[pi]
1755
+ dur_idx = di
1756
+
1757
+ phase = (((t0*u.day) - T0_bls)/P_bls) % 1
1758
+ phase = phase.value
1759
+ model = bls.model(t0*u.day, P_bls, D_bls, T0_bls)
1760
+
1761
+ dlg = QDialog(self)
1762
+ dlg.setWindowTitle(f"Analysis: Star #{idx}")
1763
+ layout = QVBoxLayout(dlg)
1764
+
1765
+ pg_ls = pg.PlotWidget(title="Lomb–Scargle")
1766
+ pg_ls.plot(1/freq, power_ls, pen='w')
1767
+ pg_ls.addLine(x=best_period, pen=pg.mkPen('y', style=Qt.PenStyle.DashLine))
1768
+ pg_ls.setLabel('bottom','Period [d]')
1769
+ pg_ls.showGrid(True,True)
1770
+ layout.addWidget(pg_ls)
1771
+
1772
+ pg_bls = pg.PlotWidget(title="BLS Periodogram")
1773
+ bls_power = res.power
1774
+ y = bls_power[:, dur_idx] if bls_power.ndim == 2 else bls_power
1775
+ pg_bls.plot(res.period.value, y, pen='w')
1776
+ pg_bls.addLine(x=P_bls.value, pen=pg.mkPen('r', style=Qt.PenStyle.DashLine))
1777
+ pg_bls.setLabel('bottom','Period [d]')
1778
+ pg_bls.showGrid(True,True)
1779
+ layout.addWidget(pg_bls)
1780
+
1781
+ pg_fold = pg.PlotWidget(title=f"Phase‐Folded (P={P_bls.value:.4f} d)")
1782
+ pg_fold.plot(phase, f0, pen=None, symbol='o', symbolBrush='c')
1783
+ ord_idx = np.argsort(phase)
1784
+ pg_fold.plot(phase[ord_idx], model[ord_idx], pen=pg.mkPen('y',width=2))
1785
+ pg_fold.setLabel('bottom','Phase')
1786
+ pg_fold.showGrid(True,True)
1787
+ layout.addWidget(pg_fold)
1788
+
1789
+ dlg.resize(900,600)
1790
+ dlg.exec()
1791
+
1792
+ def on_identify_star(self):
1793
+ radec = self.get_selected_star_radec()
1794
+ if radec is None:
1795
+ QMessageBox.warning(self, "Identify Star", "Please select exactly one star first.")
1796
+ return
1797
+ ra, dec = radec
1798
+ coord = SkyCoord(ra=ra*u.deg, dec=dec*u.deg, frame='icrs')
1799
+
1800
+ custom_simbad = Simbad()
1801
+ custom_simbad.reset_votable_fields()
1802
+ custom_simbad.add_votable_fields("otype")
1803
+ custom_simbad.add_votable_fields("flux(V)")
1804
+
1805
+ result = None
1806
+ for attempt in range(1, 6):
1807
+ try:
1808
+ result = custom_simbad.query_region(coord, radius=5*u.arcsec)
1809
+ break
1810
+ except Exception as e:
1811
+ print(f"[DEBUG] SIMBAD attempt {attempt} failed: {e}")
1812
+ if attempt == 5:
1813
+ QMessageBox.critical(self, "SIMBAD Error", f"Could not reach SIMBAD after 5 tries:\n{e}")
1814
+ return
1815
+ time.sleep(1)
1816
+
1817
+ if result is None or len(result) == 0:
1818
+ QMessageBox.information(self, "No SIMBAD Matches", f"No objects found within 5″ of {ra:.6f}, {dec:.6f}.")
1819
+ return
1820
+
1821
+ row = result[0]
1822
+ id_col = next(c for c in result.colnames if c.lower()=="main_id")
1823
+ ra_col = next(c for c in result.colnames if c.lower()=="ra")
1824
+ dec_col = next(c for c in result.colnames if c.lower()=="dec")
1825
+ otype_col = next((c for c in result.colnames if c.lower()=="otype"), None)
1826
+ flux_col = next((c for c in result.colnames if c.upper()=="V" or c.upper()=="FLUX_V"), None)
1827
+
1828
+ main_id = row[id_col]
1829
+ if isinstance(main_id, bytes):
1830
+ main_id = main_id.decode("utf-8")
1831
+
1832
+ ra_val = float(row[ra_col]); dec_val = float(row[dec_col])
1833
+ match_coord = SkyCoord(ra=ra_val*u.deg, dec=dec_val*u.deg, frame='icrs')
1834
+ offset = coord.separation(match_coord).arcsec
1835
+
1836
+ obj_type = None
1837
+ if otype_col:
1838
+ obj_type = row[otype_col]
1839
+ if isinstance(obj_type, bytes):
1840
+ obj_type = obj_type.decode("utf-8")
1841
+ obj_type = obj_type or "n/a"
1842
+
1843
+ vmag = None
1844
+ if flux_col:
1845
+ raw = row[flux_col]
1846
+ try: vmag = float(raw)
1847
+ except Exception: vmag = None
1848
+ vmag_str = f"{vmag:.3f}" if vmag is not None else "n/a"
1849
+
1850
+ simbad_url = "https://simbad.cds.unistra.fr/simbad/sim-id" f"?Ident={quote(main_id)}"
1851
+ msg = QMessageBox(self)
1852
+ msg.setWindowTitle("SIMBAD Lookup")
1853
+ msg.setText(
1854
+ f"Nearest object:\n"
1855
+ f" ID: {main_id}\n"
1856
+ f" Type: {obj_type}\n"
1857
+ f" V mag: {vmag_str}\n"
1858
+ f" Offset: {offset:.2f}″"
1859
+ )
1860
+ open_btn = msg.addButton("Open in SIMBAD", QMessageBox.ButtonRole.ActionRole)
1861
+ ok_btn = msg.addButton(QMessageBox.StandardButton.Ok)
1862
+ msg.exec()
1863
+ if msg.clickedButton() == open_btn:
1864
+ webbrowser.open(simbad_url)
1865
+
1866
+ def _query_simbad_main_id(self):
1867
+ radec = self.get_selected_star_radec()
1868
+ if radec is None:
1869
+ return None
1870
+ coord = SkyCoord(ra=radec[0]*u.deg, dec=radec[1]*u.deg, frame="icrs")
1871
+ table = None
1872
+ for attempt in range(1, 6):
1873
+ try:
1874
+ custom = Simbad(); custom.reset_votable_fields()
1875
+ custom.add_votable_fields("otype"); custom.add_votable_fields("flux(V)")
1876
+ table = custom.query_region(coord, radius=5*u.arcsec)
1877
+ break
1878
+ except Exception as e:
1879
+ print(f"[DEBUG] SIMBAD lookup attempt {attempt} failed: {e}")
1880
+ if attempt == 5:
1881
+ QMessageBox.critical(self, "SIMBAD Error", f"Could not reach SIMBAD after 5 tries:\n{e}")
1882
+ return None
1883
+ time.sleep(1)
1884
+ if table is None or len(table) == 0:
1885
+ return None
1886
+ try:
1887
+ id_col = next(c for c in table.colnames if c.lower() == "main_id")
1888
+ except StopIteration:
1889
+ return None
1890
+ val = table[0][id_col]
1891
+ if isinstance(val, bytes):
1892
+ val = val.decode("utf-8")
1893
+ return val
1894
+
1895
+ def _query_simbad_name_and_vmag(self, ra_deg, dec_deg, radius=5*u.arcsec):
1896
+ coord = SkyCoord(ra=ra_deg*u.deg, dec=dec_deg*u.deg, frame="icrs")
1897
+ table = None
1898
+ for attempt in range(1,6):
1899
+ try:
1900
+ custom = Simbad(); custom.reset_votable_fields()
1901
+ custom.add_votable_fields("otype","flux(V)")
1902
+ table = custom.query_region(coord, radius=radius)
1903
+ break
1904
+ except Exception as e:
1905
+ if attempt==5:
1906
+ QMessageBox.critical(self, "SIMBAD Error", f"Could not reach SIMBAD after 5 tries:\n{e}")
1907
+ return None, None
1908
+ time.sleep(1)
1909
+ if table is None or len(table)==0:
1910
+ return None, None
1911
+ try:
1912
+ id_col = next(c for c in table.colnames if c.lower()=="main_id")
1913
+ except StopIteration:
1914
+ return None, None
1915
+ raw_id = table[0][id_col]
1916
+ if isinstance(raw_id, bytes):
1917
+ raw_id = raw_id.decode()
1918
+ v_col = next((c for c in table.colnames if c.upper() in ("FLUX_V","V")), None)
1919
+ vmag = None
1920
+ if v_col:
1921
+ try:
1922
+ v = float(table[0][v_col])
1923
+ if np.isfinite(v): vmag = v
1924
+ except Exception:
1925
+ vmag = None
1926
+ return raw_id, vmag
1927
+
1928
+ # ---------------- Export ----------------
1929
+
1930
+ def export_data(self):
1931
+ if self.fluxes is None or self.times is None:
1932
+ QMessageBox.warning(self, "Export", "No photometry to export. Run Measure & Photometry first.")
1933
+ return
1934
+ wcs = self._wcs
1935
+ if wcs is None:
1936
+ QMessageBox.warning(self, "Export", "No WCS available. Run plate solve during photometry first.")
1937
+ return
1938
+
1939
+ dlg = QFileDialog(self, "Export Light Curves")
1940
+ dlg.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
1941
+ dlg.setNameFilters(["CSV files (*.csv)", "FITS files (*.fits)"])
1942
+ if not dlg.exec():
1943
+ return
1944
+ path = dlg.selectedFiles()[0]
1945
+ fmt = dlg.selectedNameFilter()
1946
+
1947
+ times_mjd = self.times.mjd
1948
+ n_stars = self.fluxes.shape[0]
1949
+
1950
+ xs = np.array([xy[0] for xy in self.star_positions])
1951
+ ys = np.array([xy[1] for xy in self.star_positions])
1952
+ sky = wcs.pixel_to_world(xs, ys)
1953
+ ras = sky.ra.deg
1954
+ decs = sky.dec.deg
1955
+
1956
+ if fmt.startswith("CSV") or path.lower().endswith(".csv"):
1957
+ df = pd.DataFrame({"MJD": times_mjd})
1958
+ for i in range(n_stars):
1959
+ df[f"STAR_{i}"] = self.fluxes[i]
1960
+ df[f"FLAG_{i}"] = self.flags[i]
1961
+ df[f"STAR_{i}_RA"] = ras[i]
1962
+ df[f"STAR_{i}_DEC"] = decs[i]
1963
+ df.to_csv(path, index=False)
1964
+ QMessageBox.information(self, "Export CSV", f"Wrote CSV →\n{path}")
1965
+ return
1966
+
1967
+ hdr_out = fits.Header()
1968
+ orig_hdr = None
1969
+ if hasattr(self, "_cached_headers") and 0 <= self.ref_idx < len(self._cached_headers):
1970
+ orig_hdr = self._cached_headers[self.ref_idx]
1971
+ if isinstance(orig_hdr, fits.Header):
1972
+ for key in ("OBJECT","TELESCOP","INSTRUME","OBSERVER",
1973
+ "DATE-OBS","EXPTIME","FILTER",
1974
+ "CRVAL1","CRVAL2","CRPIX1","CRPIX2",
1975
+ "CDELT1","CDELT2","CTYPE1","CTYPE2"):
1976
+ if key in orig_hdr:
1977
+ hdr_out[key] = orig_hdr[key]
1978
+ elif isinstance(orig_hdr, dict):
1979
+ for key in ("OBJECT","TELESCOP","INSTRUME","DATE-OBS","EXPTIME","FILTER"):
1980
+ val = orig_hdr.get(key, [{}])[0].get("value")
1981
+ if val is not None:
1982
+ hdr_out[key] = val
1983
+
1984
+ hdr_out["SEPTHR"] = (self.sep_threshold, "SEP detection threshold (sigma)")
1985
+ hdr_out["BFRAC"] = (self.border_fraction, "Border ignore fraction")
1986
+ hdr_out["REFIDX"] = (self.ref_idx, "Reference frame index")
1987
+ hdr_out["MEDFWHM"] = (self.median_fwhm, "Median FWHM of reference")
1988
+ hdr_out.add_history("Exported by Seti Astro Suite")
1989
+
1990
+ cols = [fits.Column(name="MJD", format="D", array=times_mjd)]
1991
+ for i in range(n_stars):
1992
+ cols.append(fits.Column(name=f"STAR_{i}", format="E", array=self.fluxes[i]))
1993
+ cols.append(fits.Column(name=f"FLAG_{i}", format="I", array=self.flags[i]))
1994
+ lc_hdu = fits.BinTableHDU.from_columns(cols, header=hdr_out, name="LIGHTCURVE")
1995
+
1996
+ star_idx = np.arange(n_stars, dtype=int)
1997
+ cols2 = [
1998
+ fits.Column(name="INDEX", format="I", array=star_idx),
1999
+ fits.Column(name="X", format="E", array=xs),
2000
+ fits.Column(name="Y", format="E", array=ys),
2001
+ fits.Column(name="RA", format="D", array=ras),
2002
+ fits.Column(name="DEC", format="D", array=decs),
2003
+ ]
2004
+ stars_hdu = fits.BinTableHDU.from_columns(cols2, name="STARS")
2005
+
2006
+ primary = fits.PrimaryHDU(header=hdr_out)
2007
+ hdul = fits.HDUList([primary, lc_hdu, stars_hdu])
2008
+ hdul.writeto(path, overwrite=True)
2009
+ QMessageBox.information(self, "Export FITS", f"Wrote FITS →\n{path}")
2010
+
2011
+ def estimate_airmass_from_altitude(self, alt_deg):
2012
+ alt_rad = np.deg2rad(np.clip(alt_deg, 0.1, 90.0))
2013
+ return 1.0 / np.sin(alt_rad)
2014
+
2015
+ def export_to_aavso(self):
2016
+ if getattr(self, "fluxes", None) is None or getattr(self, "times", None) is None:
2017
+ QMessageBox.warning(self, "Export AAVSO", "No photometry available. Run Measure & Photometry first.")
2018
+ return
2019
+ wcs = self._wcs
2020
+ if wcs is None:
2021
+ QMessageBox.warning(self, "Export AAVSO", "No WCS available. Plate-solve first.")
2022
+ return
2023
+
2024
+ sels = self.star_list.selectedItems()
2025
+ if len(sels) != 1:
2026
+ QMessageBox.warning(self, "Export AAVSO", "Please select exactly one star before exporting.")
2027
+ return
2028
+ idx = sels[0].data(Qt.ItemDataRole.UserRole)
2029
+
2030
+ star_id = self._query_simbad_main_id()
2031
+ if star_id:
2032
+ try:
2033
+ Vizier.ROW_LIMIT = 1
2034
+ v = Vizier(columns=["Name"], catalog="B/vsx")
2035
+ tbls = v.query_object(star_id)
2036
+ if tbls and len(tbls) > 0 and len(tbls[0]) > 0:
2037
+ star_id = tbls[0]["Name"][0]
2038
+ except Exception as e:
2039
+ print(f"[DEBUG] VSX lookup failed: {e}")
2040
+ if not star_id:
2041
+ star_id, ok = QInputDialog.getText(self, "Target Star Name", "Could not auto-identify. Enter target star name for STARID:", QLineEdit.EchoMode.Normal, "")
2042
+ if not ok or not star_id.strip():
2043
+ return
2044
+ star_id = star_id.strip()
2045
+
2046
+ if not hasattr(self, "exposure_time") or self.exposure_time is None:
2047
+ exp, ok = QInputDialog.getDouble(self, "Exposure Time", "No EXPOSURE found in headers. Please enter exposure time (s):", decimals=1)
2048
+ if not ok: return
2049
+ self.exposure_time = exp
2050
+
2051
+ settings = QSettings()
2052
+ prev_code = settings.value("AAVSO/observer_code", "", type=str)
2053
+ code, ok = QInputDialog.getText(self, "Observer Code", "Enter your AAVSO observer code:", QLineEdit.EchoMode.Normal, prev_code)
2054
+ if not ok: return
2055
+ code = code.strip().upper()
2056
+ settings.setValue("AAVSO/observer_code", code)
2057
+
2058
+ fmt, ok = QInputDialog.getItem(self, "AAVSO Format", "Choose submission format:", ["Variable-Star Photometry", "Exoplanet Report"], 0, False)
2059
+ if not ok: return
2060
+
2061
+ raw_members = self.ensemble_map.get(idx, [])
2062
+ members = [m for m in raw_members if 0 <= m < len(self.star_positions)]
2063
+ kname = None; kmag = None
2064
+ for m in members:
2065
+ x, y = self.star_positions[m]
2066
+ sky = wcs.pixel_to_world(x, y)
2067
+ name, v = self._query_simbad_name_and_vmag(sky.ra.deg, sky.dec.deg)
2068
+ if name and (v is not None) and np.isfinite(v):
2069
+ kname, kmag = name, v
2070
+ break
2071
+ if kname is None:
2072
+ kname, ok = QInputDialog.getText(self, "Check Star Name", "Could not auto-identify a check star. Enter check-star ID:")
2073
+ if not ok or not kname.strip(): return
2074
+ kname = kname.strip()
2075
+ kmag, ok = QInputDialog.getDouble(self, "Check Star Magnitude", f"Enter catalog magnitude for {kname}:", decimals=3)
2076
+ if not ok: return
2077
+
2078
+ filt_choices = ["V","TG","TB","TR"]
2079
+ filt, ok = QInputDialog.getItem(self, "Filter", "Select filter code for this dataset:", filt_choices, 0, False)
2080
+ if not ok: return
2081
+
2082
+ path, _ = QFileDialog.getSaveFileName(self, "Save AAVSO File", "", "Text files (*.txt *.dat *.csv)")
2083
+ if not path: return
2084
+
2085
+ header_lines = [
2086
+ "#TYPE=EXTENDED",
2087
+ f"#OBSCODE={code}",
2088
+ f"#SOFTWARE=Seti Astro Suite Pro",
2089
+ "#DELIM=,",
2090
+ "#DATE=JD",
2091
+ "#OBSTYPE=CCD",
2092
+ ]
2093
+ radec = self.get_selected_star_radec()
2094
+ if radec is None:
2095
+ QMessageBox.warning(self, "Export AAVSO", "Could not determine RA/Dec of selected star.")
2096
+ return
2097
+ c = SkyCoord(ra=radec[0]*u.deg, dec=radec[1]*u.deg, frame="icrs")
2098
+ header_lines += [
2099
+ "#RA=" + c.ra.to_string(unit=u.hour, sep=":", pad=True, precision=2),
2100
+ "#DEC=" + c.dec.to_string(unit=u.degree, sep=":", pad=True, alwayssign=True, precision=1),
2101
+ ]
2102
+ header_lines.append("#NAME,DATE,MAG,MERR,FILT,TRANS,MTYPE,CNAME,CMAG,KNAME,KMAG,AMASS,GROUP,CHART,NOTES")
2103
+
2104
+ jd = self.times.utc.jd
2105
+ rel_flux = self.fluxes[idx, :]
2106
+ with np.errstate(divide="ignore"):
2107
+ mags = kmag - 2.5 * np.log10(rel_flux)
2108
+ if hasattr(self, "flux_errors"):
2109
+ rel_err = self.flux_errors[idx, :]
2110
+ merr = (2.5/np.log(10)) * (rel_err / rel_flux)
2111
+ else:
2112
+ merr = np.full_like(mags, np.nan)
2113
+
2114
+ try:
2115
+ with open(path, "w") as f:
2116
+ for L in header_lines: f.write(L + "\n")
2117
+ f.write("\n")
2118
+ for j, t in enumerate(jd):
2119
+ m = mags[j]; me = merr[j]
2120
+ me_str = f"{me:.3f}" if np.isfinite(me) else "na"
2121
+ note = "MAG calc via ensemble: m=-2.5 log10(F/Fe)+K"
2122
+ am = float(np.clip(self.airmasses[j] if j < len(self.airmasses) else 1.0, 1.0, 40.0))
2123
+ fields = [
2124
+ star_id,
2125
+ f"{t:.5f}",
2126
+ f"{m:.3f}",
2127
+ me_str,
2128
+ filt,
2129
+ "NO",
2130
+ "STD",
2131
+ "ENSEMBLE",
2132
+ "na",
2133
+ kname,
2134
+ f"{kmag:.3f}",
2135
+ f"{am:.1f}",
2136
+ "na",
2137
+ "na",
2138
+ note
2139
+ ]
2140
+ f.write(",".join(fields) + "\n")
2141
+ except Exception as e:
2142
+ QMessageBox.critical(self, "Export AAVSO", f"Failed to write file:\n{e}")
2143
+ return
2144
+
2145
+ msg = QMessageBox(self)
2146
+ msg.setWindowTitle("Export AAVSO")
2147
+ msg.setText(f"Wrote {fmt} →\n{path}\n\nOpen AAVSO WebObs upload page now?")
2148
+ yes = msg.addButton("Yes", QMessageBox.ButtonRole.AcceptRole)
2149
+ msg.addButton("No", QMessageBox.ButtonRole.RejectRole)
2150
+ msg.exec()
2151
+ if msg.clickedButton() == yes:
2152
+ webbrowser.open("https://www.aavso.org/webobs/file")
2153
+
2154
+ # ---------------- TESScut ----------------
2155
+
2156
+ def query_tesscut(self):
2157
+ radec = self.get_selected_star_radec()
2158
+ if radec is None:
2159
+ QMessageBox.warning(self, "No Star Selected", "Please select a star from the list to fetch TESScut data.")
2160
+ return
2161
+ ra, dec = radec
2162
+ print(f"[DEBUG] TESScut Query Requested for RA={ra:.6f}, Dec={dec:.6f}")
2163
+ coord = SkyCoord(ra=ra, dec=dec, unit="deg")
2164
+
2165
+ size = 10
2166
+ MAX_RETRIES = 5
2167
+
2168
+ manifest = None
2169
+ for mtry in range(1, MAX_RETRIES+1):
2170
+ try:
2171
+ print(f"[DEBUG] Manifest attempt {mtry}/{MAX_RETRIES}…")
2172
+ manifest = Tesscut.get_cutouts(coordinates=coord, size=size)
2173
+ if manifest:
2174
+ print(f"[DEBUG] Manifest OK: {len(manifest)} sector(s).")
2175
+ break
2176
+ else:
2177
+ raise RuntimeError("Empty manifest")
2178
+ except Exception as me:
2179
+ print(f"[DEBUG] Manifest attempt {mtry} failed: {me}")
2180
+ if mtry == MAX_RETRIES:
2181
+ QMessageBox.information(self, "No TESS Data", "There are no TESS cutouts available at that position.")
2182
+ self.status_label.setText("No TESScut data found.")
2183
+ return
2184
+ time.sleep(2)
2185
+
2186
+ self.status_label.setText("Querying TESScut…")
2187
+ QApplication.processEvents()
2188
+ cache_dir = os.path.join(os.path.expanduser("~"), ".setiastro", "tesscut_cache")
2189
+ os.makedirs(cache_dir, exist_ok=True)
2190
+
2191
+ for dtry in range(1, MAX_RETRIES+1):
2192
+ try:
2193
+ print(f"[DEBUG] Download attempt {dtry}/{MAX_RETRIES}…")
2194
+ cutouts = Tesscut.download_cutouts(coordinates=coord, size=size, path=cache_dir)
2195
+ if not cutouts:
2196
+ raise RuntimeError("No cutouts downloaded")
2197
+ print(f"[DEBUG] Downloaded {len(cutouts)} cutout(s).")
2198
+
2199
+ for cutout in cutouts:
2200
+ original_path = cutout['Local Path']
2201
+ print(f"[DEBUG] Processing: {original_path}")
2202
+ with fits.open(original_path, mode='readonly') as hdul:
2203
+ sector = hdul[1].header.get('SECTOR', 'unknown')
2204
+ ext = os.path.splitext(original_path)[1]
2205
+ cache_key = f"tess_sector{sector}_ra{int(round(ra*10000))}_dec{int(round(dec*10000))}{ext}"
2206
+ cached_path = os.path.join(cache_dir, cache_key)
2207
+
2208
+ if not os.path.exists(cached_path):
2209
+ print(f"[DEBUG] Caching as: {cached_path}")
2210
+ shutil.move(original_path, cached_path)
2211
+ else:
2212
+ print(f"[DEBUG] Already cached: {cached_path}")
2213
+ os.remove(original_path)
2214
+
2215
+ tpf = TessTargetPixelFile(cached_path)
2216
+ xpix, ypix = tpf.wcs.world_to_pixel(coord)
2217
+ ny, nx = tpf.flux.shape[1], tpf.flux.shape[2]
2218
+ Y, X = np.mgrid[:ny, :nx]
2219
+ r_pix = 2.5
2220
+ aper_mask = ((X - xpix)**2 + (Y - ypix)**2) <= r_pix**2
2221
+
2222
+ lc = (tpf.to_lightcurve(aperture_mask=aper_mask).remove_nans().normalize())
2223
+ upper, lower = 5.0, -1.0
2224
+ mask = (lc.flux < upper) & (lc.flux > lower)
2225
+ n_clipped = np.sum(~mask)
2226
+ print(f"[DEBUG] Clipping {n_clipped} points outside [{lower}, {upper}]×")
2227
+ lc = lc[mask]
2228
+
2229
+ lc.plot(label=f"Sector {tpf.sector} (clipped)")
2230
+ plt.title(f"TESS Light Curve - Sector {tpf.sector}")
2231
+ plt.tight_layout()
2232
+ plt.show()
2233
+
2234
+ self.status_label.setText("TESScut fetch complete.")
2235
+ return
2236
+
2237
+ except Exception as de:
2238
+ print(f"[ERROR] Download attempt {dtry} failed: {de}")
2239
+ self.status_label.setText(f"TESScut attempt {dtry}/{MAX_RETRIES} failed.")
2240
+ QApplication.processEvents()
2241
+ if dtry == MAX_RETRIES:
2242
+ QMessageBox.critical(self, "TESScut Error", f"TESScut failed after {MAX_RETRIES} attempts.\n\n{de}")
2243
+ self.status_label.setText("TESScut fetch failed.")
2244
+ else:
2245
+ time.sleep(2)
2246
+
2247
+ # ---------------- Pixel → Sky helper ----------------
2248
+
2249
+ def get_selected_star_radec(self):
2250
+ selected_items = self.star_list.selectedItems()
2251
+ if not selected_items:
2252
+ return None
2253
+ selected_index = selected_items[0].data(Qt.ItemDataRole.UserRole)
2254
+ x, y = self.star_positions[selected_index]
2255
+ if self._wcs is None:
2256
+ return None
2257
+ sky = self._wcs.pixel_to_world(x, y)
2258
+ return sky.ra.degree, sky.dec.degree