setiastrosuitepro 1.6.2.post1__py3-none-any.whl

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

Potentially problematic release.


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

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