import uuid
from collections import OrderedDict
import numpy as np
from astropy.time import Time, TimeDelta
from astropy import units as u
from astropy.units import Quantity
from aas_timeseries.data import Data
from aas_timeseries.layers import BaseLayer, Markers, Line, VerticalLine, VerticalRange, HorizontalLine, HorizontalRange, Range, Text, time_to_vega
__all__ = ['BaseView', 'View']
VALID_TIME_FORMATS = {}
VALID_TIME_FORMATS['absolute'] = ['jd', 'mjd', 'unix', 'iso', 'auto']
VALID_TIME_FORMATS['relative'] = ['seconds', 'hours', 'days', 'years']
VALID_TIME_FORMATS['phase'] = ['unity', 'degrees', 'radians']
VALID_TIME_MODES = ['absolute', 'relative', 'phase']
[docs]class BaseView:
"""
Base class for view-like objects (both the base figure and the actual views)
"""
def __init__(self, time_mode=None):
self.uuid = str(uuid.uuid4())
self._data = OrderedDict()
self._layers = OrderedDict()
self._xlim = None
self._ylim = None
self._ylog = False
self._xlabel = ''
self._ylabel = ''
self._time_format = ''
if time_mode is not None and time_mode not in VALID_TIME_MODES:
raise ValueError("time_mode should be one of " + "/".join(VALID_TIME_MODES))
self._time_mode = time_mode or 'absolute'
@property
def time_mode(self):
return self._time_mode
@property
def ylog(self):
"""
Whether the y axis is linear (`False`) or log (`True`).
"""
return self._ylog
@ylog.setter
def ylog(self, value):
self._ylog = value
@property
def xlabel(self):
"""
The label to use for the x-axis. If not specified, this is determined
automatically from the type of x-axis.
"""
if self._xlabel:
return self._xlabel
else:
if self._time_mode == 'absolute':
return 'Time'
elif self._time_mode == 'relative':
return 'Relative Time ({0})'.format(self.time_format[0])
elif self._time_mode == 'phase':
return 'Phase'
@xlabel.setter
def xlabel(self, value):
self._xlabel = value
@property
def ylabel(self):
"""
The label to use for the y-axis.
"""
return self._ylabel
@ylabel.setter
def ylabel(self, value):
self._ylabel = value
@property
def xlim(self):
"""
The x/time limits of the view.
"""
return self._xlim
@xlim.setter
def xlim(self, range):
if not isinstance(range, (tuple, list)) or len(range) != 2:
raise ValueError("xlim should be a tuple of two elements")
start, end = range
if isinstance(start, str):
start = Time(start)
if isinstance(end, str):
end = Time(end)
if not isinstance(start, Time) or not isinstance(end, Time):
raise TypeError("xlim should be a typle of two Time instances")
self._xlim = start, end
@property
def ylim(self):
"""
The y limits of the view.
"""
return self._ylim
@ylim.setter
def ylim(self, range):
if isinstance(range[0], u.Quantity) is isinstance(range[1], u.Quantity):
if isinstance(range[0], u.Quantity):
if not range[0].unit.is_equivalent(range[1].unit):
raise u.UnitsError(f'The units of ymin ({range[0].unit}) are '
f'not compatible with the units of ymax ({range[1].unit})')
else:
raise ValueError('Either both or neither limit has to be specified '
'as a Quantity')
self._ylim = range
@property
def time_format(self):
"""
The format to use for the x-axis.
"""
if self._time_format:
return self._time_format
else:
if self._time_mode == 'absolute':
return 'auto'
elif self._time_mode == 'relative':
return 'seconds'
elif self._time_mode == 'phase':
return 'unity'
@time_format.setter
def time_format(self, value):
if value in VALID_TIME_FORMATS[self._time_mode]:
self._time_format = value
else:
raise ValueError('time_format should be one of ' + '/'.join(VALID_TIME_FORMATS[self._time_mode]))
def _validate_time_column(self, time_series, time_column):
column = time_series[time_column]
if self._time_mode == 'absolute':
if not isinstance(column, Time) or isinstance(column, TimeDelta):
raise TypeError('When in absolute time mode, the time column should be a Time object')
elif self._time_mode == 'relative':
if not isinstance(column, (TimeDelta, Quantity)) or (isinstance(column, Quantity) and column.unit.physical_type != 'time'):
raise TypeError('When in relative time model, the time column should be a TimeDelta or Quantity object with time units')
elif self._time_mode == 'phase':
if not isinstance(column, np.ndarray) or (isinstance(column, Quantity) and column.unit.physical_type != 'dimensionless'):
raise TypeError('When in relative time model, the time column should be a plain array or a dimensionless Quantity')
else:
raise ValueError('time_mode should be one of absolute/relative/phase')
[docs] def add_markers(self, *, time_series=None, column=None, time_column='time', **kwargs):
"""
Add markers, optionally with errorbars, to the figure.
Parameters
----------
data : `~astropy.timeseries.TimeSeries`
The time series object containing the data.
column : str
The field in the time series containing the data.
time_column : str, optional
The field to use for the time on the x-axis.
error : str, optional
The field in the time series containing the data uncertainties.
shape : {'circle', 'square', 'cross', 'diamond', 'triangle-up', 'triangle-down', 'triangle-right', 'triangle-left'}, optional
The symbol shape.
size : float or int, optional
The area in pixels of the bounding box of the symbols. Note that this
value sets the area of the symbol; the side lengths will increase with
the square root of this value.
color : str or tuple, optional
The fill color of the symbols.
opacity : float or int, optional
The opacity of the fill color from 0 (transparent) to 1 (opaque).
edge_color : str or tuple, optional
The edge color of the symbol.
edge_opacity : float or int, optional
The opacity of the edge color from 0 (transparent) to 1 (opaque).
edge_width : float or int, optional
The thickness of the edge, in pixels.
label : str, optional
The label to use to designate the layer in the legend.
Returns
-------
layer : `~aas_timeseries.layers.Markers`
"""
self._validate_time_column(time_series, time_column)
if id(time_series) not in self._data:
self._data[id(time_series)] = Data(time_series)
markers = Markers(parent=self, data=self._data[id(time_series)], **kwargs)
# Note that we need to set the column after the data so that the
# validation works.
markers.column = column
markers.time_column = time_column
self._layers[markers] = {'visible': True}
return markers
[docs] def add_line(self, *, time_series=None, column=None, time_column='time', **kwargs):
"""
Add a line to the figure.
Parameters
----------
data : `~astropy.timeseries.TimeSeries`
The time series object containing the data.
column : str
The field in the time series containing the data.
time_column : str, optional
The field to use for the time on the x-axis.
width : float or int, optional
The width of the line, in pixels.
color : str or tuple, optional
The color of the line.
opacity : float or int, optional
The opacity of the line from 0 (transparent) to 1 (opaque).
label : str, optional
The label to use to designate the layer in the legend.
Returns
-------
layer : `~aas_timeseries.layers.Line`
"""
self._validate_time_column(time_series, time_column)
if id(time_series) not in self._data:
self._data[id(time_series)] = Data(time_series)
line = Line(parent=self, data=self._data[id(time_series)], **kwargs)
# Note that we need to set the column after the data so that the
# validation works.
line.column = column
line.time_column = time_column
self._layers[line] = {'visible': True}
return line
[docs] def add_range(self, *, time_series=None, column_lower=None, column_upper=None, time_column='time', **kwargs):
"""
Add a time dependent range to the figure.
Parameters
----------
data : `~astropy.timeseries.TimeSeries`
The time series object containing the data.
column_lower : str
The field in the time series containing the lower value of the data
range.
column_upper : str
The field in the time series containing the upper value of the data
range.
time_column : str, optional
The field to use for the time on the x-axis.
color : str or tuple, optional
The fill color of the range.
opacity : float or int, optional
The opacity of the fill color from 0 (transparent) to 1 (opaque).
edge_color : str or tuple, optional
The edge color of the range.
edge_opacity : float or int, optional
The opacity of the edge color from 0 (transparent) to 1 (opaque).
edge_width : float or int, optional
The thickness of the edge, in pixels.
label : str, optional
The label to use to designate the layer in the legend.
Returns
-------
layer : `~aas_timeseries.layers.Range`
"""
self._validate_time_column(time_series, time_column)
if id(time_series) not in self._data:
self._data[id(time_series)] = Data(time_series)
range = Range(parent=self, data=self._data[id(time_series)], **kwargs)
# Note that we need to set the columns after the data so that the
# validation works.
range.column_lower = column_lower
range.column_upper = column_upper
range.time_column = time_column
self._layers[range] = {'visible': True}
return range
[docs] def add_vertical_line(self, time, **kwargs):
"""
Add a vertical line to the figure.
Parameters
----------
time : `~astropy.time.Time`
The date/time at which the vertical line is shown.
width : float or int, optional
The width of the line, in pixels.
color : str or tuple, optional
The color of the line.
opacity : float or int, optional
The opacity of the line from 0 (transparent) to 1 (opaque).
label : str, optional
The label to use to designate the layer in the legend.
Returns
-------
layer : `~aas_timeseries.layers.VerticalLine`
"""
line = VerticalLine(parent=self, time=time, **kwargs)
self._layers[line] = {'visible': True}
return line
[docs] def add_vertical_range(self, time_lower, time_upper, **kwargs):
"""
Add a vertical range to the figure.
Parameters
----------
time_lower : `~astropy.time.Time`
The date/time at which the range starts.
time_upper : `~astropy.time.Time`
The date/time at which the range ends.
color : str or tuple, optional
The fill color of the range.
opacity : float or int, optional
The opacity of the fill color from 0 (transparent) to 1 (opaque).
edge_color : str or tuple, optional
The edge color of the range.
edge_opacity : float or int, optional
The opacity of the edge color from 0 (transparent) to 1 (opaque).
edge_width : float or int, optional
The thickness of the edge, in pixels.
label : str, optional
The label to use to designate the layer in the legend.
Returns
-------
layer : `~aas_timeseries.layers.VerticalRange`
"""
range = VerticalRange(parent=self, time_lower=time_lower, time_upper=time_upper, **kwargs)
self._layers[range] = {'visible': True}
return range
[docs] def add_horizontal_line(self, value, **kwargs):
"""
Add a horizontal line to the figure.
Parameters
----------
value : float or int
The y value at which the horizontal line is shown.
width : float or int, optional
The width of the line, in pixels.
color : str or tuple, optional
The color of the line.
opacity : float or int, optional
The opacity of the line from 0 (transparent) to 1 (opaque).
label : str, optional
The label to use to designate the layer in the legend.
Returns
-------
layer : `~aas_timeseries.layers.HorizontalLine`
"""
line = HorizontalLine(parent=self, value=value, **kwargs)
self._layers[line] = {'visible': True}
return line
[docs] def add_horizontal_range(self, value_lower, value_upper, **kwargs):
"""
Add a horizontal range to the figure.
Parameters
----------
value_lower : float or int
The value at which the range starts.
value_upper : float or int
The value at which the range ends.
color : str or tuple, optional
The fill color of the range.
opacity : float or int, optional
The opacity of the fill color from 0 (transparent) to 1 (opaque).
edge_color : str or tuple, optional
The edge color of the range.
edge_opacity : float or int, optional
The opacity of the edge color from 0 (transparent) to 1 (opaque).
edge_width : float or int, optional
The thickness of the edge, in pixels.
label : str, optional
The label to use to designate the layer in the legend.
Returns
-------
layer : `~aas_timeseries.layers.HorizontalRange`
"""
range = HorizontalRange(parent=self, value_lower=value_lower, value_upper=value_upper, **kwargs)
self._layers[range] = {'visible': True}
return range
[docs] def add_text(self, **kwargs):
"""
Add a text label to the figure.
Parameters
----------
time : `~astropy.time.Time`
The date/time at which the text is shown.
value : float or int
The y value at which the text is shown.
weight : {'normal', 'bold'}, optional
The weight of the text.
baseline : {'alphabetic', 'top', 'middle', 'bottom'}, optional
The vertical text baseline.
align : {'left', 'center', 'right'}, optional
The horizontal text alignment.
angle : float or int, optional
The rotation angle of the text in degrees (default 0).
text : str, optional
The text label to show.
color : str or tuple, optional
The color of the text.
opacity : float or int, optional
The opacity of the text from 0 (transparent) to 1 (opaque).
label : str, optional
The label to use to designate the layer in the legend.
Returns
-------
layer : `~aas_timeseries.layers.Text`
"""
text = Text(parent=self, **kwargs)
self._layers[text] = {'visible': True}
return text
@property
def layers(self):
return list(self._layers)
def _get_domains(self, yunit, as_vega=True):
if self.xlim is None or self.ylim is None:
all_times = []
all_values = []
# If there are symbol layers, we just use those to determine limits
if any(isinstance(layer, Markers) for layer in self.layers):
layer_types = (Markers,)
else:
layer_types = (Range, Line)
for layer in self.layers:
if isinstance(layer, layer_types):
if self._time_mode == 'absolute':
all_times.append(np.min(layer.data.time_series[layer.time_column]))
all_times.append(np.max(layer.data.time_series[layer.time_column]))
elif self._time_mode == 'relative':
all_times.append(np.nanmin(layer.data.column_to_values(layer.time_column, u.s)))
all_times.append(np.nanmax(layer.data.column_to_values(layer.time_column, u.s)))
elif self._time_mode == 'phase':
all_times.append(np.nanmin(layer.data.column_to_values(layer.time_column, u.one)))
all_times.append(np.nanmax(layer.data.column_to_values(layer.time_column, u.one)))
all_values.append(np.nanmin(layer.data.column_to_values(layer.column, yunit)))
all_values.append(np.nanmax(layer.data.column_to_values(layer.column, yunit)))
if len(all_times) > 0:
xlim_auto = np.min(all_times), np.max(all_times)
else:
xlim_auto = None
if len(all_values) > 0:
ylim_auto = float(np.min(all_values)), float(np.max(all_values))
else:
ylim_auto = None
if self.xlim is None:
xlim = xlim_auto
else:
xlim = self.xlim
if self.ylim is None:
ylim = ylim_auto
else:
ylim = self.ylim
if isinstance(ylim[0], u.Quantity):
ylim = ylim[0].to_value(yunit), ylim[1].to_value(yunit)
elif yunit is not u.one:
raise u.UnitsError(f'Limits for y axis are dimensionless but '
f'expected units of {yunit}')
xlim = xlim_auto if xlim is None else xlim
ylim = ylim_auto if ylim is None else ylim
if xlim is not None:
if self._time_mode == 'absolute' and as_vega:
x_domain = ({'signal': time_to_vega(xlim[0])},
{'signal': time_to_vega(xlim[1])})
else:
x_domain = list(xlim)
else:
x_domain = None
if ylim is not None:
y_domain = list(ylim)
else:
y_domain = None
return x_domain, y_domain
[docs]class View(BaseView):
def __init__(self, figure=None, inherited_layers=None, time_mode=None):
super().__init__(time_mode=time_mode)
self._figure = figure
self._inherited_layers = inherited_layers or OrderedDict()
self._data = figure._data
self.ylabel = figure.ylabel
[docs] def show(self, layers):
self._set_visible(layers, True)
[docs] def hide(self, layers):
self._set_visible(layers, False)
def _set_visible(self, layers, visible):
if isinstance(layers, BaseLayer):
layers = [layers]
for layer in layers:
if layer in self._layers:
self._layers[layer]['visible'] = visible
elif layer in self._inherited_layers:
self._inherited_layers[layer]['visible'] = visible
else:
raise ValueError(f'Layer {layer} not in view')
[docs] def remove(self, layer):
"""
Remove a layer from the view.
"""
if layer in self._inherited_layers:
self._inherited_layers.pop(layer)
elif layer in self._layers:
self._layers.pop(layer)
else:
raise ValueError(f"Layer '{layer.label}' is not in view")
@property
def layers(self):
return list(self._inherited_layers) + list(self._layers)