Source code for stellarphot.gui_tools.seeing_profile_functions

import warnings

import ipywidgets as ipw
import numpy as np
from astropy.io import fits
from astropy.table import Table

try:
    from astrowidgets import ImageWidget
except ImportError:
    from astrowidgets.ginga import ImageWidget

import matplotlib.pyplot as plt

from stellarphot.io import TessSubmission
from stellarphot.photometry import CenterAndProfile
from stellarphot.photometry.photometry import EXPOSURE_KEYWORDS
from stellarphot.plotting import seeing_plot
from stellarphot.settings import (
    PartialPhotometrySettings,
    PhotometryApertures,
    PhotometryWorkingDirSettings,
    SavedSettings,
    ui_generator,
)
from stellarphot.settings.custom_widgets import ChooseOrMakeNew
from stellarphot.settings.fits_opener import FitsOpener

__all__ = [
    "set_keybindings",
    "SeeingProfileWidget",
]

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
# other widgets using this.
[docs] def set_keybindings(image_widget, scroll_zoom=False): """ Set image widget keyboard bindings. The bindings are: + Pan by click-and-drag or with arrow keys. + Zoom by scrolling or using the ``+``/``-`` keys. + Adjust contrast by Ctrl-right click and drag + Reset contrast with shift-right-click. Any existing key bindings are removed. Parameters ---------- image_widget : `astrowidgets.ImageWidget` Image widget on which to set the key bindings. scroll_zoom : bool, optional If True, zooming can be done by scrolling the mouse wheel. Default is False. Returns ------- None Adds key bindings to the image widget. """ bind_map = image_widget._viewer.get_bindmap() # Displays the event map... # bind_map.eventmap bind_map.clear_event_map() bind_map.map_event(None, (), "ms_left", "pan") if scroll_zoom: bind_map.map_event(None, (), "pa_pan", "zoom") # bind_map.map_event(None, (), 'ms_left', 'cursor') # contrast with right mouse bind_map.map_event(None, (), "ms_right", "contrast") # shift-right mouse to reset contrast bind_map.map_event(None, ("shift",), "ms_right", "contrast_restore") bind_map.map_event(None, ("ctrl",), "ms_left", "cursor") # Bind +/- to zoom in/out bind_map.map_event(None, (), "kp_+", "zoom_in") bind_map.map_event(None, (), "kp_=", "zoom_in") bind_map.map_event(None, (), "kp_-", "zoom_out") bind_map.map_event(None, (), "kp__", "zoom_out") # Bind arrow keys to panning # There is NOT a typo below. I want the keys to move the image in the # direction of the arrow bind_map.map_event(None, (), "kp_left", "pan_right") bind_map.map_event(None, (), "kp_right", "pan_left") bind_map.map_event(None, (), "kp_up", "pan_down") bind_map.map_event(None, (), "kp_down", "pan_up")
[docs] class SeeingProfileWidget: """ A class for storing an instance of a widget displaying the seeing profile of stars in an image. Parameters ---------- imagewidget : `astrowidgets.ImageWidget`, optional ImageWidget instance to use for the seeing profile. width : int, optional Width of the seeing profile widget. Default is 500 pixels. camera : `stellarphot.settings.Camera`, optional Camera instance to use for calculating the signal to noise ratio. If ``None``, the signal to noise ratio will not be calculated. observatory : `stellarphot.settings.Observatory`, optional Observatory instance to use for setting the TESS telescope information. If `None`, or if the `~stellarphot.settings.Observatory.TESS_telescope_code` is `None`, the TESS settings will not be displayed. Attributes ---------- ap_t : `ipywidgets.IntText` Text box for the aperture radius. box : `ipywidgets.VBox` Box containing the seeing profile widget. container : `ipywidgets.VBox` Container for the seeing profile widget. object_name : str Name of the object in the FITS file. in_t : `ipywidgets.IntText` Text box for the inner annulus. iw : `astrowidgets.ImageWidget` ImageWidget instance used for the seeing profile. object_name : str Name of the object in the FITS file. out : `ipywidgets.Output` Output widget for the seeing profile. out2 : `ipywidgets.Output` Output widget for the integrated counts. out3 : `ipywidgets.Output` Output widget for the SNR. out_t : `ipywidgets.IntText` Text box for the outer annulus. rad_prof : `RadialProfile` Radial profile of the star. save_aps : `ipywidgets.Button` Button to save the aperture settings. tess_box : `ipywidgets.VBox` Box containing the TESS settings. """ 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 ) self.photometry_settings = PhotometryWorkingDirSettings() self.iw = imagewidget 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() bind_map.map_event(None, ("shift",), "ms_left", "cursor") gvc = self.iw._viewer.get_canvas() self._mse = self._make_show_event() gvc.add_callback("cursor-down", self._mse) # Outputs to hold the graphs 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=self._format_title("Choose an image")) self.camera_chooser = ChooseOrMakeNew( "camera", details_hideable=True, _testing_path=_testing_path ) if camera is not None: self.camera_chooser._choose_existing.value = camera # Do not show the camera details by default self.camera_chooser.display_details = False image_camer_box = ipw.HBox() image_camer_box.children = [self.fits_file.file_chooser, self.camera_chooser] im_view_plot_box = ipw.GridspecLayout(1, 2) # Box for aperture settings and title ap_setting_box = ipw.VBox() 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) ap_setting_box.children = [ self.ap_title, self.aperture_settings, ] plot_box = ipw.VBox() plt_tabs = ipw.Tab() plt_tabs.children = [ self.snr_plot, self.seeing_profile_plot, self.curve_growth_plot, ] plt_tabs.titles = [ "SNR", "Seeing profile", "Integrated counts", ] self.tess_box = self._make_tess_box() plot_box.children = [plt_tabs, self.tess_box] imbox = ipw.VBox() imbox.children = [imagewidget] 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. 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" 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 self._set_observers() self.aperture_settings.description = "" @property def camera(self): return self.camera_chooser.value
[docs] def load_fits(self): """ Load a FITS file into the image widget. """ self.fits_file.load_in_image_widget(self.iw) self.object_name = self.fits_file.object for key in EXPOSURE_KEYWORDS: if key in self.fits_file.header: self.exposure = self.fits_file.header[key] break else: # apparently setting a higher stacklevel is better, see # https://docs.astral.sh/ruff/rules/no-explicit-stacklevel/ warnings.warn( "No exposure time keyword found in FITS header. " "Setting exposure to NaN", stacklevel=2, ) self.exposure = np.nan
[docs] 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 ), 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. """ return f"<h2>{title}</h2>" def _update_file(self, change): # noqa: ARG002 # Widget callbacks need to accept a single argument, even if it is not used. self.load_fits() def _construct_tess_sub(self): file = self.fits_file.path self._tess_sub = TessSubmission.from_header( fits.getheader(file), telescope_code=self.setting_box.telescope_code.value, planet=self.setting_box.planet_num.value, ) def _set_seeing_profile_name(self, change): # noqa: ARG002 """ Widget callbacks need to accept a single argument, even if it is not used. """ self._construct_tess_sub() self.seeing_file_name.value = self._tess_sub.seeing_profile def _save_toggle_action(self, change): activated = change["new"] if activated: self.setting_box.layout.visibility = "visible" self._set_seeing_profile_name("") else: self.setting_box.layout.visibility = "hidden" def _save_seeing_plot(self, button): # noqa: ARG002 """ Widget button callbacks need to accept a single argument. """ self._seeing_plot_fig.savefig(self.seeing_file_name.value) 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( f"{DEFAULT_SAVE_TITLE} {AP_SETTING_NEEDS_SAVE}" ) else: self.ap_title.value = self._format_title( f"{DEFAULT_SAVE_TITLE} {AP_SETTING_SAVED}" ) def _set_observers(self): def aperture_obs(change): self._update_plots() ape = PhotometryApertures(**change["new"]) self.aperture_settings.description = ( f"Aperture radius: {ape.radius_pixels(ape.fwhm_estimate):.2f} pix, " f"Inner annulus: {ape.inner_annulus:.2f} pix, " f"outer annulus: {ape.outer_annulus:.2f} pix" ) 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) self.setting_box.planet_num.observe(self._set_seeing_profile_name) self.setting_box.telescope_code.observe(self._set_seeing_profile_name) def _make_tess_box(self): box = ipw.VBox() if self.observatory is None or self.observatory.TESS_telescope_code is None: """ No TESS information, so definitely don't display this group of settings. """ box.layout.flex_flow = "row wrap" box.layout.visibility = "hidden" self.save_toggle = None return box setting_box = ipw.HBox() self.save_toggle = ipw.ToggleButton( description="TESS seeing profile...", disabled=True ) scope_name = ipw.Text( description="Telescope code", value=self.observatory.TESS_telescope_code, style=DESC_STYLE, ) planet_num = ipw.IntText(description="Planet", value=1) self.save_seeing = ipw.Button(description="Save") self.seeing_file_name = ipw.Label(value="Moo") setting_box.children = ( scope_name, planet_num, self.seeing_file_name, self.save_seeing, ) # for kid in setting_box.children: # kid.disabled = True box.children = (self.save_toggle, setting_box) setting_box.telescope_code = scope_name setting_box.planet_num = planet_num setting_box.layout.flex_flow = "row wrap" setting_box.layout.visibility = "hidden" self.setting_box = setting_box return box def _update_ap_settings(self, value): self.aperture_settings.value = value def _make_show_event(self): def show_event( viewer, event=None, datax=None, datay=None, aperture=None # noqa: ARG001 ): """ ginga callbacks require the function signature above. """ profile_size = 60 centering_cutout_size = 20 default_gap = 5 # pixels default_annulus_width = 15 # pixels if self.save_toggle: self.save_toggle.disabled = False update_aperture_settings = False if event is not None: # 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)) try: rad_prof = CenterAndProfile( data, (x, y), profile_radius=profile_size, 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): # 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( "<strong>No star found at this location. " "Try clicking closer " "to a star or on a brighter star</strong>" ) ) print(f"{self.error_console.outputs=}") return else: # RadialProfile did not generate this error, pass it # on to the user raise e # pragma: no cover else: # Success, clear any previous error messages self.error_console.clear_output() try: try: # Remove previous marker self.iw.remove_markers(marker_name=self._aperture_name) except AttributeError: self.iw.remove_markers_by_name(marker_name=self._aperture_name) except ValueError: # No markers yet, keep going pass # ADD MARKER WHERE CLICKED self.iw.add_markers( Table( data=[[rad_prof.center[0]], [rad_prof.center[1]]], names=["x", "y"], ), marker_name=self._aperture_name, ) # Default is 1.5 times FWHM aperture_radius = np.round(1.5 * rad_prof.FWHM, 0) self.rad_prof = rad_prof # Make an aperture settings object, but don't update it's widget yet. ap_settings = PhotometryApertures( radius=aperture_radius, gap=default_gap, annulus_width=default_annulus_width, fwhm_estimate=rad_prof.FWHM, ) update_aperture_settings = True else: # User changed aperture aperture_radius = aperture["radius"] ap_settings = PhotometryApertures( **aperture ) # Make an aperture settings object, but don't update it's widget yet. if update_aperture_settings: # 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() return show_event 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) with self.seeing_profile_plot: r_exact, individual_counts = rad_prof.pixel_values_in_profile scaled_exact_counts = ( individual_counts / rad_prof.radial_profile.profile.max() ) self._seeing_plot_fig = seeing_plot( r_exact, scaled_exact_counts, rad_prof.radial_profile.radius, rad_prof.normalized_profile, rad_prof.HWHM, self.object_name, photometry_settings=ap_settings, figsize=fig_size, ) plt.show() # CALCULATE AND DISPLAY NET COUNTS INSIDE RADIUS self.curve_growth_plot.clear_output(wait=True) with self.curve_growth_plot: cog = rad_prof.curve_of_growth plt.figure(figsize=fig_size) plt.plot(cog.radius, cog.profile) plt.xlim(0, 40) ylim = plt.ylim() plt.vlines( ap_settings.radius_pixels(rad_prof.FWHM), *plt.ylim(), colors=["red"] ) plt.ylim(*ylim) plt.grid() plt.title("Net counts in aperture") plt.xlabel("Aperture radius (pixels)") plt.ylabel(f"Net counts ({self.camera.data_unit})") plt.show() # CALCULATE And DISPLAY SNR AS A FUNCTION OF RADIUS self.snr_plot.clear_output(wait=True) with self.snr_plot: plt.figure(figsize=fig_size) snr = rad_prof.snr(self.camera, self.exposure) plt.plot(rad_prof.curve_of_growth.radius, snr) plt.title( f"Signal to noise ratio max {snr.max():.1f} " f"at radius {snr.argmax() + 1}" ) plt.xlim(0, 40) ylim = plt.ylim() plt.vlines( ap_settings.radius_pixels(rad_prof.FWHM), *plt.ylim(), colors=["red"] ) plt.ylim(*ylim) plt.xlabel("Aperture radius (pixels)") plt.ylabel("SNR") plt.grid() plt.show()