Source code for stellarphot.gui_tools.comparison_functions

import functools
import logging

import ipyautoui
import ipywidgets as ipw
import numpy as np
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.coordinates.name_resolve import NameResolveError
from astropy.table import Table
from ipyautoui.custom import FileChooser

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

from stellarphot import SourceListData
from stellarphot.gui_tools.seeing_profile_functions import set_keybindings
from stellarphot.settings import (
    PartialPhotometrySettings,
    PhotometryWorkingDirSettings,
    SourceLocationSettings,
    ui_generator,
)
from stellarphot.settings.custom_widgets import SettingWithTitle, Spinner
from stellarphot.settings.fits_opener import FitsOpener
from stellarphot.utils.comparison_utils import (
    crossmatch_APASS2VSX,
    in_field,
    mag_scale,
    set_up,
)

__all__ = ["make_markers", "wrap", "ComparisonViewer"]

DESC_STYLE = {"description_width": "initial"}


[docs] def make_markers(iw, RD, vsx, ent, name_or_coord=None): """ Add markers for APASS, TESS targets, VSX. Also center on object/coordinate. Parameters ---------- iw : `astrowidgets.ImageWidget` Ginga widget. RD : `astropy.table.Table` Table with target information, including a `astropy.coordinates.SkyCoord` column. vsx : `astropy.table.Table` Table with known variables in the field of view. ent : `astropy.table.Table` Table with APASS stars in the field of view. name_or_coord : str or `astropy.coordinates.SkyCoord`, optional Name or coordinates of the target. Returns ------- None Markers are added to the image in Ginga widget. """ iw.zoom_level = "fit" try: iw.reset_markers() except AttributeError: iw.remove_all_markers() if RD: iw.marker = {"type": "circle", "color": "green", "radius": 10} iw.add_markers( RD, skycoord_colname="coords", use_skycoord=True, marker_name="TESS Targets" ) if name_or_coord is not None: if isinstance(name_or_coord, str): iw.center_on(SkyCoord.from_name(name_or_coord)) else: iw.center_on(name_or_coord) if vsx: # TODO: if astrowidgets ever gets more sensible marker types use those instead # of using the internal interface to marker iw._marker = functools.partial( iw.dc.Box, xradius=10, yradius=10, color="blue", ) iw.add_markers( vsx, skycoord_colname="coords", use_skycoord=True, marker_name="VSX" ) # TODO: if astrowidgets ever gets more sensible marker types use those instead # of using the internal interface to marker iw._marker = functools.partial( iw.dc.Triangle, xradius=10, yradius=10, color="red", ) iw.add_markers( ent, skycoord_colname="coords", use_skycoord=True, marker_name="APASS comparison", ) iw.marker = {"type": "cross", "color": "red", "radius": 6}
[docs] def wrap(imagewidget, outputwidget): """ Utility function to let you click to select/deselect comparisons. Parameters ---------- imagewidget : `astrowidgets.ImageWidget` Ginga widget. outputwidget : `ipywidgets.Output` Output widget for printing information. """ def cb(viewer, event, data_x, data_y): # noqa: ARG001 """ The signature of this function must have the four arguments above. """ i = imagewidget._viewer.get_image() try: imagewidget.next_elim += 1 except AttributeError: imagewidget.next_elim = 1 ra, dec = i.wcs.wcs.all_pix2world(event.data_x, event.data_y, 0) out_skycoord = SkyCoord(ra=ra, dec=dec, unit=(u.degree, u.degree)) try: all_table = imagewidget.get_markers(marker_name="all") except AttributeError: all_table = imagewidget.get_all_markers() with outputwidget: index, d2d, d3d = out_skycoord.match_to_catalog_sky(all_table["coord"]) if d2d < 10 * u.arcsec: mouse = all_table["coord"][index].separation(all_table["coord"]) rat = mouse < 1 * u.arcsec elims = [ name for name in all_table["marker name"][rat] if name.startswith("elim") ] if not elims: imagewidget.add_markers( all_table[rat], skycoord_colname="coord", use_skycoord=True, marker_name=f"elim{imagewidget.next_elim}", ) else: for elim in elims: try: imagewidget.remove_markers_by_name(marker_name=elim) except AttributeError: imagewidget.remove_markers(marker_name=elim) else: print("sorry try again") imagewidget._viewer.onscreen_message("Click closer to a star") return cb
[docs] class ComparisonViewer: """ A class to store an instance of the comparison viewer. Parameters ---------- file : str, optional File to open. Defaults to "". directory : str, optional Directory to open file from. Defaults to '.'. target_mag : float, optional Magnitude of the target. Defaults to 10. bright_mag_limit : float, optional Bright magnitude limit for APASS stars. Defaults to 8. dim_mag_limit : float, optional Dim magnitude limit for APASS stars. Defaults to 17. targets_from_file : str, optional File with target information. Defaults to None. object_coordinate : `astropy.coordinates.SkyCoord`, optional Coordinates of the target. Defaults to None. photom_apertures_file : str, optional File to save photometry aperture information to. Defaults to None. overwrite_outputs: bool, optional Whether to overwrite existing output files. Defaults to True. observatory : `stellarphot.settings.Observatory`, optional Observatory information. Attributes ---------- photom_apertures_file : str File to save photometry aperture information to. box : `ipywidgets.Box` Box containing the widgets. bright_mag_limit : float Bright magnitude limit for APASS stars. dim_mag_limit : float Dim magnitude limit for APASS stars. iw : `ginga.util.grc.RemoteClient` Ginga widget. overwrite_outputs: bool Whether to overwrite existing output files. Defaults to True. target_coord : `astropy.coordinates.SkyCoord` Coordinates of the target. targets_from_file : str File with target information. target_mag : float Magnitude of the target. variables : `astropy.table.Table` """ def __init__( self, file="", directory=".", target_mag=10, bright_mag_limit=8, dim_mag_limit=15, targets_from_file=None, object_coordinate=None, photom_apertures_file=None, overwrite_outputs=True, observatory=None, ): self._label_name = "labels" self._circle_name = "target circle" self._file_chooser = FitsOpener() self._directory = directory self.target_mag = target_mag self.bright_mag_limit = bright_mag_limit self.dim_mag_limit = dim_mag_limit self.targets_from_file = targets_from_file self.target_coord = object_coordinate self.observatory = observatory # This function defines several attributes in addition to returning the box and # image viewer. You should take a look at it to see what it does. self.box, self.iw = self._viewer() self.photometry_settings = PhotometryWorkingDirSettings() if photom_apertures_file is not None: original_settings = self.source_locations.value.copy() original_settings["source_list_file"] = photom_apertures_file self.source_locations.value = original_settings self.overwrite_outputs = overwrite_outputs if file: self._file_chooser.set_file(file, directory=directory) self._set_file(None) # Save the source location settings self.source_locations.savebuttonbar.bn_save.click() self._make_observers() def _init(self): """ Handles aspects of initialization that need to be deferred until a file is chosen. """ # Read in the ccd data here and load the image into viewer instead of doing # it behind the scenes in set_up and make_markers self.ccd = self.fits_file.ccd self.fits_file.load_in_image_widget(self.iw) spinner = Spinner(message="Loading variable/comparison stars") legend = self._legend_spinner_box.children[0] spinner.start() self._legend_spinner_box.children = [spinner] self.help_stuff.selected_index = 1 self.vsx = set_up(self.ccd) apass, vsx_apass_angle, targets_apass_angle = crossmatch_APASS2VSX( self.ccd, self.targets_from_file, self.vsx ) self._legend_spinner_box.children = [legend] apass_good_coord, good_stars = mag_scale( self.target_mag, apass, vsx_apass_angle, targets_apass_angle, brighter_dmag=self.target_mag - self.bright_mag_limit, dimmer_dmag=self.dim_mag_limit - self.target_mag, ) apass_comps = in_field(apass_good_coord, self.ccd, apass, good_stars) # Set the object here so that the viewer is properly centered self._set_object() make_markers( self.iw, self.targets_from_file, self.vsx, apass_comps, name_or_coord=self.target_coord, ) @property def fits_file(self): return self._file_chooser @property def photom_apertures_file(self): return self.source_locations.value["source_list_file"] @property def variables(self): """ An `astropy.table.Table` of the variables in the class. """ comp_table = self.generate_table() new_vsx_mark = comp_table["marker name"] == "VSX" idx, _, _ = comp_table["coord"][new_vsx_mark].match_to_catalog_sky( self.vsx["coords"] ) our_vsx = self.vsx[idx] our_vsx["star_id"] = comp_table["star_id"][new_vsx_mark] return our_vsx def _set_object(self): """ Try to automatically set object name immediately after file is chosen. """ try: self.object_name.value = ( f"<h2> Object: {self._file_chooser.header['object']}</h2>" ) self._object = self._file_chooser.header["object"] except KeyError: # No object, will show placeholder self.object_name.value = "<h2>Object unknown</h2>" self._object = "" # We have a name, try to get coordinates from it try: self.target_coord = SkyCoord.from_name(self._object) except NameResolveError: # If there are no coordinations so far then use the center of the frame. The # generation of the source list table depends on the target coordinates. self.target_coord = self.ccd.wcs.pixel_to_world( self.ccd.shape[0] / 2, self.ccd.shape[1] / 2 ) def _save_source_location_file(self): """ Save the source location file. """ # Save the initial source list if there is a file name for it and if the target # coordinates are known. The target coordinates are needed to generate the # source list table. if ( self.source_locations.value["source_list_file"] is not None and self.target_coord is not None ): self._save_aperture_to_file(None) def _set_file(self, change): # noqa: ARG002 """ Widget callbacks need to accept a change argument, even if not used. """ self._init() self._save_source_location_file() def _set_target_file(self, change): """ Set the target file. """ input_source_list_name = change["new"] self.targets_from_file = SourceListData.read(input_source_list_name) # Only call init if a file has been chosen if self._file_chooser.file_chooser.value != ".": self._init() self._save_source_location_file() def _make_observers(self): self._show_labels_button.observe(self._show_label_button_handler, names="value") self._save_var_info.on_click(self._save_variables_to_file) self._file_chooser.file_chooser.observe(self._set_file, names="_value") self._choose_input_source_list.observe(self._set_target_file, names="_value") def _save_variables_to_file(self, button=None, filename=""): # noqa: ARG002 """ Widget button callbacks need to be able to take an argument. It is called button above the the button will be passed as the first positional argument. """ if not filename: filename = "variables.csv" # Export variables as CSV (overwrite existing file if it exists) try: self.variables.write(filename, overwrite=self.overwrite_outputs) except OSError as err: raise OSError( f"Existing file ({filename}) can not be overwritten. " "Set overwrite_outputs=True to address this." ) from err def _show_label_button_handler(self, change): value = change["new"] if value: # Showing labels can take a bit, disable while in progress self._show_labels_button.disabled = True self.show_labels() self._show_labels_button.disabled = False else: self.remove_labels() self._show_labels_button.description = self._show_labels_button.descriptions[ value ] def _save_aperture_to_file(self, button=None, filename=""): # noqa: ARG002 """ Widget button callbacks need to be able to take an argument. It is called button above the the button will be passed as the first positional argument. """ if not filename: filename = self.photom_apertures_file # Convert aperture table into a SourceList objects and output targets_table = self.generate_table() # Assign units to columns targets_table["ra"] = targets_table["ra"] * u.deg targets_table["dec"] = targets_table["dec"] * u.deg targets_table["x"] = targets_table["x"] * u.pixel targets_table["y"] = targets_table["y"] * u.pixel # Drop redundant sky position column targets_table.remove_columns(["coord"]) # Build sources list targets2sourcelist = {"x": "xcenter", "y": "ycenter"} sources = SourceListData( input_data=targets_table, colname_map=targets2sourcelist ) # Export aperture file as CSV (overwrite existing file if it exists) try: sources.write(filename, overwrite=self.overwrite_outputs) except OSError as err: raise OSError( f"Existing file ({filename}) can not be overwritten." "Set overwrite_outputs=True to address this." ) from err def _make_control_bar(self): self._show_labels_button = ipw.ToggleButton(description="Click to show labels") self._show_labels_button.descriptions = { True: "Click to hide labels", False: "Click to show labels", } self._save_var_info = ipw.Button(description="Save variable info") controls = ipw.HBox( children=[ self._show_labels_button, self._save_var_info, ] ) return controls
[docs] def save(self): """ Save all of the settings we have to a partial settings file. """ self.photometry_settings.save( PartialPhotometrySettings( source_location_settings=self.source_locations.value, ), 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.source_locations.savebuttonbar.unsaved_changes = False # Update the save box title to reflect the save self.source_and_title.decorate_title()
def _viewer(self): header = ipw.HTML( value=""" <h3>Click and drag or use arrow keys to pan, use +/- keys to zoom.</h3> <h3>Shift-left click (or Crtl-left click) to exclude star as target or comp. Click again to include.</h3> """ ) legend = ipw.HTML( value=""" <ul> <li>Green circles -- Gaia stars within 2.5 arcmin of target</li> <li>Red triangles -- Comparison stars from APASS</li> <li>Blue squares -- VSX variables</li> <li>Red × -- Exclude as target or comp</li> </ul> """ ) iw = ImageWidget() out = ipw.Output() set_keybindings(iw) bind_map = iw._viewer.get_bindmap() gvc = iw._viewer.get_canvas() bind_map.map_event(None, ("shift",), "ms_left", "cursor") gvc.add_callback("cursor-down", wrap(iw, out)) self.object_name = ipw.HTML(value="<h2>Object: </h2>") self._object = None controls = self._make_control_bar() # Capture the logging message issued about setting the file chooser to a file # that does not exist. original_logging_level = ipyautoui.custom.filechooser.logger.level ipyautoui.custom.filechooser.logger.setLevel(logging.CRITICAL) # The logging message will be generated here if it is generated. self.source_locations = ui_generator( SourceLocationSettings, max_field_width="75px" ) ipyautoui.custom.filechooser.logger.setLevel(original_logging_level) self.source_and_title = SettingWithTitle( "Source location settings", self.source_locations ) self.source_locations.savebuttonbar.fns_onsave_add_action(self.save) # Put the legend in a box whose children can be changed to a # spinner while loading. self._legend_spinner_box = ipw.VBox() self._legend_spinner_box.children = [legend] self.help_stuff = ipw.Accordion(children=[header, self._legend_spinner_box]) self.help_stuff.titles = ["Help", "Legend"] box = ipw.VBox() inner_box = ipw.HBox() source_legend_box = ipw.VBox() source_legend_box.children = [ self.help_stuff, self.object_name, self.source_and_title, ] inner_box.children = [iw, source_legend_box] # Add a file chooser for an input source list self._choose_input_source_list = FileChooser( filter_pattern="*.ecsv", ) self._choose_input_source_list.title = "OPTIONAL: Choose input source list" box.children = [ self._file_chooser.file_chooser, self._choose_input_source_list, inner_box, controls, ] return box, iw
[docs] def generate_table(self): """ Generate the table of stars to use for the aperture file. Returns ------- comp_table : `astropy.table.Table` Table of stars to use for the aperture file. """ try: all_table = self.iw.get_all_markers() except AttributeError: all_table = self.iw.get_markers(marker_name="all") elims = np.array([name.startswith("elim") for name in all_table["marker name"]]) elim_table = all_table[elims] comp_table = all_table[~elims] index, d2d, d3d = elim_table["coord"].match_to_catalog_sky(comp_table["coord"]) comp_table.remove_rows(index) # Add separate RA and Dec columns for ease in processing later comp_table["ra"] = comp_table["coord"].ra.degree comp_table["dec"] = comp_table["coord"].dec.degree # Calculate how far each is from target comp_table["separation"] = self.target_coord.separation(comp_table["coord"]) # Add dummy column for sorting in the order we want comp_table["sort"] = np.zeros(len(comp_table)) # Set sort order apass_mark = comp_table["marker name"] == "APASS comparison" vsx_mark = comp_table["marker name"] == "VSX" tess_mark = (comp_table["marker name"] == "TESS Targets") | ( comp_table["separation"] < 0.3 * u.arcsec ) comp_table["sort"][apass_mark] = 2 comp_table["sort"][vsx_mark] = 1 comp_table["sort"][tess_mark] = 0 # Sort the table comp_table.sort(["sort", "separation"]) # Assign the IDs comp_table["star_id"] = range(1, len(comp_table) + 1) return comp_table
[docs] def show_labels(self): """ Show the labels for the stars. Returns ------- None Labels for the stars are shown. """ plot_names = [] comp_table = self.generate_table() label_size = 15 original_mark = self.iw._marker for star in comp_table: star_id = star["star_id"] if star["marker name"] == "TESS Targets": label = f"T{star_id}" self.iw._marker = functools.partial( self.iw.dc.Text, text=label, fontsize=label_size, fontscale=False, color="green", ) self.iw.add_markers( Table(data=[[star["x"] + 20], [star["y"] - 20]], names=["x", "y"]), marker_name=self._label_name, ) elif star["marker name"] == "APASS comparison": label = f"C{star_id}" self.iw._marker = functools.partial( self.iw.dc.Text, text=label, fontsize=label_size, fontscale=False, color="red", ) self.iw.add_markers( Table(data=[[star["x"] + 20], [star["y"] - 20]], names=["x", "y"]), marker_name=self._label_name, ) elif star["marker name"] == "VSX": label = f"V{star_id}" self.iw._marker = functools.partial( self.iw.dc.Text, text=label, fontsize=label_size, fontscale=False, color="blue", ) self.iw.add_markers( Table(data=[[star["x"] + 20], [star["y"] - 20]], names=["x", "y"]), marker_name=self._label_name, ) else: label = f"U{star_id}" print(f"Unrecognized marker name: {star['marker name']}") plot_names.append(label) self.iw._marker = original_mark
[docs] def remove_labels(self): """ Remove the labels for the stars. Returns ------- None Labels for the stars are removed. """ try: try: self.iw.remove_markers(marker_name=self._label_name) except AttributeError: self.iw.remove_markers_by_name(marker_name=self._label_name) except ValueError: # No labels, keep going pass
[docs] def show_circle(self, radius=2.5 * u.arcmin, pixel_scale=0.56 * u.arcsec / u.pixel): """ Show a circle around the target. Parameters ---------- radius : `astropy.units.Quantity`, optional Radius of circle. The default is ``2.5*u.arcmin``. pixel_scale : `astropy.units.Quantity`, optional Pixel scale of image. The default is ``0.56*u.arcsec/u.pixel``. Returns ------- None Circle is shown around the target. """ radius_pixels = np.round((radius / pixel_scale).to(u.pixel).value, decimals=0) orig_marker = self.iw.marker self.iw.marker = {"color": "yellow", "radius": radius_pixels, "type": "circle"} self.iw.add_markers( Table(data=[[self.target_coord]], names=["coords"]), skycoord_colname="coords", use_skycoord=True, marker_name=self._circle_name, ) self.iw.marker = orig_marker
[docs] def remove_circle(self): """ Remove the circle around the target. Returns ------- None Circle is removed from the image. """ try: self.iw.remove_markers(marker_name=self._circle_name) except AttributeError: self.iw.remove_markers_by_name(marker_name=self._circle_name)
[docs] def tess_field_view(self): """ Show the whole TESS field of view including circle around target, but hide labels. Returns ------- None Shows image as described above. """ # Show whole field of view self.iw.zoom_level = "fit" # Show the circle self.show_circle() # Turn off labels -- too cluttered self.remove_labels()
[docs] def tess_field_zoom_view(self, width=6 * u.arcmin): """ Zoom in on the TESS field of view. Parameters ---------- width : `astropy.units.Quantity`, optional Width of field of view. The default is ``6*u.arcmin``. Returns ------- None Zooms in on the image as described above. """ # Turn off labels -- too cluttered self.remove_labels() left_side = self.ccd.wcs.pixel_to_world(0, self.ccd.shape[1] / 2) right_side = self.ccd.wcs.pixel_to_world( self.ccd.shape[0], self.ccd.shape[1] / 2 ) fov = left_side.separation(right_side) view_ratio = width / fov # fit first to get zoom level at full field of view self.iw.zoom_level = "fit" # Then set it to what we actually want... self.iw.zoom_level = self.iw.zoom_level / view_ratio # Show the circle self.show_circle()