blob: d91803b303792a97d4392c53091bb892da7d64b6 [file] [log] [blame]
#!/usr/bin/env python3.4
#
# Copyright 2021 - The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import bokeh, bokeh.plotting, bokeh.io
import collections
import itertools
import json
import math
from acts_contrib.test_utils.wifi import wifi_performance_test_utils as wputils
# Plotting Utilities
class BokehFigure():
"""Class enabling simplified Bokeh plotting."""
COLORS = [
'black',
'blue',
'blueviolet',
'brown',
'burlywood',
'cadetblue',
'cornflowerblue',
'crimson',
'cyan',
'darkblue',
'darkgreen',
'darkmagenta',
'darkorange',
'darkred',
'deepskyblue',
'goldenrod',
'green',
'grey',
'indigo',
'navy',
'olive',
'orange',
'red',
'salmon',
'teal',
'yellow',
]
MARKERS = [
'asterisk', 'circle', 'circle_cross', 'circle_x', 'cross', 'diamond',
'diamond_cross', 'hex', 'inverted_triangle', 'square', 'square_x',
'square_cross', 'triangle', 'x'
]
TOOLS = ('box_zoom,box_select,pan,crosshair,redo,undo,reset,hover,save')
def __init__(self,
title=None,
x_label=None,
primary_y_label=None,
secondary_y_label=None,
height=700,
width=1100,
title_size='15pt',
axis_label_size='12pt',
legend_label_size='12pt',
axis_tick_label_size='12pt',
x_axis_type='auto',
sizing_mode='scale_both',
json_file=None):
if json_file:
self.load_from_json(json_file)
else:
self.figure_data = []
self.fig_property = {
'title': title,
'x_label': x_label,
'primary_y_label': primary_y_label,
'secondary_y_label': secondary_y_label,
'num_lines': 0,
'height': height,
'width': width,
'title_size': title_size,
'axis_label_size': axis_label_size,
'legend_label_size': legend_label_size,
'axis_tick_label_size': axis_tick_label_size,
'x_axis_type': x_axis_type,
'sizing_mode': sizing_mode
}
def init_plot(self):
self.plot = bokeh.plotting.figure(
sizing_mode=self.fig_property['sizing_mode'],
width=self.fig_property['width'],
height=self.fig_property['height'],
title=self.fig_property['title'],
tools=self.TOOLS,
x_axis_type=self.fig_property['x_axis_type'],
output_backend='webgl')
tooltips = [
('index', '$index'),
('(x,y)', '($x, $y)'),
]
hover_set = []
for line in self.figure_data:
hover_set.extend(line['hover_text'].keys())
hover_set = set(hover_set)
for item in hover_set:
tooltips.append((item, '@{}'.format(item)))
self.plot.hover.tooltips = tooltips
self.plot.add_tools(
bokeh.models.tools.WheelZoomTool(dimensions='width'))
self.plot.add_tools(
bokeh.models.tools.WheelZoomTool(dimensions='height'))
def _filter_line(self, x_data, y_data, hover_text=None):
"""Function to remove NaN points from bokeh plots."""
x_data_filtered = []
y_data_filtered = []
hover_text_filtered = {}
for idx, xy in enumerate(
itertools.zip_longest(x_data, y_data, fillvalue=float('nan'))):
if not math.isnan(xy[1]):
x_data_filtered.append(xy[0])
y_data_filtered.append(xy[1])
if hover_text:
for key, value in hover_text.items():
hover_text_filtered.setdefault(key, [])
hover_text_filtered[key].append(
value[idx] if len(value) > idx else '')
return x_data_filtered, y_data_filtered, hover_text_filtered
def add_line(self,
x_data,
y_data,
legend,
hover_text=None,
color=None,
width=3,
style='solid',
marker=None,
marker_size=10,
shaded_region=None,
y_axis='default'):
"""Function to add line to existing BokehFigure.
Args:
x_data: list containing x-axis values for line
y_data: list containing y_axis values for line
legend: string containing line title
hover_text: text to display when hovering over lines
color: string describing line color
width: integer line width
style: string describing line style, e.g, solid or dashed
marker: string specifying line marker, e.g., cross
shaded region: data describing shaded region to plot
y_axis: identifier for y-axis to plot line against
"""
if y_axis not in ['default', 'secondary']:
raise ValueError('y_axis must be default or secondary')
if color == None:
color = self.COLORS[self.fig_property['num_lines'] %
len(self.COLORS)]
if style == 'dashed':
style = [5, 5]
if isinstance(hover_text, list):
hover_text = {'info': hover_text}
x_data_filter, y_data_filter, hover_text_filter = self._filter_line(
x_data, y_data, hover_text)
self.figure_data.append({
'x_data': x_data_filter,
'y_data': y_data_filter,
'legend': legend,
'hover_text': hover_text_filter,
'color': color,
'width': width,
'style': style,
'marker': marker,
'marker_size': marker_size,
'shaded_region': shaded_region,
'y_axis': y_axis
})
self.fig_property['num_lines'] += 1
def add_scatter(self,
x_data,
y_data,
legend,
hover_text=None,
color=None,
marker=None,
marker_size=10,
y_axis='default'):
"""Function to add line to existing BokehFigure.
Args:
x_data: list containing x-axis values for line
y_data: list containing y_axis values for line
legend: string containing line title
hover_text: text to display when hovering over lines
color: string describing line color
marker: string specifying marker, e.g., cross
y_axis: identifier for y-axis to plot line against
"""
if y_axis not in ['default', 'secondary']:
raise ValueError('y_axis must be default or secondary')
if color == None:
color = self.COLORS[self.fig_property['num_lines'] %
len(self.COLORS)]
if marker == None:
marker = self.MARKERS[self.fig_property['num_lines'] %
len(self.MARKERS)]
self.figure_data.append({
'x_data': x_data,
'y_data': y_data,
'legend': legend,
'hover_text': hover_text,
'color': color,
'width': 0,
'style': 'solid',
'marker': marker,
'marker_size': marker_size,
'shaded_region': None,
'y_axis': y_axis
})
self.fig_property['num_lines'] += 1
def generate_figure(self, output_file=None, save_json=True):
"""Function to generate and save BokehFigure.
Args:
output_file: string specifying output file path
"""
self.init_plot()
two_axes = False
for line in self.figure_data:
data_dict = {'x': line['x_data'], 'y': line['y_data']}
for key, value in line['hover_text'].items():
data_dict[key] = value
source = bokeh.models.ColumnDataSource(data=data_dict)
if line['width'] > 0:
self.plot.line(x='x',
y='y',
legend_label=line['legend'],
line_width=line['width'],
color=line['color'],
line_dash=line['style'],
name=line['y_axis'],
y_range_name=line['y_axis'],
source=source)
if line['shaded_region']:
band_x = line['shaded_region']['x_vector']
band_x.extend(line['shaded_region']['x_vector'][::-1])
band_y = line['shaded_region']['lower_limit']
band_y.extend(line['shaded_region']['upper_limit'][::-1])
self.plot.patch(band_x,
band_y,
color='#7570B3',
line_alpha=0.1,
fill_alpha=0.1)
if line['marker'] in self.MARKERS:
marker_func = getattr(self.plot, line['marker'])
marker_func(x='x',
y='y',
size=line['marker_size'],
legend_label=line['legend'],
line_color=line['color'],
fill_color=line['color'],
name=line['y_axis'],
y_range_name=line['y_axis'],
source=source)
if line['y_axis'] == 'secondary':
two_axes = True
#x-axis formatting
self.plot.xaxis.axis_label = self.fig_property['x_label']
self.plot.x_range.range_padding = 0
self.plot.xaxis[0].axis_label_text_font_size = self.fig_property[
'axis_label_size']
self.plot.xaxis.major_label_text_font_size = self.fig_property[
'axis_tick_label_size']
#y-axis formatting
self.plot.yaxis[0].axis_label = self.fig_property['primary_y_label']
self.plot.yaxis[0].axis_label_text_font_size = self.fig_property[
'axis_label_size']
self.plot.yaxis.major_label_text_font_size = self.fig_property[
'axis_tick_label_size']
self.plot.y_range = bokeh.models.DataRange1d(names=['default'])
if two_axes and 'secondary' not in self.plot.extra_y_ranges:
self.plot.extra_y_ranges = {
'secondary': bokeh.models.DataRange1d(names=['secondary'])
}
self.plot.add_layout(
bokeh.models.LinearAxis(
y_range_name='secondary',
axis_label=self.fig_property['secondary_y_label'],
axis_label_text_font_size=self.
fig_property['axis_label_size']), 'right')
# plot formatting
self.plot.legend.location = 'top_right'
self.plot.legend.click_policy = 'hide'
self.plot.title.text_font_size = self.fig_property['title_size']
self.plot.legend.label_text_font_size = self.fig_property[
'legend_label_size']
if output_file is not None:
self.save_figure(output_file, save_json)
return self.plot
def load_from_json(self, file_path):
with open(file_path, 'r') as json_file:
fig_dict = json.load(json_file)
self.fig_property = fig_dict['fig_property']
self.figure_data = fig_dict['figure_data']
def _save_figure_json(self, output_file):
"""Function to save a json format of a figure"""
figure_dict = collections.OrderedDict(fig_property=self.fig_property,
figure_data=self.figure_data)
output_file = output_file.replace('.html', '_plot_data.json')
with open(output_file, 'w') as outfile:
json.dump(wputils.serialize_dict(figure_dict), outfile, indent=4)
def save_figure(self, output_file, save_json=True):
"""Function to save BokehFigure.
Args:
output_file: string specifying output file path
save_json: flag controlling json outputs
"""
if save_json:
self._save_figure_json(output_file)
bokeh.io.output_file(output_file)
bokeh.io.save(self.plot)
@staticmethod
def save_figures(figure_array, output_file_path, save_json=True):
"""Function to save list of BokehFigures in one file.
Args:
figure_array: list of BokehFigure object to be plotted
output_file: string specifying output file path
"""
for idx, figure in enumerate(figure_array):
figure.generate_figure()
if save_json:
json_file_path = output_file_path.replace(
'.html', '{}-plot_data.json'.format(idx))
figure._save_figure_json(json_file_path)
plot_array = [figure.plot for figure in figure_array]
all_plots = bokeh.layouts.column(children=plot_array,
sizing_mode='scale_width')
bokeh.plotting.output_file(output_file_path)
bokeh.plotting.save(all_plots)