From 68f3cf1a8f79f2d08c8fb0face3c3fbd650f4abb Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 13 May 2024 11:33:58 -0500 Subject: [PATCH 01/19] Add camera chooser to seeing profile --- .../gui_tools/seeing_profile_functions.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index 7925871f..e33b6229 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -19,6 +19,7 @@ from stellarphot.photometry.photometry import EXPOSURE_KEYWORDS from stellarphot.plotting import seeing_plot from stellarphot.settings import PhotometryApertures, ui_generator +from stellarphot.settings.custom_widgets import ChooseOrMakeNew __all__ = [ "set_keybindings", @@ -165,7 +166,6 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): self.iw = imagewidget - self.camera = camera self.observatory = observatory # Do some set up of the ImageWidget set_keybindings(self.iw, scroll_zoom=False) @@ -179,9 +179,15 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): self.seeing_profile_plot = ipw.Output() self.curve_growth_plot = ipw.Output() self.snr_plot = ipw.Output() + self.error_console = ipw.Output() + # Build the larger widget self.container = ipw.VBox() self.fits_file = FitsOpener(title="Choose an image") + self.camera_chooser = ChooseOrMakeNew("camera", details_hideable=True) + if camera is not None: + self.camera_chooser.value = camera + big_box = ipw.HBox() big_box = ipw.GridspecLayout(1, 2) layout = ipw.Layout(width="60ch") @@ -224,7 +230,12 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): # don't jump around as the image value changes. big_box.layout.justify_content = "space-between" self.big_box = big_box - self.container.children = [self.fits_file.file_chooser, self.big_box] + self.container.children = [ + self.fits_file.file_chooser, + self.camera_chooser, + self.error_console, + self.big_box, + ] self.box = self.container self._aperture_name = "aperture" @@ -236,6 +247,10 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): self._set_observers() self.aperture_settings.description = "" + @property + def camera(self): + return self.camera_chooser.value + def load_fits(self): """ Load a FITS file into the image widget. From b5cd8239d6fa19e65014007125a19873c7427509 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 13 May 2024 11:43:47 -0500 Subject: [PATCH 02/19] WIP hack on notebook 01 --- .../gui_tools/seeing_profile_functions.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index e33b6229..724504a8 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -394,17 +394,19 @@ def show_event( # User clicked on a star, so generate profile i = self.iw._viewer.get_image() data = i.get_data() - - # Rough location of click in original image - x = int(np.floor(event.data_x)) - y = int(np.floor(event.data_y)) - - rad_prof = CenterAndProfile( - data, - (x, y), - profile_radius=profile_size, - centering_cutout_size=centering_cutout_size, - ) + with self.error_console: + # Rough location of click in original image + x = int(np.floor(event.data_x)) + y = int(np.floor(event.data_y)) + print(f"Clicked at {x}, {y}") + rad_prof = CenterAndProfile( + data, + (x, y), + profile_radius=profile_size, + cutout_size=profile_size, + match_limit=100, + ) + print(f"Center: {rad_prof.center} FWHM: {rad_prof.FWHM}") try: try: # Remove previous marker From 4f8d4ab88dee9bf94edc9590c77897e35a2ee4bf Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Wed, 22 May 2024 15:56:01 -0400 Subject: [PATCH 03/19] Add way to programmatically set details visibility --- stellarphot/settings/custom_widgets.py | 19 +++++++++ .../settings/tests/test_custom_widgets.py | 39 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/stellarphot/settings/custom_widgets.py b/stellarphot/settings/custom_widgets.py index a504818d..4b3140eb 100644 --- a/stellarphot/settings/custom_widgets.py +++ b/stellarphot/settings/custom_widgets.py @@ -188,6 +188,25 @@ def value(self): """ return self._item_widget.model(**self._item_widget.value) + @property + def display_details(self): + """ + Whether the details box is displayed. Returns the value of the details checkbox + if the details are hideable, otherwise returns None. + """ + if self._show_details_shown: + return self._show_details_ui.value + else: + return None + + @display_details.setter + def display_details(self, value): + """ + Set the value of the details checkbox if the details are hideable. + """ + if self._show_details_shown: + self._show_details_ui.value = value + def _save_confirmation(self): """ Function to attach to the save button to show the confirmation widget if diff --git a/stellarphot/settings/tests/test_custom_widgets.py b/stellarphot/settings/tests/test_custom_widgets.py index c7db6b8d..d87ce1b1 100644 --- a/stellarphot/settings/tests/test_custom_widgets.py +++ b/stellarphot/settings/tests/test_custom_widgets.py @@ -695,6 +695,45 @@ def test_chooser_has_value(self, tmp_path): choose_or_make_new = ChooseOrMakeNew("camera", _testing_path=tmp_path) assert choose_or_make_new.value == Camera(**TEST_CAMERA_VALUES) + def test_details_can_be_hidden(self, tmp_path): + # Make sure that item details can be hidden programmatically. + self.make_test_camera(tmp_path) + + choose_or_make_new = ChooseOrMakeNew( + "camera", details_hideable=True, _testing_path=tmp_path + ) + + # Check that the details start out displayed + assert choose_or_make_new._details_box.layout.display != "none" + + # Hide the details + choose_or_make_new.display_details = False + + # Check that the details are hidden + assert choose_or_make_new._details_box.layout.display == "none" + + def test_details_visibilty_cannot_be_changed_when_not_hideable(self, tmp_path): + # Make sure that item details cannot be hidden programmatically when hideable + # is False. + self.make_test_camera(tmp_path) + + choose_or_make_new = ChooseOrMakeNew( + "camera", details_hideable=False, _testing_path=tmp_path + ) + + # Check that the details start out displayed + assert choose_or_make_new._details_box.layout.display != "none" + # Value should be None in this case + assert choose_or_make_new.display_details is None + + # Hide the details + choose_or_make_new.display_details = False + # Value should still be None in this case + assert choose_or_make_new.display_details is None + + # Check that the details are still displayed + assert choose_or_make_new._details_box.layout.display != "none" + class TestConfirm: def test_initial_value(self): From ad3d8fa664b8cba116118e8e51039b6d23761fdd Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Wed, 22 May 2024 16:05:08 -0400 Subject: [PATCH 04/19] Hide camera details by default --- stellarphot/gui_tools/seeing_profile_functions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index 724504a8..a9b088d6 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -188,6 +188,9 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): if camera is not None: self.camera_chooser.value = camera + # Do not show the camera details by default + self.camera_chooser.display_details = False + big_box = ipw.HBox() big_box = ipw.GridspecLayout(1, 2) layout = ipw.Layout(width="60ch") @@ -403,8 +406,7 @@ def show_event( data, (x, y), profile_radius=profile_size, - cutout_size=profile_size, - match_limit=100, + centering_cutout_size=centering_cutout_size, ) print(f"Center: {rad_prof.center} FWHM: {rad_prof.FWHM}") From 088fdeec40edbef18297ea911a8c9fcae402c01b Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Wed, 22 May 2024 16:30:11 -0400 Subject: [PATCH 05/19] Provide user feedback if no star found --- .../gui_tools/seeing_profile_functions.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index a9b088d6..0d12eac1 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -397,18 +397,28 @@ def show_event( # User clicked on a star, so generate profile i = self.iw._viewer.get_image() data = i.get_data() - with self.error_console: - # Rough location of click in original image - x = int(np.floor(event.data_x)) - y = int(np.floor(event.data_y)) - print(f"Clicked at {x}, {y}") + + # Rough location of click in original image + x = int(np.floor(event.data_x)) + y = int(np.floor(event.data_y)) + + try: rad_prof = CenterAndProfile( data, (x, y), profile_radius=profile_size, centering_cutout_size=centering_cutout_size, ) - print(f"Center: {rad_prof.center} FWHM: {rad_prof.FWHM}") + except RuntimeError as e: + if "Centroid did not converge on a star." in str(e): + with self.error_console: + print( + "No star found at this location. Try clicking closer " + "to a star or on a brighter star" + ) + else: + # Success, clear any previous error messages + self.error_console.clear_output() try: try: # Remove previous marker From 6e385f0ffc521f916aeeb14276e98ef8a9159575 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Tue, 28 May 2024 10:09:04 -0400 Subject: [PATCH 06/19] Make save button save settings to photometry file --- .../gui_tools/seeing_profile_functions.py | 87 ++++++++++++------- 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index 0d12eac1..3ead5c27 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -1,5 +1,4 @@ import warnings -from pathlib import Path import ipywidgets as ipw import numpy as np @@ -18,7 +17,12 @@ from stellarphot.photometry import CenterAndProfile from stellarphot.photometry.photometry import EXPOSURE_KEYWORDS from stellarphot.plotting import seeing_plot -from stellarphot.settings import PhotometryApertures, ui_generator +from stellarphot.settings import ( + PartialPhotometrySettings, + PhotometryApertures, + PhotometryWorkingDirSettings, + ui_generator, +) from stellarphot.settings.custom_widgets import ChooseOrMakeNew __all__ = [ @@ -164,6 +168,7 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): image_width=width, image_height=width, use_opencv=True ) + self.photometry_settings = PhotometryWorkingDirSettings() self.iw = imagewidget self.observatory = observatory @@ -179,11 +184,13 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): self.seeing_profile_plot = ipw.Output() self.curve_growth_plot = ipw.Output() self.snr_plot = ipw.Output() + + # Include an error console to display messages to the user self.error_console = ipw.Output() # Build the larger widget self.container = ipw.VBox() - self.fits_file = FitsOpener(title="Choose an image") + self.fits_file = FitsOpener(title=self._format_title("Choose an image")) self.camera_chooser = ChooseOrMakeNew("camera", details_hideable=True) if camera is not None: self.camera_chooser.value = camera @@ -191,22 +198,23 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): # Do not show the camera details by default self.camera_chooser.display_details = False + top_box = ipw.HBox() + top_box.children = [self.fits_file.file_chooser, self.camera_chooser] + big_box = ipw.HBox() big_box = ipw.GridspecLayout(1, 2) - layout = ipw.Layout(width="60ch") + + # Box for aperture settings and title vb = ipw.VBox() - self.aperture_settings_file_name = ipw.Text( - description="Aperture settings file name", - style={"description_width": "initial"}, - value="aperture_settings.json", - layout=layout, - ) + + self.ap_title = ipw.HTML(value=self._format_title("Save aperture and camera")) self.aperture_settings = ui_generator(PhotometryApertures) self.aperture_settings.show_savebuttonbar = True - self.aperture_settings.path = Path(self.aperture_settings_file_name.value) + self.aperture_settings.savebuttonbar.fns_onsave_add_action(self.save) + # self.aperture_settings.path = Path(self.aperture_settings_file_name.value) vb.children = [ - self.aperture_settings_file_name, + self.ap_title, self.aperture_settings, ] @@ -216,15 +224,20 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): self.snr_plot, self.seeing_profile_plot, self.curve_growth_plot, + self.aperture_settings, + ] + lil_tabs.titles = [ + "SNR", + "Seeing profile", + "Integrated counts", + "Aperture settings", ] - lil_tabs.set_title(0, "SNR") - lil_tabs.set_title(1, "Seeing profile") - lil_tabs.set_title(2, "Integrated counts") + self.tess_box = self._make_tess_box() lil_box.children = [lil_tabs, self.tess_box] imbox = ipw.VBox() - imbox.children = [imagewidget, vb] + imbox.children = [imagewidget] big_box[0, 0] = imbox big_box[0, 1] = lil_box big_box.layout.width = "100%" @@ -233,12 +246,7 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): # don't jump around as the image value changes. big_box.layout.justify_content = "space-between" self.big_box = big_box - self.container.children = [ - self.fits_file.file_chooser, - self.camera_chooser, - self.error_console, - self.big_box, - ] + self.container.children = [top_box, self.error_console, self.big_box, vb] self.box = self.container self._aperture_name = "aperture" @@ -274,6 +282,20 @@ def load_fits(self): ) self.exposure = np.nan + def save(self): + self.photometry_settings.save( + PartialPhotometrySettings( + photometry_apertures=self.aperture_settings.value, camera=self.camera + ), + update=True, + ) + + def _format_title(self, title): + """ + Format titles in a consistent way. + """ + return f"

