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.
- setiastro/__init__.py +2 -0
- setiastro/data/SASP_data.fits +0 -0
- setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
- setiastro/data/catalogs/astrobin_filters.csv +890 -0
- setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
- setiastro/data/catalogs/cali2.csv +63 -0
- setiastro/data/catalogs/cali2color.csv +65 -0
- setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
- setiastro/data/catalogs/celestial_catalog.csv +24031 -0
- setiastro/data/catalogs/detected_stars.csv +24784 -0
- setiastro/data/catalogs/fits_header_data.csv +46 -0
- setiastro/data/catalogs/test.csv +8 -0
- setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
- setiastro/images/Astro_Spikes.png +0 -0
- setiastro/images/Background_startup.jpg +0 -0
- setiastro/images/HRDiagram.png +0 -0
- setiastro/images/LExtract.png +0 -0
- setiastro/images/LInsert.png +0 -0
- setiastro/images/Oxygenation-atm-2.svg.png +0 -0
- setiastro/images/RGB080604.png +0 -0
- setiastro/images/abeicon.png +0 -0
- setiastro/images/aberration.png +0 -0
- setiastro/images/andromedatry.png +0 -0
- setiastro/images/andromedatry_satellited.png +0 -0
- setiastro/images/annotated.png +0 -0
- setiastro/images/aperture.png +0 -0
- setiastro/images/astrosuite.ico +0 -0
- setiastro/images/astrosuite.png +0 -0
- setiastro/images/astrosuitepro.icns +0 -0
- setiastro/images/astrosuitepro.ico +0 -0
- setiastro/images/astrosuitepro.png +0 -0
- setiastro/images/background.png +0 -0
- setiastro/images/background2.png +0 -0
- setiastro/images/benchmark.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
- setiastro/images/blaster.png +0 -0
- setiastro/images/blink.png +0 -0
- setiastro/images/clahe.png +0 -0
- setiastro/images/collage.png +0 -0
- setiastro/images/colorwheel.png +0 -0
- setiastro/images/contsub.png +0 -0
- setiastro/images/convo.png +0 -0
- setiastro/images/copyslot.png +0 -0
- setiastro/images/cosmic.png +0 -0
- setiastro/images/cosmicsat.png +0 -0
- setiastro/images/crop1.png +0 -0
- setiastro/images/cropicon.png +0 -0
- setiastro/images/curves.png +0 -0
- setiastro/images/cvs.png +0 -0
- setiastro/images/debayer.png +0 -0
- setiastro/images/denoise_cnn_custom.png +0 -0
- setiastro/images/denoise_cnn_graph.png +0 -0
- setiastro/images/disk.png +0 -0
- setiastro/images/dse.png +0 -0
- setiastro/images/exoicon.png +0 -0
- setiastro/images/eye.png +0 -0
- setiastro/images/fliphorizontal.png +0 -0
- setiastro/images/flipvertical.png +0 -0
- setiastro/images/font.png +0 -0
- setiastro/images/freqsep.png +0 -0
- setiastro/images/functionbundle.png +0 -0
- setiastro/images/graxpert.png +0 -0
- setiastro/images/green.png +0 -0
- setiastro/images/gridicon.png +0 -0
- setiastro/images/halo.png +0 -0
- setiastro/images/hdr.png +0 -0
- setiastro/images/histogram.png +0 -0
- setiastro/images/hubble.png +0 -0
- setiastro/images/imagecombine.png +0 -0
- setiastro/images/invert.png +0 -0
- setiastro/images/isophote.png +0 -0
- setiastro/images/isophote_demo_figure.png +0 -0
- setiastro/images/isophote_demo_image.png +0 -0
- setiastro/images/isophote_demo_model.png +0 -0
- setiastro/images/isophote_demo_residual.png +0 -0
- setiastro/images/jwstpupil.png +0 -0
- setiastro/images/linearfit.png +0 -0
- setiastro/images/livestacking.png +0 -0
- setiastro/images/mask.png +0 -0
- setiastro/images/maskapply.png +0 -0
- setiastro/images/maskcreate.png +0 -0
- setiastro/images/maskremove.png +0 -0
- setiastro/images/morpho.png +0 -0
- setiastro/images/mosaic.png +0 -0
- setiastro/images/multiscale_decomp.png +0 -0
- setiastro/images/nbtorgb.png +0 -0
- setiastro/images/neutral.png +0 -0
- setiastro/images/nuke.png +0 -0
- setiastro/images/openfile.png +0 -0
- setiastro/images/pedestal.png +0 -0
- setiastro/images/pen.png +0 -0
- setiastro/images/pixelmath.png +0 -0
- setiastro/images/platesolve.png +0 -0
- setiastro/images/ppp.png +0 -0
- setiastro/images/pro.png +0 -0
- setiastro/images/project.png +0 -0
- setiastro/images/psf.png +0 -0
- setiastro/images/redo.png +0 -0
- setiastro/images/redoicon.png +0 -0
- setiastro/images/rescale.png +0 -0
- setiastro/images/rgbalign.png +0 -0
- setiastro/images/rgbcombo.png +0 -0
- setiastro/images/rgbextract.png +0 -0
- setiastro/images/rotate180.png +0 -0
- setiastro/images/rotateclockwise.png +0 -0
- setiastro/images/rotatecounterclockwise.png +0 -0
- setiastro/images/satellite.png +0 -0
- setiastro/images/script.png +0 -0
- setiastro/images/selectivecolor.png +0 -0
- setiastro/images/simbad.png +0 -0
- setiastro/images/slot0.png +0 -0
- setiastro/images/slot1.png +0 -0
- setiastro/images/slot2.png +0 -0
- setiastro/images/slot3.png +0 -0
- setiastro/images/slot4.png +0 -0
- setiastro/images/slot5.png +0 -0
- setiastro/images/slot6.png +0 -0
- setiastro/images/slot7.png +0 -0
- setiastro/images/slot8.png +0 -0
- setiastro/images/slot9.png +0 -0
- setiastro/images/spcc.png +0 -0
- setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
- setiastro/images/spinner.gif +0 -0
- setiastro/images/stacking.png +0 -0
- setiastro/images/staradd.png +0 -0
- setiastro/images/staralign.png +0 -0
- setiastro/images/starnet.png +0 -0
- setiastro/images/starregistration.png +0 -0
- setiastro/images/starspike.png +0 -0
- setiastro/images/starstretch.png +0 -0
- setiastro/images/statstretch.png +0 -0
- setiastro/images/supernova.png +0 -0
- setiastro/images/uhs.png +0 -0
- setiastro/images/undoicon.png +0 -0
- setiastro/images/upscale.png +0 -0
- setiastro/images/viewbundle.png +0 -0
- setiastro/images/whitebalance.png +0 -0
- setiastro/images/wimi_icon_256x256.png +0 -0
- setiastro/images/wimilogo.png +0 -0
- setiastro/images/wims.png +0 -0
- setiastro/images/wrench_icon.png +0 -0
- setiastro/images/xisfliberator.png +0 -0
- setiastro/qml/ResourceMonitor.qml +126 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +945 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +3 -0
- setiastro/saspro/abe.py +1346 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +624 -0
- setiastro/saspro/astrobin_exporter.py +1010 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1841 -0
- setiastro/saspro/autostretch.py +198 -0
- setiastro/saspro/backgroundneutral.py +602 -0
- setiastro/saspro/batch_convert.py +328 -0
- setiastro/saspro/batch_renamer.py +522 -0
- setiastro/saspro/blemish_blaster.py +491 -0
- setiastro/saspro/blink_comparator_pro.py +2926 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +213 -0
- setiastro/saspro/clahe.py +368 -0
- setiastro/saspro/comet_stacking.py +1442 -0
- setiastro/saspro/common_tr.py +107 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1400 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +190 -0
- setiastro/saspro/cosmicclarity.py +1589 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +973 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2562 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +673 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2664 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +748 -0
- setiastro/saspro/fix_bom.py +32 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1349 -0
- setiastro/saspro/function_bundle.py +1596 -0
- setiastro/saspro/generate_translations.py +3092 -0
- setiastro/saspro/ghs_dialog_pro.py +663 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +637 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8810 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
- setiastro/saspro/gui/mixins/file_mixin.py +450 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/gui/statistics_dialog.py +47 -0
- setiastro/saspro/halobgon.py +488 -0
- setiastro/saspro/header_viewer.py +448 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +756 -0
- setiastro/saspro/history_explorer.py +941 -0
- setiastro/saspro/i18n.py +168 -0
- setiastro/saspro/image_combine.py +417 -0
- setiastro/saspro/image_peeker_pro.py +1604 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +236 -0
- setiastro/saspro/isophote.py +1182 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3676 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +537 -0
- setiastro/saspro/live_stacking.py +1841 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +931 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3831 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +407 -0
- setiastro/saspro/multiscale_decomp.py +1293 -0
- setiastro/saspro/nbtorgb_stars.py +541 -0
- setiastro/saspro/numba_utils.py +3145 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1473 -0
- setiastro/saspro/ops/settings.py +637 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1071 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1604 -0
- setiastro/saspro/plate_solver.py +2445 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +331 -0
- setiastro/saspro/remove_stars.py +1599 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +501 -0
- setiastro/saspro/rgb_combination.py +208 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +73 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1472 -0
- setiastro/saspro/shortcuts.py +3043 -0
- setiastro/saspro/signature_insert.py +1102 -0
- setiastro/saspro/stacking_suite.py +18470 -0
- setiastro/saspro/star_alignment.py +7435 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +765 -0
- setiastro/saspro/star_stretch.py +507 -0
- setiastro/saspro/stat_stretch.py +538 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3328 -0
- setiastro/saspro/supernovaasteroidhunter.py +1719 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/translations/all_source_strings.json +3654 -0
- setiastro/saspro/translations/ar_translations.py +3865 -0
- setiastro/saspro/translations/de_translations.py +3749 -0
- setiastro/saspro/translations/es_translations.py +3939 -0
- setiastro/saspro/translations/fr_translations.py +3858 -0
- setiastro/saspro/translations/hi_translations.py +3571 -0
- setiastro/saspro/translations/integrate_translations.py +270 -0
- setiastro/saspro/translations/it_translations.py +3678 -0
- setiastro/saspro/translations/ja_translations.py +3601 -0
- setiastro/saspro/translations/pt_translations.py +3869 -0
- setiastro/saspro/translations/ru_translations.py +2848 -0
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +255 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +253 -0
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +12520 -0
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +12514 -0
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +257 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +12520 -0
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +257 -0
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +257 -0
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +237 -0
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +257 -0
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +10771 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +12520 -0
- setiastro/saspro/translations/sw_translations.py +3671 -0
- setiastro/saspro/translations/uk_translations.py +3700 -0
- setiastro/saspro/translations/zh_translations.py +3675 -0
- setiastro/saspro/versioning.py +77 -0
- setiastro/saspro/view_bundle.py +1558 -0
- setiastro/saspro/wavescale_hdr.py +645 -0
- setiastro/saspro/wavescale_hdr_preset.py +101 -0
- setiastro/saspro/wavescalede.py +680 -0
- setiastro/saspro/wavescalede_preset.py +230 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +492 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +306 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/minigame/game.js +986 -0
- setiastro/saspro/widgets/minigame/index.html +53 -0
- setiastro/saspro/widgets/minigame/style.css +241 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/resource_monitor.py +237 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +331 -0
- setiastro/saspro/wimi.py +7996 -0
- setiastro/saspro/wims.py +578 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
- setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
- setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
- 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
|