Source code for rubin_sim.maf.plots.hg_plotters

"""Second generation hourglass plotting classes.
"""

# pylint: disable=too-many-arguments
# pylint: disable=super-init-not-called
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-few-public-methods
__all__ = (
    "GeneralHourglassPlot",
    "CategoricalHourglassPlotMixin",
    "RangeHourglassCategoricalPlot",
    "MonthHourglassCategoricalPlot",
    "MonthHourglassUsePlot",
    "TimeUseHourglassPlotMixin",
    "MonthHourglassPlot",
    "YearHourglassCategoricalPlot",
    "YearHourglassPlot",
    "YearHourglassUsePlot",
)

import calendar
import copy
import logging
from collections import OrderedDict

import astropy.coordinates
import astropy.time
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from astropy import units as u
from rubin_scheduler.utils.ddf_locations import ddf_locations
from rubin_scheduler.utils.riseset import riseset_times

from .plot_handler import BasePlotter


[docs] class GeneralHourglassPlot(BasePlotter): """Make an hourglass plot Parameters ---------- tz : `str` The timezone to use site : `str` The site name (sent to `astropy.coordinates.EarthLocation.of_site`) solar_time : `bool` Use solar time as the x axis (instead of civil time)? marked_ra : `dict` A dictionary of RA values (in deg) for which to label transit lines. A general feature of the hourglass plot is that you can pass more data (such as the metric values, calculated for the entire survey) to the plotter than are actually included in the plot. The plotter will pull out the select subset of data, defined by kwargs. """ def __init__( self, tz="Chile/Continental", site="Cerro Pachon", solar_time=True, marked_ra=None, ): self.plot_type = "GeneralizedHourglass" self.object_plotter = False # pylint: disable=invalid-name self.default_plot_dict = { "title": None, "xlabel": "Hours after midnight", "ylabel": "MJD", "x_min": -6, "x_max": 6, "figsize": None, "cmap": plt.get_cmap("viridis"), "colorbar": True, "legend": False, "legend_ncols": 2, "legend_bbox_to_anchor": (0.9, 0.0), "legend_loc": "lower right", } self.plot_dict = copy.copy(self.default_plot_dict) # pylint: disable=invalid-name self.tz = tz # pylint: disable=invalid-name if isinstance(site, str): self.site = astropy.coordinates.EarthLocation.of_site(site) else: self.site = site self.solar_time = solar_time self.color_map = {} self.marked_ra = {} if marked_ra is None else marked_ra def _add_axis_labels(self, ax): # pylint: disable=invalid-name, no-self-use """Add title/x/y labels to one set of axes. Parameters ---------- ax : `matplotlib.axes.Axes` The axis to which to add labels """ # Isolate code to add axis titles and labels because we need # to be able to call it independently of the actually plotting, # so that when we plot grids of axes on the same figure # we don't have overlaps and repeats. ax.set_title(self.plot_dict["title"]) ax.set_xlabel(self.plot_dict["xlabel"]) ax.set_ylabel(self.plot_dict["ylabel"]) def _plot(self, fig, intervals): """Create axes for the data and add the data itself. This method does not add the legend or color bar. Use the __call__ method to combine all needed steps: handling creation of the intervals DataFrame, calling this method, and then adding any necessary legends or colorbars. Parameters ---------- fig : `matplotlib.figure.Figure` figure on which to plot intervals : `pandas.DataFrame` data to be plotted, with "mjd", "duration", and "value" columns. "duration" should be in units of hours. Returns ------- color_mappable : `matplotlib.cm.ScalarMappable` map of scalar data to colors axes : numpy.array a numpy array of matplotlib axes. """ # This is similar to the __call__ method # in other plotters, but isolates the making of the plot itself # from other things __call__ needs to do, such as reformat the # data and add figure-wide elements like the legend and color # bar, so that these tasks can be replaced independently in # subclases without needing to repeat the other elements of # call, which might not change. # Subclasses should override this method to change # how the plotting itself is done, as distinct from # restructuring the data or modifying other elements of the # figure such as the color bar and legend. ax = fig.add_axes([0, 0, 1, 1]) # pylint: disable=invalid-name color_mappable = self._plot_one_axis(intervals, ax) self._add_axis_labels(ax, self.plot_dict) return [color_mappable, np.array([ax])] def _plot_one_axis(self, intervals, ax, epoch_mjd=None): # pylint: disable=invalid-name """Actually add the data to one axis of an hourglass plot includes adding the twilight/transit lines, does not include any supporting infrastructure or plot elements: it does not create the figure or axis, or add labels, color bars, or other elements of the plot that do not represent the data itself. Parameters ---------- intervals : `pandas.DataFrame` data to be plotted, with "mjd", "duration", and "value" columns. "duration" should be in units of hours. ax : `matplotlib.axes.Axes` the axes on which to plot data epoch_mjd : `float` the MJD used to set y limits for the plot """ # Add content relevant to an individual axis. # It is separated from _plot so that it can be called # in different subplots, while _plot drives the the content # of all subplots. # Add the 'block' data if len(intervals) > 0: if self.solar_time: ( night_mjds, hours_after_midnight, ) = _compute_hours_after_solar_midnight(intervals["mjd"].values, self.site) else: ( night_mjds, hours_after_midnight, ) = _compute_hours_after_midnight(intervals["mjd"].values) color_mappable = self._plot_value_bars( intervals["value"], night_mjds, hours_after_midnight, intervals["duration"], epoch_mjd, ax, ) else: color_mappable = None try: ax.set_ylim(self.plot_dict["y_max"], self.plot_dict["y_min"]) except KeyError: ax.set_ylim(ax.get_ylim()[-1] + 1, -0.5) ax.set_xlim(self.plot_dict["x_min"], self.plot_dict["x_max"]) start_mjd = ax.get_ylim()[1] + epoch_mjd end_mjd = ax.get_ylim()[0] + epoch_mjd # Add the twilight boundaries and moon transit lines _astron_hourglass(start_mjd + 0.5, end_mjd + 1, ax, self.site, self.solar_time) # Add the transits self._plot_transits(start_mjd + 0.5, end_mjd + 1, ax) return color_mappable def _plot_value_bars( # pylint: disable=invalid-name self, values, night_mjds, hours_after_midnight, duration, epoch_mjd, ax, ): """Add the color bar blocks for the metric values. Parameters ---------- values : `numpy.array` values to assign to the value bars night_mjds : `numpy.array` array if integer MJDs of the nights for each bar. hours_after_midnight : `numpy.array` array of times (hours after midnight) for the start times of each bar. duration : `numpy.array` array of durations (in hours) for each bar epoch_mjd : `int` the reference MJD of the vertical axis of the plot, for example the start of the month if the vertical axis is to be labeled with the day of month. ax : `matplotlib.axes.Axes` the axes on which to plot the bars Returns ------- color_mappable : `matplotlib.cm.ScalarMappable` relates values to colors for the bars (see the matplotlib documentation) """ # Drives the plotting of the elements that represent the metric itself, # changing as a function of time. # Find the colors to be used. colors, color_mappable = self._map_colors(values) # Add the bars. ax.bar( hours_after_midnight, height=0.8, width=duration, bottom=night_mjds - epoch_mjd - 0.4, color=colors, linewidth=0, align="edge", ) return color_mappable def _map_colors(self, values): # pylint: disable=invalid-name, no-self-use """Get colors corresponding to values Paramaters ---------- values : `numpy.array` Values for which to get colors Returns ------- colors : `numpy.array` colors corresponding to the provided values color_mappable : `matplotlib.cm.ScalarMappable` relates values to colors for the bars (see the matplotlib documentation) """ # Manages the mapping of metric values to colors in the # horizonatal lines in the hourglass plot. cmap = self.plot_dict["cmap"] if values.max() == values.min(): colors = cmap(0.5) else: try: color_limits = self.plot_dict["color_limits"] except KeyError: color_limits = (values.min(), values.max()) colors = cmap((values - color_limits[0]) / (color_limits[1] - color_limits[0])) norm = mpl.colors.Normalize(vmin=color_limits[0], vmax=color_limits[1]) color_mappable = plt.cm.ScalarMappable(cmap=cmap, norm=norm) color_mappable.set_array([]) return colors, color_mappable
[docs] def __call__(self, metric_value, slicer, user_plot_dict, fig=None): """Restructure the metric data to use, and build the figure. Parameters ---------- metric_value : `numpy.ma.MaskedArray` The metric values from the bundle. slicer : `rubin_sim.maf.slicers.TwoDSlicer` The slicer. user_plot_dict: `dict` Dictionary of plot parameters set by user (overrides default values). fig : `matplotlib.figure.Figure` Matplotlib figure number to use. Default = None, starts new figure. Returns ------- fig : `matplotlib.figure.Figure` Figure with the plot. """ # Highest level driver for the plotter. # Prepares data structures and figure-wide elements # Building the figure entails plotting it the relevant # class._plot function and adding plot decorations with # the relevant class._add_colorbar and class._add_figure_legend # methods. intervals = pd.DataFrame( { "mjd": slicer.slice_points["mjd"], "duration": (slicer.slice_points["duration"] * u.s).to_value( u.hour # pylint: disable=no-member ), "value": metric_value, } ) # Set up a copy of the plot dict to contain entries relevant # for this plot self.plot_dict = copy.copy(self.default_plot_dict) # pylint: disable=invalid-name self.plot_dict.update(user_plot_dict) # Generate the figure if fig is None: fig = plt.figure(figsize=self.plot_dict["figsize"]) # Add the plots color_mappable, axes = self._plot(fig, intervals) # add the colorbar, if requested if self.plot_dict["colorbar"] and color_mappable is not None: self._add_colorbar(fig, color_mappable, axes) # add the legend, if requested if self.plot_dict["legend"] and len(self.color_map) > 0: fig = self._add_figure_legend(fig, axes) return fig
def _add_figure_legend(self, fig, axes): """Creates and adds the figure legend. Parameters ---------- fig : `matplotlib.figure.Figure` figure on which to plot axes : `matplotlib.axes.Axes` axes from which to extract automatic labels. Returns ------- fig : `matplotlib.figure.Figure` The matplotlib figure """ # Creates and adds the figure legend. This method follows two # stages: first, it adds entries for each element in the color # map (defined in self.color_map). Then, it adds any # additional elements of the plats that were given matplotlib # labels, but are not already in the legend by way of the # color map. The user may limit the enties in the color map # by way of the legend_elements key in the plot_dict, which if # preset limits the elements of the key to only those # requested. # Build a dictionary and build lable and handle lists # later, so that we can ensure that the order of the # labels in the keys matches the order specified by the # user, if present. handle_dict = OrderedDict() # Label everything we have assigned a color for label in self.color_map: if "legend_elements" in self.plot_dict: if label not in self.plot_dict["legend_elements"]: continue handle_dict[label] = mpl.patches.Patch(facecolor=self.color_map[label], label=label) # If we missed anything we have assigned a label while # plotting, automatically pick it up, but avoid # duplication auto_handles, auto_labels = axes.T.flatten()[-1].get_legend_handles_labels() for auto_handle, auto_label in zip(auto_handles, auto_labels): handle_dict[auto_label] = auto_handle # If the user specified which elements they wanted labels, # include only them (if present). # Otherwise, unclude all defined labels if "legend_elements" in self.plot_dict: desired_labels = self.plot_dict["legend_elements"] else: desired_labels = handle_dict.keys() labels = [lbl for lbl in desired_labels if lbl in handle_dict] handles = [handle_dict[lbl] for lbl in desired_labels if lbl in handle_dict] fig.legend( handles, labels, loc=self.plot_dict["legend_loc"], ncol=self.plot_dict["legend_ncols"], bbox_to_anchor=self.plot_dict["legend_bbox_to_anchor"], ) return fig def _add_colorbar(self, fig, color_mappable, axes): # pylint: disable=invalid-name, no-self-use """Add a colorbar. Parameters ---------- fig : `matplotlib.figure.Figure` figure on which to plot color_mappable : `matplotlib.cm.ScalarMappable` map of scalar data to colors axes : `matplotlib.axes.Axes` The axes for which to add the colorbar """ colorbar_kwargs = {"fraction": 0.05} if "colorbar_ticks" in self.plot_dict: colorbar_kwargs["ticks"] = self.plot_dict["colorbar_ticks"] fig.colorbar(color_mappable, ax=axes, **colorbar_kwargs) def _plot_dates( # pylint: disable=invalid-name self, intervals, start_tstamp, end_tstamp, ax, epoch_tstamp=None, ): """Plot data on one axis for a range of dates. Parameters ---------- intervals : `pandas.DataFrame` data to be plotted, with "mjd", "duration", and "value" columns. "duration" should be in units of hours. start_tstamp : `pandas.Timestamp` The first time to include in the axes end_tstamp : `pandas.Timestamp` The last time to include in the axes ax : `matplotlib.axes.Axes` the axes on which to plot data epoch_tstamp : `float` the time used to set y limits for the plot Returns ------- color_mappable : `matplotlib.cm.ScalarMappable` map of data to colors """ if self.plot_dict is None: self.plot_dict = {} start_mjd = start_tstamp.to_julian_date() - 2400000.5 end_mjd = end_tstamp.to_julian_date() - 2400000.5 epoch_mjd = 0 if epoch_tstamp is None else epoch_tstamp.to_julian_date() - 2400000.5 these_intervals = intervals.query(f"{start_mjd}<mjd<{end_mjd}") color_mappable = self._plot_one_axis(these_intervals, ax, epoch_mjd) return color_mappable def _plot_month(self, intervals, month, year, ax): """Plots data as function of date in the month. intervals : `pandas.DataFrame` data to be plotted, with "mjd", "duration", and "value" columns. "duration" should be in units of hours. month : `int` Month number whose dates to plot year : `int` Year of the month to plot ax : `matplotlib.axes.Axes` the axes on which to plot data Returns ------- color_mappable : `matplotlib.cm.ScalarMappable` map of data to colors """ if self.plot_dict is None: self.plot_dict = {} start_tstamp = pd.Timestamp(year=year, month=month, day=1, hour=12, tz=self.tz) end_tstamp = start_tstamp + pd.DateOffset(months=1) epoch_tstamp = pd.Timestamp(year=year, month=month, day=1, hour=0, tz="UTC") - pd.DateOffset(days=1) color_mappable = self._plot_dates( intervals, start_tstamp, end_tstamp, ax, epoch_tstamp=epoch_tstamp, ) ax.set_yticks(np.arange(1, 32, 7)) return color_mappable def _plot_transits(self, start_mjd, end_mjd, ax): # pylint: disable=invalid-name """Plot transit lines on a set of axes. Parameters ---------- start_mjd : `int` Start time for the axes end_mjd: `int` End time for the exes ax: `matplotlib.axes.Axes` the axes on which to plot transit lines """ # Add transit lines for each of the RA values # it self.marked_ra. mjds = np.arange(start_mjd, end_mjd + 1, 1) for field in self.marked_ra: ra = self.marked_ra[field] # pylint: disable=invalid-name try: color = self.color_map[field] except KeyError: continue transit_mjds = _compute_coord_transit_mjds(mjds, ra, self.site) if self.solar_time: ( transit_nights, transit_hams, ) = _compute_hours_after_solar_midnight(transit_mjds, self.site) else: transit_nights, transit_hams = _compute_hours_after_midnight(transit_mjds) ax.plot( transit_hams, transit_nights - start_mjd + 1, color=color, label=field + "-transit", )
# # Plot appearance subclasses # # The following subclasses change the arrangement of dates on the # hourglass plot. class RangeHourglassPlot(GeneralHourglassPlot): """Make an hourglass plot for a range of dates Parameters ---------- start_date : `str` The start date (in a format parseable by `pandas.Timestamp`) end_date : `str` The end date (in a format parseable by `pandas.Timestamp`) Keyward arguments are passed to `GeneralHourglassPlot` """ def __init__(self, start_date, end_date, **kwargs): super().__init__(**kwargs) self.plot_type = "RangeHourglass" self.start_date = pd.Timestamp(start_date) self.end_date = pd.Timestamp(end_date) def _plot(self, fig, intervals): """Make the hourglass plot for a range of dates. This method does not add the legend or color bar. Use the __call__ method to handle creation of the intervals DataFrame, call this method, and then add any necessary legends or colorbars. Parameters ---------- fig : `matplotlib.figure.Figure` figure on which to plot intervals : `pandas.DataFrame` data to be plotted, with "mjd", "duration", and "value" columns. "duration" should be in units of hours. """ ax = fig.add_axes([0, 0, 1, 1]) # pylint: disable=invalid-name color_mappable = self._plot_dates(intervals, self.start_date, self.end_date, ax) self._add_axis_labels(ax, self.plot_dict) return color_mappable, np.array([ax])
[docs] class MonthHourglassPlot(GeneralHourglassPlot): """Make an hourglass plot for a month Parameters ---------- month : `int` The month number (1-12). year : `int` The year. Keyward arguments are passed to `GeneralHourglassPlot` Note that this pulls the chosen month's data out of the metric values calculated for the entire survey. Unsubclassed, this class expects to obtain float/int data. To use it with other data, its subclass that includes the appropriate mixin (MonthHourglassCategoricalPlot or MonthHourglassUsePlot) or a custom subclass. """ def __init__(self, month, year, **kwargs): super().__init__(**kwargs) self.plot_type = "MonthHourglass" self.default_plot_dict["ylabel"] = "day of month" self.default_plot_dict["y_max"] = calendar.monthrange(year, month)[1] + 0.5 self.default_plot_dict["y_min"] = 0.5 self.month = month self.year = year def _plot(self, fig, intervals): """Make the hourglass plot for a month. This method does not add the legend or color bar. Use the __call__ method to handle creation of the intervals DataFrame, call this method, and then add any necessary legends or colorbars. Parameters ---------- fig : `matplotlib.figure.Figure` figure on which to plot intervals : `pandas.DataFrame` data to be plotted, with "mjd", "duration", and "value" columns. "duration" should be in units of hours. """ ax = fig.add_axes([0, 0, 1, 1]) # pylint: disable=invalid-name color_mappable = self._plot_month(intervals, self.month, self.year, ax) self._add_axis_labels(ax) return color_mappable, np.array([ax])
[docs] class YearHourglassPlot(GeneralHourglassPlot): """Make an array of monthly hourglass plots for a year. Parameters ---------- year : `int` The year. Keyward arguments are passed to `GeneralHourglassPlot` Note that this plot pulls the chosen year's data out of the metric values calculated for the entire survey. Like the MonthHourglassPlot class, this class expects to obtain float/int data. To use it with other data, its subclass that includes the appropriate mixin (YearHourglassCategoricalPlot or YearHourglassUsePlot) or a custom subclass. """ def __init__(self, year, **kwargs): super().__init__(**kwargs) self.year = year self.plot_type = "YearHourglass" self.default_plot_dict["ylabel"] = "day of month" self.default_plot_dict["y_max"] = 31.5 self.default_plot_dict["y_min"] = 0.5 def _plot(self, fig, intervals): """Make a series of month plots, arranged for the entire year. This method does not add the legend or color bar. Use the __call__ method to handle creation of the intervals DataFrame, call this method, and then add any necessary legends or colorbars. Parameters ---------- fig : `matplotlib.figure.Figure` figure on which to plot intervals : `pandas.DataFrame` data to be plotted, with "mjd", "duration", and "value" columns. "duration" should be in units of hours. """ axes = fig.subplots( 3, 4, sharex=True, sharey=True, gridspec_kw={"wspace": 0.025, "hspace": 0}, ) for month, ax in zip(np.arange(1, 13), axes.T.flatten()): # pylint: disable=invalid-name logging.info("Working on %s, %d", calendar.month_name[month], self.year) self.plot_dict["y_max"] = calendar.monthrange(self.year, month)[1] + 0.5 color_mappable = self._plot_month(intervals, month, self.year, ax) ax.set_title("") ax.set_title(calendar.month_abbr[month], y=1, x=0.005, pad=-15, loc="left") self._add_figure_labels(fig, axes) return color_mappable, axes.ravel() def _add_figure_labels(self, fig, axes): # pylint: disable=invalid-name, no-self-use """Generate the figure labels. Parameters ---------- fig : `matplotlib.figure.Figure` figure to which to add lables ax : `matplotlib.axes.Axes` The axis to which to add labels """ axes[1, 0].set_ylabel(self.plot_dict["ylabel"]) fig.suptitle(self.plot_dict["title"], y=0.9) fig.text(0.5, 0.05, self.plot_dict["xlabel"], ha="center")
# # Mixin classes to change handling of metric data # # Mixin classes avoid the need to duplicate code for every appearance # type subclass. Rather that need to duplicate code between # RangeHourglassPlot, MonthHourglassPlot, and YearHourglassPlot to # enable them all to support hourglass data, a single "mixin" class # allows the needed modifications to be defined once, and then "mixed # in" to each of these classes (see RangeHourglassCategoricalPlot, # MonthHourglassCategoricalPlot, and YearHourglassCategoricalPlot # below).
[docs] class CategoricalHourglassPlotMixin: """A mix-in to allow the HourglassPlot to accept categorical data, rather than float/int. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.object_plotter = True # pylint: disable=invalid-name self.plot_type = ( # pylint: disable=invalid-name "Categorical" + self.plot_type ) # pylint: disable=invalid-name self.default_plot_dict["cmap"] = plt.get_cmap("tab10") self.default_plot_dict["assigned_colors"] = OrderedDict() self.default_plot_dict["legend"] = True self.default_plot_dict["colorbar"] = False if "marked_ra" not in kwargs: self.marked_ra = {} def _map_colors(self, values): cmap = self.plot_dict["cmap"] assigned_colors = self.plot_dict["assigned_colors"] self.color_map = {} if len(self.color_map.keys()) == 0: self.color_map = _assign_category_colors( np.unique(values), cmap, assigned_colors=assigned_colors, ) colors = np.array(np.vectorize(self.color_map.get)(values)).T.tolist() color_mappable = None return colors, color_mappable
[docs] class RangeHourglassCategoricalPlot(CategoricalHourglassPlotMixin, RangeHourglassPlot): """Plot categorical data for a range of dates."""
[docs] class MonthHourglassCategoricalPlot(CategoricalHourglassPlotMixin, MonthHourglassPlot): """Plot categorical data for a Month."""
[docs] class YearHourglassCategoricalPlot(CategoricalHourglassPlotMixin, YearHourglassPlot): """Plot categorical data for a year."""
[docs] class TimeUseHourglassPlotMixin(CategoricalHourglassPlotMixin): """A mix-in to allow the HourglassPlot to accept categorical 'use' data rather than float/int.""" def __init__(self, *args, **kwargs): """Customize hourglass plotter for time use""" super().__init__(*args, **kwargs) self.plot_type = "Use" + self.plot_type # pylint: disable=invalid-name self.default_plot_dict["cmap"] = plt.get_cmap("tab10") self.default_plot_dict["colorbar"] = False self.default_plot_dict["assigned_colors"] = OrderedDict( ( ("wide with only IR", 3), ("wide with u, g, or r", 0), ("greedy", 1), ) ) self.default_plot_dict["legend"] = True if "marked_ra" not in kwargs: self.marked_ra = {f: c[0] for f, c in ddf_locations().items()}
[docs] class MonthHourglassUsePlot(TimeUseHourglassPlotMixin, MonthHourglassPlot): """Plot categorical 'use' data for one month."""
[docs] class YearHourglassUsePlot(TimeUseHourglassPlotMixin, YearHourglassPlot): """Plot categorical 'use' data for one year."""
# internal functions & classes def _assign_category_colors(uses, cmap, use_colors=None, assigned_colors=None): """Set a dictionary of nice colors for the use blocks. Options allow specifing pre-defined elements for some categories.""" use_colors = OrderedDict() if use_colors is None else use_colors assigned_colors = OrderedDict() if assigned_colors is None else assigned_colors available_idxs = list(range(cmap.N)) used_colors = set(use_colors.values()) for cmap_idx in range(cmap.N): # Skip gray because it is confusing against background this_cmap = cmap(cmap_idx) if this_cmap[0] == this_cmap[1] and this_cmap[0] == this_cmap[2]: available_idxs.remove(cmap_idx) # If a color has been used already, don't reuse it for something else if cmap(cmap_idx) in used_colors: available_idxs.remove(cmap_idx) for use, cmap_idx in assigned_colors.items(): if cmap_idx in available_idxs: available_idxs.remove(cmap_idx) use_colors[use] = cmap(cmap_idx) else: assert use_colors[use] == cmap(cmap_idx) for use in uses: if use not in use_colors: use_idx = available_idxs[0] use_colors[use] = cmap(use_idx) available_idxs.remove(use_idx) return use_colors def _compute_hours_after_midnight(mjd, tz="Chile/Continental"): # pylint: disable=invalid-name """Calculate the civil 'hours' value for the hourglass plot. Parameters ---------- mjd : `numpy.array` array of Modified Julian Dates tz : `str` The timezone (to be passed to `pandas.DatetimeIndex.tz_convret`) Returns ------- night_mjds : `numpy.array` array of interger nights for the dates of the provided mjds. hours_after_midnight : `numpy.array` array of hours after local civil midnight in the timezone provided, for each input mjd value. """ utc_datetimes = pd.to_datetime(mjd + 2400000.5, unit="D", origin="julian").tz_localize("UTC") local_times = pd.DatetimeIndex(utc_datetimes).tz_convert(tz) night_mjds = np.floor(local_times.to_julian_date() - 2400001).astype(int).values hours_after_midnight = (local_times.to_julian_date().values - 2400001.5 - night_mjds) * 24 return night_mjds, hours_after_midnight def _compute_hours_after_solar_midnight(mjd, site, tz="Chile/Continental"): # pylint: disable=invalid-name """Calculate the solar 'hours' value for the hourglass plot. Parameters ---------- mjd : `numpy.array` array of Modified Julian Dates tz : `str` The timezone (to be passed to `pandas.DatetimeIndex.tz_convret`) Returns ------- night_mjds : `numpy.array` array of interger nights for the dates of the provided mjds. hours_after_midnight : `numpy.array` array of hours after mean local solar midnight (antitransit), for each input mjd value. """ times = astropy.time.Time(mjd, format="mjd", location=site) mean_solar_jd = times.ut1.mjd + site.lon.deg / 360 mean_solar_time = astropy.coordinates.Angle( mean_solar_jd * 360, unit=u.deg # pylint: disable=no-member ).wrap_at( 180 * u.deg # pylint: disable=no-member ) hours_after_midnight = ( mean_solar_time.to_value(u.deg) * 24 / 360.0 # pylint: disable=no-member # pylint: disable=no-member ) # pylint: disable=no-member utc_datetimes = pd.to_datetime(mjd + 2400000.5, unit="D", origin="julian").tz_localize("UTC") local_times = pd.DatetimeIndex(utc_datetimes).tz_convert(tz) night_mjds = np.floor(local_times.to_julian_date() - 2400001).astype(int).values return night_mjds, hours_after_midnight def _compute_coord_transit_mjds(mjds, ra, site): # pylint: disable=invalid-name """Compute the coordinate transit times. Parameters ---------- mjds : `numpy.array` array of Modified Julian Dates for the nights for which to calculate transit ra : `float` R.A. for which to calculate transit in degrees site : `astropy.coordinates.EarthLocation` location of the telescope site Return ------ mjds : `numpy.array` array of MJDs of transit times. """ # Used for transit lines for the hourglass plot. # Calculate the local sidereal time at mjds - in degrees (for RA # comparison) lsts = astropy.time.Time(mjds, format="mjd", location=site).sidereal_time("apparent").deg # pylint: disable=invalid-name, no-member ha = astropy.coordinates.Angle(lsts - ra, unit=u.deg).wrap_at(180 * u.deg) mjds = mjds - ha.to_value(u.deg) / 360 return mjds def _compute_moon_transit_mjds(mjds, site): """Compute the transit times for the moon, Parameters ---------- mjds : `numpy.array` array of Modified Julian Dates for the nights for which to calculate transit site : `astropy.coordinates.EarthLocation` location of the telescope site Return ------ mjds : `numpy.array` array of MJDs of transit times. """ # for transit lines for the hourglass plot. for _ in np.arange(3): times = astropy.time.Time(mjds, format="mjd", location=site) moon_coords = astropy.coordinates.get_body("moon", times) # Calculate the local sidereal time at mjds lsts = astropy.time.Time(mjds, format="mjd", location=site).sidereal_time("apparent") # pylint: disable=invalid-name, no-member ha = (lsts - moon_coords.ra).wrap_at(180 * u.deg) mjds = mjds - ha.to_value(u.deg) / 360 return mjds def _astron_hourglass( start_mjd, end_mjd, ax, site, solar_time ): # pylint: disable=invalid-name, too-many-locals, too-many-branches """Add the moon transit, night and twilight start/end lines. Parameters ---------- start_mjd : `int` Start time for the axes end_mjd: `int` End time for the exes ax: `matplotlib.axes.Axes` the axes on which to plot transit lines site : `astropy.coordinates.EarthLocation` location of the telescope site solar_time : `bool` True to use mean local solar midnight False to use civil midnight Returns ------- ax: `matplotlib.axes.Axes` the axes on transit lines were plotted """ mjds = np.arange(start_mjd, end_mjd + 1, 1) start_mjd = mjds[0] # Moon transit cal_night_mjds = mjds - site.lon.deg / 360 moon_transit_mjds = _compute_moon_transit_mjds(cal_night_mjds, site) if solar_time: ( moon_transit_nights, moon_transit_hams, ) = _compute_hours_after_solar_midnight(moon_transit_mjds, site) else: moon_transit_nights, moon_transit_hams = _compute_hours_after_midnight(moon_transit_mjds) # Loop to avoid wrapping moon_lines = np.cumsum(np.diff(moon_transit_hams, prepend=moon_transit_hams[0]) < 0) moon_label = "moon" for moon_line in np.unique(moon_lines): these_hams = moon_transit_hams[moon_lines == moon_line] these_nights = moon_transit_nights[moon_lines == moon_line] ax.plot( these_hams, these_nights - start_mjd + 1, color="yellow", linewidth=8, alpha=0.5, label=moon_label, ) moon_label = None # Moon rise and set for direction in ("up", "down"): moon_event_mjds = riseset_times(cal_night_mjds, direction, alt=0, body="moon") if solar_time: ( moon_event_nights, moon_event_hams, ) = _compute_hours_after_solar_midnight(moon_event_mjds, site) else: moon_event_nights, moon_event_hams = _compute_hours_after_midnight(moon_event_mjds) # Loop to avoid wrapping moon_lines = np.cumsum(np.diff(moon_event_hams, prepend=moon_event_hams[0]) < 0) for moon_line in np.unique(moon_lines): these_hams = moon_event_hams[moon_lines == moon_line] these_nights = moon_event_nights[moon_lines == moon_line] ax.plot( these_hams, these_nights - start_mjd + 1, color="yellow", linestyle="dotted", ) # Twilight guess_offset = {"up": 0.2, "down": -0.2} twilight_shade = {0: "0.7", -6: "0.6", -12: "0.4", -18: "black"} for alt in twilight_shade: pm_event_mjds = riseset_times(cal_night_mjds + guess_offset["down"], "down", alt=alt, body="sun") am_event_mjds = riseset_times(cal_night_mjds + guess_offset["up"], "up", alt=alt, body="sun") if solar_time: ( pm_event_nights, pm_event_hams, ) = _compute_hours_after_solar_midnight(pm_event_mjds, site) ( am_event_nights, am_event_hams, ) = _compute_hours_after_solar_midnight(am_event_mjds, site) else: ( pm_event_nights, pm_event_hams, ) = _compute_hours_after_midnight(pm_event_mjds) ( am_event_nights, am_event_hams, ) = _compute_hours_after_midnight(am_event_mjds) assert np.array_equal(am_event_nights, pm_event_nights) ax.fill_betweenx( am_event_nights - start_mjd + 1, pm_event_hams, am_event_hams, color=twilight_shade[alt], zorder=-10, ) return ax