{title}

" + def _update_file(self, change): # noqa: ARG002 # Widget callbacks need to accept a single argument, even if it is not used. self.load_fits() @@ -308,12 +330,6 @@ def _save_seeing_plot(self, button): # noqa: ARG002 """ self._seeing_plot_fig.savefig(self.seeing_file_name.value) - def _change_aperture_save_location(self, change): - new_name = change["new"] - new_path = Path(new_name) - self.aperture_settings.path = new_path - self.aperture_settings.savebuttonbar.unsaved_changes = True - def _set_observers(self): def aperture_obs(change): self._update_plots() @@ -324,9 +340,7 @@ def aperture_obs(change): ) self.aperture_settings.observe(aperture_obs, names="_value") - self.aperture_settings_file_name.observe( - self._change_aperture_save_location, names="value" - ) + self.fits_file.file_chooser.observe(self._update_file, names="_value") if self.save_toggle: self.save_toggle.observe(self._save_toggle_action, names="value") @@ -458,7 +472,16 @@ def show_event( ) # Make an aperture settings object, but don't update it's widget yet. if update_aperture_settings: - self._update_ap_settings(ap_settings.dict()) + # So it turns out that the validation stuff only updates when changes + # are made in the UI rather than programmatically. Since we know we've + # set a valid value, and that we've made changes we just manually set + # the relevant values. + self.aperture_settings.savebuttonbar.unsaved_changes = True + self.aperture_settings.is_valid.value = True + + # Update the value last so that the unsaved state is properly set when + # the value is updated. + self._update_ap_settings(ap_settings.model_dump()) self._update_plots() From 724bb68f36dc9f46a9fc20d7283fa99836d10567 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Tue, 28 May 2024 10:09:53 -0400 Subject: [PATCH 07/19] Give variable more meaningful names --- .../gui_tools/seeing_profile_functions.py | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index 3ead5c27..43bea4d4 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -30,7 +30,7 @@ "SeeingProfileWidget", ] -desc_style = {"description_width": "initial"} +DESC_STYLE = {"description_width": "initial"} # TODO: maybe move this into SeeingProfileWidget unless we anticipate @@ -198,14 +198,13 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): # Do not show the camera details by default self.camera_chooser.display_details = False - top_box = ipw.HBox() - top_box.children = [self.fits_file.file_chooser, self.camera_chooser] + image_camer_box = ipw.HBox() + image_camer_box.children = [self.fits_file.file_chooser, self.camera_chooser] - big_box = ipw.HBox() - big_box = ipw.GridspecLayout(1, 2) + im_view_plot_box = ipw.GridspecLayout(1, 2) # Box for aperture settings and title - vb = ipw.VBox() + ap_setting_box = ipw.VBox() self.ap_title = ipw.HTML(value=self._format_title("Save aperture and camera")) self.aperture_settings = ui_generator(PhotometryApertures) @@ -213,40 +212,43 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): self.aperture_settings.savebuttonbar.fns_onsave_add_action(self.save) # self.aperture_settings.path = Path(self.aperture_settings_file_name.value) - vb.children = [ + ap_setting_box.children = [ self.ap_title, self.aperture_settings, ] - lil_box = ipw.VBox() - lil_tabs = ipw.Tab() - lil_tabs.children = [ + plot_box = ipw.VBox() + plt_tabs = ipw.Tab() + plt_tabs.children = [ self.snr_plot, self.seeing_profile_plot, self.curve_growth_plot, - self.aperture_settings, ] - lil_tabs.titles = [ + plt_tabs.titles = [ "SNR", "Seeing profile", "Integrated counts", - "Aperture settings", ] self.tess_box = self._make_tess_box() - lil_box.children = [lil_tabs, self.tess_box] + plot_box.children = [plt_tabs, self.tess_box] imbox = ipw.VBox() imbox.children = [imagewidget] - big_box[0, 0] = imbox - big_box[0, 1] = lil_box - big_box.layout.width = "100%" + im_view_plot_box[0, 0] = imbox + im_view_plot_box[0, 1] = plot_box + im_view_plot_box.layout.width = "100%" # Line below puts space between the image and the plots so the plots # don't jump around as the image value changes. - big_box.layout.justify_content = "space-between" - self.big_box = big_box - self.container.children = [top_box, self.error_console, self.big_box, vb] + im_view_plot_box.layout.justify_content = "space-between" + self.big_box = im_view_plot_box + self.container.children = [ + image_camer_box, + self.error_console, + self.big_box, + ap_setting_box, + ] self.box = self.container self._aperture_name = "aperture" @@ -368,7 +370,7 @@ def _make_tess_box(self): scope_name = ipw.Text( description="Telescope code", value=self.observatory.TESS_telescope_code, - style=desc_style, + style=DESC_STYLE, ) planet_num = ipw.IntText(description="Planet", value=1) self.save_seeing = ipw.Button(description="Save") From 49b94b3f6b0cae1efe544d6c652b4f6d3a52df63 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Tue, 28 May 2024 10:19:08 -0400 Subject: [PATCH 08/19] Make error display stand out more --- stellarphot/gui_tools/seeing_profile_functions.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index 43bea4d4..fe082718 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -4,6 +4,7 @@ import numpy as np from astropy.io import fits from astropy.table import Table +from IPython.display import display try: from astrowidgets import ImageWidget @@ -428,9 +429,12 @@ def show_event( except RuntimeError as e: if "Centroid did not converge on a star." in str(e): with self.error_console: - print( - "No star found at this location. Try clicking closer " - "to a star or on a brighter star" + display( + ipw.HTML( + "No star found at this location. " + "Try clicking closer " + "to a star or on a brighter star" + ) ) else: # Success, clear any previous error messages From ddefd3bc4daa90409b5322cfe2d3c6b0d00b41ce Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Tue, 28 May 2024 10:40:51 -0400 Subject: [PATCH 09/19] Add visual cue that settings need to be changed --- .../gui_tools/seeing_profile_functions.py | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index fe082718..1d0626b2 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -32,6 +32,9 @@ ] DESC_STYLE = {"description_width": "initial"} +AP_SETTING_NEEDS_SAVE = "❗️" +AP_SETTING_SAVED = "✅" +DEFAULT_SAVE_TITLE = "Save aperture and camera" # TODO: maybe move this into SeeingProfileWidget unless we anticipate @@ -173,6 +176,7 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): self.iw = imagewidget self.observatory = observatory + # Do some set up of the ImageWidget set_keybindings(self.iw, scroll_zoom=False) bind_map = self.iw._viewer.get_bindmap() @@ -207,7 +211,7 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): # Box for aperture settings and title ap_setting_box = ipw.VBox() - self.ap_title = ipw.HTML(value=self._format_title("Save aperture and camera")) + self.ap_title = ipw.HTML(value=self._format_title(DEFAULT_SAVE_TITLE)) self.aperture_settings = ui_generator(PhotometryApertures) self.aperture_settings.show_savebuttonbar = True self.aperture_settings.savebuttonbar.fns_onsave_add_action(self.save) @@ -286,6 +290,9 @@ def load_fits(self): self.exposure = np.nan def save(self): + """ + Save all of the settings we have to a partial settings file. + """ self.photometry_settings.save( PartialPhotometrySettings( photometry_apertures=self.aperture_settings.value, camera=self.camera @@ -293,6 +300,12 @@ def save(self): update=True, ) + # For some reason the value of unsaved_changes is not updated until after this + # function executes, so we force its value here. + self.aperture_settings.savebuttonbar.unsaved_changes = False + # Update the save box title to reflect the save + self._set_save_box_title("") + def _format_title(self, title): """ Format titles in a consistent way. @@ -333,6 +346,12 @@ def _save_seeing_plot(self, button): # noqa: ARG002 """ self._seeing_plot_fig.savefig(self.seeing_file_name.value) + def _set_save_box_title(self, _): + if self.aperture_settings.savebuttonbar.unsaved_changes: + self.ap_title.value = self._format_title(DEFAULT_SAVE_TITLE + " ❗️") + else: + self.ap_title.value = self._format_title(DEFAULT_SAVE_TITLE + " ✅") + def _set_observers(self): def aperture_obs(change): self._update_plots() @@ -345,6 +364,9 @@ def aperture_obs(change): self.aperture_settings.observe(aperture_obs, names="_value") self.fits_file.file_chooser.observe(self._update_file, names="_value") + + self.aperture_settings.observe(self._set_save_box_title, "_value") + if self.save_toggle: self.save_toggle.observe(self._save_toggle_action, names="value") self.save_seeing.on_click(self._save_seeing_plot) From 77938c9b13854b7ede9334b41e0e414deff3d87f Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Tue, 28 May 2024 10:41:03 -0400 Subject: [PATCH 10/19] Clean up notebook 1 --- .../01-viewer-seeing-template.ipynb | 43 ++++++++----------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/stellarphot/notebooks/photometry/01-viewer-seeing-template.ipynb b/stellarphot/notebooks/photometry/01-viewer-seeing-template.ipynb index 6ae4721f..27353733 100644 --- a/stellarphot/notebooks/photometry/01-viewer-seeing-template.ipynb +++ b/stellarphot/notebooks/photometry/01-viewer-seeing-template.ipynb @@ -4,34 +4,16 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Seeing profile\n", + "# Seeing profile and aperture settings\n", "\n", "This notebook lets you measure the *seeing profile* of a star, which is essentially the size of a star in the image.\n", "\n", "\n", "## Instructions\n", "\n", - "Fill in the name of the file you want to look at in the cell below, then run the whole notebook.\n", + "Select an image and a camera.\n", "\n", - "You can change the filename if you want to look at another image." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from stellarphot.gui_tools.seeing_profile_functions import SeeingProfileWidget" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "seeing_profile = SeeingProfileWidget()" + "Shift-click on a star to calculate its seeing file and choose aperture settings" ] }, { @@ -40,11 +22,11 @@ "tags": [] }, "source": [ - "## The cell below displays the viewer\n", + "# The cell below displays the viewer\n", "\n", - "### Left-click and drag to pan\n", - "### Scroll or use the +/- keys to zoom in/out\n", - "### Shift-click to display seeing profiles for stars" + "+ Left-click and drag to pan\n", + "+ Use the +/- keys to zoom in/out\n", + "+ Shift-click to select star and display profile" ] }, { @@ -53,8 +35,17 @@ "metadata": {}, "outputs": [], "source": [ + "from stellarphot.gui_tools.seeing_profile_functions import SeeingProfileWidget\n", + "seeing_profile = SeeingProfileWidget(width=400)\n", "seeing_profile.box" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { @@ -73,7 +64,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.6" + "version": "3.11.9" } }, "nbformat": 4, From 54b399868abed16c67c0baa53b8bad5c5f4107c7 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Tue, 28 May 2024 10:44:04 -0400 Subject: [PATCH 11/19] Drop unused code --- stellarphot/gui_tools/seeing_profile_functions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index 1d0626b2..3b02b4ae 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -215,7 +215,6 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): self.aperture_settings = ui_generator(PhotometryApertures) self.aperture_settings.show_savebuttonbar = True self.aperture_settings.savebuttonbar.fns_onsave_add_action(self.save) - # self.aperture_settings.path = Path(self.aperture_settings_file_name.value) ap_setting_box.children = [ self.ap_title, From 6c635b5ee159e100784a8482525df3b59dd91d64 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Tue, 28 May 2024 10:54:42 -0400 Subject: [PATCH 12/19] Add launcher entry for notebook 1 --- stellarphot/notebooks/jp_app_launcher_stellarphot.yaml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/stellarphot/notebooks/jp_app_launcher_stellarphot.yaml b/stellarphot/notebooks/jp_app_launcher_stellarphot.yaml index 1c2f98a0..d44b41a7 100644 --- a/stellarphot/notebooks/jp_app_launcher_stellarphot.yaml +++ b/stellarphot/notebooks/jp_app_launcher_stellarphot.yaml @@ -4,4 +4,12 @@ icon: ../stellarphot/photometry/glowing-waffle-3-smaller.svg cwd: not_used type: notebook - catalog: Stellarphot + catalog: Stellarphot settings + +- title: Seeing profile + description: Choose aperture settings + source: ../stellarphot/photometry/01-viewer-seeing-template.ipynb + icon: ../stellarphot/photometry/glowing-waffle-3-smaller.svg + cwd: not_used + type: notebook + catalog: Stellarphot photometry From d07561d99567e3c67add22ee8551b8d461c2b06b Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Wed, 29 May 2024 10:51:08 -0400 Subject: [PATCH 13/19] Add camera if it does not exist --- .../gui_tools/seeing_profile_functions.py | 24 ++++++++++++++++--- .../gui_tools/tests/test_seeing_profile.py | 4 +++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index 3b02b4ae..f06a3447 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -22,6 +22,7 @@ PartialPhotometrySettings, PhotometryApertures, PhotometryWorkingDirSettings, + SavedSettings, ui_generator, ) from stellarphot.settings.custom_widgets import ChooseOrMakeNew @@ -166,7 +167,14 @@ class SeeingProfileWidget: """ - def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): + def __init__( + self, + imagewidget=None, + width=500, + camera=None, + observatory=None, + _testing_path=None, + ): if not imagewidget: imagewidget = ImageWidget( image_width=width, image_height=width, use_opencv=True @@ -177,6 +185,13 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): self.observatory = observatory + # If a camera is provided make sure it has already been saved. + # If it has not been saved, raise an error. + if camera is not None: + saved = SavedSettings(_testing_path=_testing_path) + if camera not in saved.cameras.as_dict.values(): + saved.add_item(camera) + # Do some set up of the ImageWidget set_keybindings(self.iw, scroll_zoom=False) bind_map = self.iw._viewer.get_bindmap() @@ -196,9 +211,12 @@ def __init__(self, imagewidget=None, width=500, camera=None, observatory=None): # Build the larger widget self.container = ipw.VBox() self.fits_file = FitsOpener(title=self._format_title("Choose an image")) - self.camera_chooser = ChooseOrMakeNew("camera", details_hideable=True) + self.camera_chooser = ChooseOrMakeNew( + "camera", details_hideable=True, _testing_path=_testing_path + ) + if camera is not None: - self.camera_chooser.value = camera + self.camera_chooser._choose_existing.value = camera # Do not show the camera details by default self.camera_chooser.display_details = False diff --git a/stellarphot/gui_tools/tests/test_seeing_profile.py b/stellarphot/gui_tools/tests/test_seeing_profile.py index 5443b33f..d820f761 100644 --- a/stellarphot/gui_tools/tests/test_seeing_profile.py +++ b/stellarphot/gui_tools/tests/test_seeing_profile.py @@ -54,7 +54,9 @@ def test_seeing_profile_object_creation(): def test_seeing_profile_properties(tmp_path): # Here we make a seeing profile then load an image. - profile_widget = spf.SeeingProfileWidget(camera=Camera(**TEST_CAMERA_VALUES)) + profile_widget = spf.SeeingProfileWidget( + camera=Camera(**TEST_CAMERA_VALUES), _testing_path=tmp_path + ) # Make a fits file image = make_gaussian_sources_image(SHAPE, STARS) + make_noise_image( From a146b1d84a940d56c29a42012fe840d0905ce425 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 31 May 2024 14:59:18 -0400 Subject: [PATCH 14/19] Add test of save method --- .../gui_tools/tests/test_seeing_profile.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/stellarphot/gui_tools/tests/test_seeing_profile.py b/stellarphot/gui_tools/tests/test_seeing_profile.py index d820f761..e17b7517 100644 --- a/stellarphot/gui_tools/tests/test_seeing_profile.py +++ b/stellarphot/gui_tools/tests/test_seeing_profile.py @@ -1,3 +1,4 @@ +import os import warnings from collections import namedtuple @@ -10,7 +11,12 @@ from stellarphot.gui_tools import seeing_profile_functions as spf from stellarphot.photometry.tests.test_profiles import RANDOM_SEED, SHAPE, STARS -from stellarphot.settings import Camera, Observatory, PhotometryApertures +from stellarphot.settings import ( + Camera, + Observatory, + PhotometryApertures, + PhotometryWorkingDirSettings, +) from stellarphot.settings.tests.test_models import ( TEST_CAMERA_VALUES, ) @@ -112,6 +118,28 @@ def test_seeing_profile_properties(tmp_path): assert profile_widget.aperture_settings.value == phot_aps +def test_seeing_profile_save_apertures(tmp_path): + # Make sure that saving partial photometery settings works + os.chdir(tmp_path) + + phot_settings = PhotometryWorkingDirSettings() + + # There should be no saved settings... + with pytest.raises(ValueError, match="does not exist"): + phot_settings.load() + + profile_widget = spf.SeeingProfileWidget( + camera=Camera(**TEST_CAMERA_VALUES), _testing_path=tmp_path + ) + + profile_widget.save() + settings = phot_settings.load() + assert settings.camera == Camera(**TEST_CAMERA_VALUES) + assert settings.photometry_apertures == PhotometryApertures( + radius=1, annulus_width=1, gap=1 + ) + + def test_seeing_profile_no_observatory(): # This test checks that with no observatory set, there is no TESS # related box displayed. From a4bda8c44e64881f152b736867f0fbfc54a10442 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 31 May 2024 15:43:34 -0400 Subject: [PATCH 15/19] Add test of whether the save box title is correct --- .../gui_tools/seeing_profile_functions.py | 25 +++++++++++++++++-- .../gui_tools/tests/test_seeing_profile.py | 21 ++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index f06a3447..cd78ec52 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -276,6 +276,8 @@ def __init__( self._tess_sub = None + # This is eventually used to store the radial profile + self.rad_prof = None # Fill these in later with name of object from FITS file self.object_name = "" self.exposure = 0 @@ -363,8 +365,22 @@ def _save_seeing_plot(self, button): # noqa: ARG002 """ self._seeing_plot_fig.savefig(self.seeing_file_name.value) - def _set_save_box_title(self, _): - if self.aperture_settings.savebuttonbar.unsaved_changes: + def _set_save_box_title(self, change): + # If we got here via a traitlets event then change is a dict, check that + # case first. + dirty = False + + try: + if change["new"] != change["old"]: + dirty = True + except (KeyError, TypeError): + dirty = False + + # The unsaved_changes attribute is not a traitlet, and it isn't clear when + # in the event handling it gets set. When not called from an event, though, + # this function can only used unsaved_changes to decide what the title + # should be. + if self.aperture_settings.savebuttonbar.unsaved_changes or dirty: self.ap_title.value = self._format_title(DEFAULT_SAVE_TITLE + " ❗️") else: self.ap_title.value = self._format_title(DEFAULT_SAVE_TITLE + " ✅") @@ -536,6 +552,11 @@ def _update_plots(self): # DISPLAY THE SCALED PROFILE fig_size = (10, 5) + # Stop if the update is happening before a radial profile has been generated + # (e.g. the user changes the aperture settings before loading an image). + if self.rad_prof is None: + return + rad_prof = self.rad_prof self.seeing_profile_plot.clear_output(wait=True) ap_settings = PhotometryApertures(**self.aperture_settings.value) diff --git a/stellarphot/gui_tools/tests/test_seeing_profile.py b/stellarphot/gui_tools/tests/test_seeing_profile.py index e17b7517..edf97c26 100644 --- a/stellarphot/gui_tools/tests/test_seeing_profile.py +++ b/stellarphot/gui_tools/tests/test_seeing_profile.py @@ -133,6 +133,8 @@ def test_seeing_profile_save_apertures(tmp_path): ) profile_widget.save() + assert not profile_widget.aperture_settings.savebuttonbar.unsaved_changes + settings = phot_settings.load() assert settings.camera == Camera(**TEST_CAMERA_VALUES) assert settings.photometry_apertures == PhotometryApertures( @@ -140,6 +142,25 @@ def test_seeing_profile_save_apertures(tmp_path): ) +def test_seeing_profile_save_box_title(tmp_path): + # Save box title ends with different characters depending on whether values + # need to be saved. + + # Change working directory since settings file is saved there. + os.chdir(tmp_path) + + profile_widget = spf.SeeingProfileWidget( + camera=Camera(**TEST_CAMERA_VALUES), _testing_path=tmp_path + ) + profile_widget.aperture_settings.di_widgets["radius"].value = 3 + + assert profile_widget.aperture_settings.savebuttonbar.unsaved_changes + assert "❗️" in profile_widget.ap_title.value + + profile_widget.save() + assert "✅" in profile_widget.ap_title.value + + def test_seeing_profile_no_observatory(): # This test checks that with no observatory set, there is no TESS # related box displayed. From 82c5b7974ff4650438b868941549df9e1071b67e Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 31 May 2024 16:42:17 -0400 Subject: [PATCH 16/19] Limit error message display to one error --- .../gui_tools/seeing_profile_functions.py | 29 +++++++---- .../gui_tools/tests/test_seeing_profile.py | 48 +++++++++++++++++++ 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index cd78ec52..d2e790e6 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -4,7 +4,6 @@ import numpy as np from astropy.io import fits from astropy.table import Table -from IPython.display import display try: from astrowidgets import ImageWidget @@ -482,15 +481,29 @@ def show_event( centering_cutout_size=centering_cutout_size, ) except RuntimeError as e: + # Check whether this error is one generated by RadialProfile if "Centroid did not converge on a star." in str(e): - with self.error_console: - display( - ipw.HTML( - "No star found at this location. " - "Try clicking closer " - "to a star or on a brighter star" - ) + # Clear any previous messages...no idea why the clear_output + # method doesn't work here, but it doesn't/ + self.error_console.outputs = () + + # Use the append_display_data method instead of the + # error_console context manager because there seems to be + # a timing issue with the context manager when running + # tests. + self.error_console.append_display_data( + ipw.HTML( + "No star found at this location. " + "Try clicking closer " + "to a star or on a brighter star" ) + ) + print(f"{self.error_console.outputs=}") + return + else: + # RadialProfile did not generate this error, pass it + # on to the user + raise e else: # Success, clear any previous error messages self.error_console.clear_output() diff --git a/stellarphot/gui_tools/tests/test_seeing_profile.py b/stellarphot/gui_tools/tests/test_seeing_profile.py index edf97c26..5012d405 100644 --- a/stellarphot/gui_tools/tests/test_seeing_profile.py +++ b/stellarphot/gui_tools/tests/test_seeing_profile.py @@ -161,6 +161,54 @@ def test_seeing_profile_save_box_title(tmp_path): assert "✅" in profile_widget.ap_title.value +def test_seeing_profile_error_messages_no_star(tmp_path): + # Make sure the appropriate error message is displayed when a click happens on + # a region with no star, and that the message only appears once. + profile_widget = spf.SeeingProfileWidget( + camera=Camera(**TEST_CAMERA_VALUES), _testing_path=tmp_path + ) + # Make a fits file with no stars + image = make_noise_image(SHAPE, mean=10, stddev=1, seed=RANDOM_SEED) + + ccd = CCDData(image, unit="adu") + ccd.header["exposure"] = 30.0 + ccd.header["object"] = "test" + file_name = tmp_path / "test.fits" + ccd.write(file_name) + + # Load the file + profile_widget.fits_file.set_file("test.fits", tmp_path) + profile_widget.load_fits() + + # Get the event handler that updates plots + handler = profile_widget._make_show_event() + + # Make a mock event object + Event = namedtuple("Event", ["data_x", "data_y"]) + star_loc_x, star_loc_y = SHAPE[0] // 2, SHAPE[1] // 2 + # Sending a mock event will generate plots that we don't want to see + # so set the matplotlib backend to a non-interactive one + matplotlib.use("agg") + # matplotlib generates a warning that we are using a non-interactive backend + # so filter that warning out for the remainder of the test. There are at least + # a couple of times we generate this warning as values are changed. + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + assert len(profile_widget.error_console.outputs) == 0 + + # Clicking once should generate an error... + handler(profile_widget.iw, Event(star_loc_x, star_loc_y)) + assert len(profile_widget.error_console.outputs) == 1 + assert ( + "No star found at this location" + in profile_widget.error_console.outputs[0]["data"]["text/plain"] + ) + + # Clicking a second time should also just have one error + handler(profile_widget.iw, Event(star_loc_x, star_loc_y)) + assert len(profile_widget.error_console.outputs) == 1 + + def test_seeing_profile_no_observatory(): # This test checks that with no observatory set, there is no TESS # related box displayed. From a64d47d760f996fe44272238037ef1ec463dfe44 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 31 May 2024 16:52:48 -0400 Subject: [PATCH 17/19] Add test of property --- stellarphot/settings/tests/test_custom_widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/stellarphot/settings/tests/test_custom_widgets.py b/stellarphot/settings/tests/test_custom_widgets.py index d87ce1b1..09f1bfea 100644 --- a/stellarphot/settings/tests/test_custom_widgets.py +++ b/stellarphot/settings/tests/test_custom_widgets.py @@ -705,6 +705,7 @@ def test_details_can_be_hidden(self, tmp_path): # Check that the details start out displayed assert choose_or_make_new._details_box.layout.display != "none" + assert choose_or_make_new.display_details # Hide the details choose_or_make_new.display_details = False From 54b8a4a91c9d55ca75f67c8a0a2004aa67da00e4 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 31 May 2024 16:55:42 -0400 Subject: [PATCH 18/19] Skip coverage on an exception --- stellarphot/gui_tools/seeing_profile_functions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index d2e790e6..9ce77df5 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -503,7 +503,7 @@ def show_event( else: # RadialProfile did not generate this error, pass it # on to the user - raise e + raise e # pragma: no cover else: # Success, clear any previous error messages self.error_console.clear_output() From e13ddc20e640e96389b78b496f105eae9a97526f Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 31 May 2024 17:04:56 -0400 Subject: [PATCH 19/19] Use variable names for special characters --- stellarphot/gui_tools/seeing_profile_functions.py | 8 ++++++-- stellarphot/gui_tools/tests/test_seeing_profile.py | 12 +++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/stellarphot/gui_tools/seeing_profile_functions.py b/stellarphot/gui_tools/seeing_profile_functions.py index 9ce77df5..1148d6e9 100644 --- a/stellarphot/gui_tools/seeing_profile_functions.py +++ b/stellarphot/gui_tools/seeing_profile_functions.py @@ -380,9 +380,13 @@ def _set_save_box_title(self, change): # this function can only used unsaved_changes to decide what the title # should be. if self.aperture_settings.savebuttonbar.unsaved_changes or dirty: - self.ap_title.value = self._format_title(DEFAULT_SAVE_TITLE + " ❗️") + self.ap_title.value = self._format_title( + f"{DEFAULT_SAVE_TITLE} {AP_SETTING_NEEDS_SAVE}" + ) else: - self.ap_title.value = self._format_title(DEFAULT_SAVE_TITLE + " ✅") + self.ap_title.value = self._format_title( + f"{DEFAULT_SAVE_TITLE} {AP_SETTING_SAVED}" + ) def _set_observers(self): def aperture_obs(change): diff --git a/stellarphot/gui_tools/tests/test_seeing_profile.py b/stellarphot/gui_tools/tests/test_seeing_profile.py index 5012d405..1253ea00 100644 --- a/stellarphot/gui_tools/tests/test_seeing_profile.py +++ b/stellarphot/gui_tools/tests/test_seeing_profile.py @@ -9,7 +9,13 @@ from astrowidgets import ImageWidget from photutils.datasets import make_gaussian_sources_image, make_noise_image -from stellarphot.gui_tools import seeing_profile_functions as spf +from stellarphot.gui_tools import ( + seeing_profile_functions as spf, +) +from stellarphot.gui_tools.seeing_profile_functions import ( + AP_SETTING_NEEDS_SAVE, + AP_SETTING_SAVED, +) from stellarphot.photometry.tests.test_profiles import RANDOM_SEED, SHAPE, STARS from stellarphot.settings import ( Camera, @@ -155,10 +161,10 @@ def test_seeing_profile_save_box_title(tmp_path): profile_widget.aperture_settings.di_widgets["radius"].value = 3 assert profile_widget.aperture_settings.savebuttonbar.unsaved_changes - assert "❗️" in profile_widget.ap_title.value + assert AP_SETTING_NEEDS_SAVE in profile_widget.ap_title.value profile_widget.save() - assert "✅" in profile_widget.ap_title.value + assert AP_SETTING_SAVED in profile_widget.ap_title.value def test_seeing_profile_error_messages_no_star(tmp_path):