diff --git a/dashboard.py b/dashboard.py index f21b13b..4c24099 100644 --- a/dashboard.py +++ b/dashboard.py @@ -18,960 +18,46 @@ To run Coach Dashboard, run the following command: python3 dashboard.py """ - -from utils import * import os -import datetime -import sys -import wx -import random -import pandas as pd -from pandas.io.common import EmptyDataError -import numpy as np -import colorsys -from bokeh.palettes import Dark2 -from bokeh.layouts import row, column, widgetbox, Spacer -from bokeh.models import ColumnDataSource, Range1d, LinearAxis, HoverTool, WheelZoomTool, PanTool, Legend -from bokeh.models.widgets import RadioButtonGroup, MultiSelect, Button, Select, Slider, Div, CheckboxGroup -from bokeh.models.glyphs import Patch -from bokeh.plotting import figure, show, curdoc -from utils import force_list -from utils import squeeze_list -from itertools import cycle -from os import listdir -from os.path import isfile, join, isdir, basename -from enum import Enum +from dashboard_components.experiment_board import display_directory_group, display_files, averaging_slider_dummy_source +from dashboard_components.globals import doc +import dashboard_components.boards # needed for setting the layouts global variable +from dashboard_components.landing_page import landing_page - -class DialogApp(wx.App): - def getFileDialog(self): - with wx.FileDialog(None, "Open CSV file", wildcard="CSV files (*.csv)|*.csv", - style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST | wx.FD_CHANGE_DIR | wx.FD_MULTIPLE) as fileDialog: - if fileDialog.ShowModal() == wx.ID_CANCEL: - return None # the user changed their mind - else: - # Proceed loading the file chosen by the user - return fileDialog.GetPaths() - - def getDirDialog(self): - with wx.DirDialog (None, "Choose input directory", "", - style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST | wx.FD_CHANGE_DIR) as dirDialog: - if dirDialog.ShowModal() == wx.ID_CANCEL: - return None # the user changed their mind - else: - # Proceed loading the dir chosen by the user - return dirDialog.GetPath() -class Signal: - def __init__(self, name, parent): - self.name = name - self.full_name = "{}/{}".format(parent.filename, self.name) - self.selected = False - self.color = random.choice(Dark2[8]) - self.line = None - self.bands = None - self.bokeh_source = parent.bokeh_source - self.min_val = 0 - self.max_val = 0 - self.axis = 'default' - self.sub_signals = [] - for name in self.bokeh_source.data.keys(): - if (len(name.split('/')) == 1 and name == self.name) or '/'.join(name.split('/')[:-1]) == self.name: - self.sub_signals.append(name) - if len(self.sub_signals) > 1: - self.mean_signal = squeeze_list([name for name in self.sub_signals if 'Mean' in name.split('/')[-1]]) - self.stdev_signal = squeeze_list([name for name in self.sub_signals if 'Stdev' in name.split('/')[-1]]) - self.min_signal = squeeze_list([name for name in self.sub_signals if 'Min' in name.split('/')[-1]]) - self.max_signal = squeeze_list([name for name in self.sub_signals if 'Max' in name.split('/')[-1]]) - else: - self.mean_signal = squeeze_list(self.name) - self.stdev_signal = None - self.min_signal = None - self.max_signal = None - self.has_bollinger_bands = False - if self.mean_signal and self.stdev_signal and self.min_signal and self.max_signal: - self.has_bollinger_bands = True - self.show_bollinger_bands = False - self.bollinger_bands_source = None - self.update_range() - - def set_color(self, color): - self.color = color - if self.line: - self.line.glyph.line_color = color - if self.bands: - self.bands.glyph.fill_color = color - - def set_selected(self, val): - global current_color - if self.selected != val: - self.selected = val - if self.line: - # self.set_color(Dark2[8][current_color]) - # current_color = (current_color + 1) % len(Dark2[8]) - self.line.visible = self.selected - if self.bands: - self.bands.visible = self.selected and self.show_bollinger_bands - elif self.selected: - # lazy plotting - plot only when selected for the first time - show_spinner() - self.set_color(Dark2[8][current_color]) - current_color = (current_color + 1) % len(Dark2[8]) - if self.has_bollinger_bands: - self.set_bands_source() - self.create_bands() - self.line = plot.line('index', self.mean_signal, source=self.bokeh_source, - line_color=self.color, line_width=2) - self.line.visible = True - hide_spinner() - - def set_dash(self, dash): - self.line.glyph.line_dash = dash - - def create_bands(self): - self.bands = plot.patch(x='band_x', y='band_y', source=self.bollinger_bands_source, - color=self.color, fill_alpha=0.4, alpha=0.1, line_width=0) - self.bands.visible = self.show_bollinger_bands - # self.min_line = plot.line('index', self.min_signal, source=self.bokeh_source, - # line_color=self.color, line_width=3, line_dash="4 4") - # self.max_line = plot.line('index', self.max_signal, source=self.bokeh_source, - # line_color=self.color, line_width=3, line_dash="4 4") - # self.min_line.visible = self.show_bollinger_bands - # self.max_line.visible = self.show_bollinger_bands - - def set_bands_source(self): - x_ticks = self.bokeh_source.data['index'] - mean_values = self.bokeh_source.data[self.mean_signal] - stdev_values = self.bokeh_source.data[self.stdev_signal] - band_x = np.append(x_ticks, x_ticks[::-1]) - band_y = np.append(mean_values - stdev_values, mean_values[::-1] + stdev_values[::-1]) - source_data = {'band_x': band_x, 'band_y': band_y} - if self.bollinger_bands_source: - self.bollinger_bands_source.data = source_data - else: - self.bollinger_bands_source = ColumnDataSource(source_data) - - def change_bollinger_bands_state(self, new_state): - self.show_bollinger_bands = new_state - if self.bands and self.selected: - self.bands.visible = new_state - # self.min_line.visible = new_state - # self.max_line.visible = new_state - - def update_range(self): - self.min_val = np.min(self.bokeh_source.data[self.mean_signal]) - self.max_val = np.max(self.bokeh_source.data[self.mean_signal]) - - def set_axis(self, axis): - self.axis = axis - self.line.y_range_name = axis - - def toggle_axis(self): - if self.axis == 'default': - self.set_axis('secondary') - else: - self.set_axis('default') - - -class SignalsFileBase: - def __init__(self): - self.full_csv_path = "" - self.dir = "" - self.filename = "" - self.signals_averaging_window = 1 - self.show_bollinger_bands = False - self.csv = None - self.bokeh_source = None - self.bokeh_source_orig = None - self.last_modified = None - self.signals = {} - self.separate_files = False - - def load_csv(self): - pass - - def update_source_and_signals(self): - # create bokeh data sources - self.bokeh_source_orig = ColumnDataSource(self.csv) - self.bokeh_source_orig.data['index'] = self.bokeh_source_orig.data[x_axis] - - if self.bokeh_source is None: - self.bokeh_source = ColumnDataSource(self.csv) - else: - # self.bokeh_source.data = self.bokeh_source_orig.data - # smooth the data if necessary - self.change_averaging_window(self.signals_averaging_window, force=True) - - # create all the signals - if len(self.signals.keys()) == 0: - self.signals = {} - unique_signal_names = [] - for name in self.csv.columns: - if len(name.split('/')) == 1: - unique_signal_names.append(name) - else: - unique_signal_names.append('/'.join(name.split('/')[:-1])) - unique_signal_names = list(set(unique_signal_names)) - for signal_name in unique_signal_names: - self.signals[signal_name] = Signal(signal_name, self) - - def load(self): - self.load_csv() - self.update_source_and_signals() - - def reload_data(self, signals): - # this function is a workaround to reload the data of all the signals - # if the data doesn't change, bokeh does not refreshes the line - self.change_averaging_window(self.signals_averaging_window + 1, force=True) - self.change_averaging_window(self.signals_averaging_window - 1, force=True) - - def change_averaging_window(self, new_size, force=False, signals=None): - if force or self.signals_averaging_window != new_size: - self.signals_averaging_window = new_size - win = np.ones(new_size) / new_size - temp_data = self.bokeh_source_orig.data.copy() - for col in self.bokeh_source.data.keys(): - if col == 'index' or col in x_axis_options \ - or (signals and not any(col in signal for signal in signals)): - temp_data[col] = temp_data[col][:-new_size] - continue - temp_data[col] = np.convolve(self.bokeh_source_orig.data[col], win, mode='same')[:-new_size] - self.bokeh_source.data = temp_data - - # smooth bollinger bands - for signal in self.signals.values(): - if signal.has_bollinger_bands: - signal.set_bands_source() - - def hide_all_signals(self): - for signal_name in self.signals.keys(): - self.set_signal_selection(signal_name, False) - - def set_signal_selection(self, signal_name, val): - self.signals[signal_name].set_selected(val) - - def change_bollinger_bands_state(self, new_state): - self.show_bollinger_bands = new_state - for signal in self.signals.values(): - signal.change_bollinger_bands_state(new_state) - - def file_was_modified_on_disk(self): - pass - - def get_range_of_selected_signals_on_axis(self, axis, selected_signal=None): - max_val = -float('inf') - min_val = float('inf') - for signal in self.signals.values(): - if (selected_signal and signal.name == selected_signal) or (signal.selected and signal.axis == axis): - max_val = max(max_val, signal.max_val) - min_val = min(min_val, signal.min_val) - return min_val, max_val - - def get_selected_signals(self): - signals = [] - for signal in self.signals.values(): - if signal.selected: - signals.append(signal) - return signals - - def show_files_separately(self, val): - pass - - -class SignalsFile(SignalsFileBase): - def __init__(self, csv_path, load=True): - SignalsFileBase.__init__(self) - self.full_csv_path = csv_path - self.dir, self.filename, _ = break_file_path(csv_path) - if load: - self.load() - # this helps set the correct x axis - self.change_averaging_window(1, force=True) - - def load_csv(self): - # load csv and fix sparse data. - # csv can be in the middle of being written so we use try - except - self.csv = None - while self.csv is None: - try: - self.csv = pd.read_csv(self.full_csv_path) - break - except EmptyDataError: - self.csv = None - continue - self.csv = self.csv.interpolate() - self.csv.fillna(value=0, inplace=True) - - self.last_modified = os.path.getmtime(self.full_csv_path) - - def file_was_modified_on_disk(self): - return self.last_modified != os.path.getmtime(self.full_csv_path) - - -class SignalsFilesGroup(SignalsFileBase): - def __init__(self, csv_paths): - SignalsFileBase.__init__(self) - self.full_csv_paths = csv_paths - self.signals_files = [] - if len(csv_paths) == 1 and os.path.isdir(csv_paths[0]): - self.signals_files = [SignalsFile(str(file), load=False) for file in add_directory_csv_files(csv_paths[0])] - else: - for csv_path in csv_paths: - if os.path.isdir(csv_path): - self.signals_files.append(SignalsFilesGroup(add_directory_csv_files(csv_path))) - else: - self.signals_files.append(SignalsFile(str(csv_path), load=False)) - if len(csv_paths) == 1: - # get the parent directory name (since the current directory is the timestamp directory) - self.dir = os.path.abspath(os.path.join(os.path.dirname(csv_paths[0]), '..')) - else: - # get the common directory for all the experiments - self.dir = os.path.dirname(os.path.commonprefix(csv_paths)) - self.filename = '{} - Group({})'.format(basename(self.dir), len(self.signals_files)) - self.load() - - # this helps set the correct x axis - self.change_averaging_window(1, force=True) - - def load_csv(self): - corrupted_files_idx = [] - for idx, signal_file in enumerate(self.signals_files): - signal_file.load_csv() - if not all(option in signal_file.csv.keys() for option in x_axis_options): - print("Warning: {} file seems to be corrupted and does contain the necessary columns " - "and will not be rendered".format(signal_file.filename)) - corrupted_files_idx.append(idx) - - for file_idx in corrupted_files_idx: - del self.signals_files[file_idx] - - # get the stats of all the columns - csv_group = pd.concat([signals_file.csv for signals_file in self.signals_files]) - columns_to_remove = [s for s in csv_group.columns if '/Stdev' in s] + \ - [s for s in csv_group.columns if '/Min' in s] + \ - [s for s in csv_group.columns if '/Max' in s] - for col in columns_to_remove: - del csv_group[col] - csv_group = csv_group.groupby(csv_group.index) - self.csv_mean = csv_group.mean() - self.csv_mean.columns = [s + '/Mean' for s in self.csv_mean.columns] - self.csv_stdev = csv_group.std() - self.csv_stdev.columns = [s + '/Stdev' for s in self.csv_stdev.columns] - self.csv_min = csv_group.min() - self.csv_min.columns = [s + '/Min' for s in self.csv_min.columns] - self.csv_max = csv_group.max() - self.csv_max.columns = [s + '/Max' for s in self.csv_max.columns] - - # get the indices from the file with the least number of indices and which is not an evaluation worker - file_with_min_indices = self.signals_files[0] - for signals_file in self.signals_files: - if signals_file.csv.shape[0] < file_with_min_indices.csv.shape[0] and \ - 'Training reward' in signals_file.csv.keys(): - file_with_min_indices = signals_file - self.index_columns = file_with_min_indices.csv[x_axis_options] - - # concat the stats and the indices columns - num_rows = file_with_min_indices.csv.shape[0] - self.csv = pd.concat([self.index_columns, self.csv_mean.head(num_rows), self.csv_stdev.head(num_rows), - self.csv_min.head(num_rows), self.csv_max.head(num_rows)], axis=1) - - # remove the stat columns for the indices columns - columns_to_remove = [s + '/Mean' for s in x_axis_options] + \ - [s + '/Stdev' for s in x_axis_options] + \ - [s + '/Min' for s in x_axis_options] + \ - [s + '/Max' for s in x_axis_options] - for col in columns_to_remove: - del self.csv[col] - - # remove NaNs - self.csv.fillna(value=0, inplace=True) # removing this line will make bollinger bands fail - for key in self.csv.keys(): - if 'Stdev' in key and 'Evaluation' not in key: - self.csv[key] = self.csv[key].fillna(value=0) - - for signal_file in self.signals_files: - signal_file.update_source_and_signals() - - def change_averaging_window(self, new_size, force=False, signals=None): - for signal_file in self.signals_files: - signal_file.change_averaging_window(new_size, force, signals) - SignalsFileBase.change_averaging_window(self, new_size, force, signals) - - def set_signal_selection(self, signal_name, val): - self.show_files_separately(self.separate_files) - SignalsFileBase.set_signal_selection(self, signal_name, val) - - def file_was_modified_on_disk(self): - for signal_file in self.signals_files: - if signal_file.file_was_modified_on_disk(): - return True - return False - - def show_files_separately(self, val): - self.separate_files = val - for signal in self.signals.values(): - if signal.selected: - if val: - signal.set_dash("4 4") - else: - signal.set_dash("") - for signal_file in self.signals_files: - try: - if val: - signal_file.set_signal_selection(signal.name, signal.selected) - else: - signal_file.set_signal_selection(signal.name, False) - except: - pass - - -class RunType(Enum): - SINGLE_FOLDER_SINGLE_FILE = 1 - SINGLE_FOLDER_MULTIPLE_FILES = 2 - MULTIPLE_FOLDERS_SINGLE_FILES = 3 - MULTIPLE_FOLDERS_MULTIPLE_FILES = 4 - UNKNOWN = 0 - - -class FolderType(Enum): - SINGLE_FILE = 1 - MULTIPLE_FILES = 2 - MULTIPLE_FOLDERS = 3 - EMPTY = 4 - -dialog = DialogApp() - -# read data -patches = {} -signals_files = {} -selected_file = None -x_axis = 'Episode #' -x_axis_options = ['Episode #', 'Total steps', 'Wall-Clock Time'] -current_color = 0 - -# spinner -root_dir = os.path.dirname(os.path.abspath(__file__)) -with open(os.path.join(root_dir, 'spinner.css'), 'r') as f: - spinner_style = """""".format(f.read()) -spinner_html = """""" -spinner = Div(text="""""") - -# file refresh time placeholder -refresh_info = Div(text="""""", width=210) - -# create figures -plot = figure(plot_width=1200, plot_height=800, - tools='pan,box_zoom,wheel_zoom,crosshair,undo,redo,reset,save', - toolbar_location='above', x_axis_label='Episodes', - x_range=Range1d(0, 10000), y_range=Range1d(0, 100000)) -plot.extra_y_ranges = {"secondary": Range1d(start=-100, end=200)} -plot.add_layout(LinearAxis(y_range_name="secondary"), 'right') - -# legend -div = Div(text="""""") -legend = widgetbox([div]) - -bokeh_legend = Legend( - items=[("12345678901234567890123456789012345678901234567890", [])], # 50 letters - # items=[(" ", [])], # 50 letters - location=(-20, 0), orientation="vertical", - border_line_color="black", - label_text_font_size={'value': '9pt'}, - margin=30 -) -plot.add_layout(bokeh_legend, "right") - - -def update_axis_range(name, range_placeholder): - max_val = -float('inf') - min_val = float('inf') - selected_signal = None - if name in x_axis_options: - selected_signal = name - for signals_file in signals_files.values(): - curr_min_val, curr_max_val = signals_file.get_range_of_selected_signals_on_axis(name, selected_signal) - max_val = max(max_val, curr_max_val) - min_val = min(min_val, curr_min_val) - if min_val != float('inf'): - range = max_val - min_val - range_placeholder.start = min_val - 0.1 * range - range_placeholder.end = max_val + 0.1 * range - - -# update axes ranges -def update_ranges(): - update_axis_range('default', plot.y_range) - update_axis_range('secondary', plot.extra_y_ranges['secondary']) - - -def get_all_selected_signals(): - signals = [] - for signals_file in signals_files.values(): - signals += signals_file.get_selected_signals() - return signals - - -# update legend using the legend text dictionary -def update_legend(): - legend_text = """
""" - selected_signals = get_all_selected_signals() - items = [] - for signal in selected_signals: - side_sign = "<" if signal.axis == 'default' else ">" - legend_text += """
{} {}
"""\ - .format(signal.color, side_sign, signal.full_name) - items.append((signal.full_name, [signal.line])) - div.text = legend_text - # the visible=false => visible=true is a hack to make the legend render again - bokeh_legend.visible = False - bokeh_legend.items = items - bokeh_legend.visible = True - - -# select lines to display -def select_data(args, old, new): - if selected_file is None: - return - show_spinner() - selected_signals = new - for signal_name in selected_file.signals.keys(): - is_selected = signal_name in selected_signals - selected_file.set_signal_selection(signal_name, is_selected) - - # update axes ranges - update_ranges() - update_axis_range(x_axis, plot.x_range) - - # update the legend - update_legend() - - hide_spinner() - - -# add new lines to the plot -def plot_signals(signals_file, signals): - for idx, signal in enumerate(signals): - signal.line = plot.line('index', signal.name, source=signals_file.bokeh_source, - line_color=signal.color, line_width=2) - - -def open_file_dialog(): - return dialog.getFileDialog() - - -def open_directory_dialog(): - return dialog.getDirDialog() - - -def show_spinner(): - spinner.text = spinner_style + spinner_html - - -def hide_spinner(): - spinner.text = "" - - -# will create a group from the files -def create_files_group_signal(files): - global selected_file - signals_file = SignalsFilesGroup(files) - signals_files[signals_file.filename] = signals_file - - filenames = [signals_file.filename] - files_selector.options += filenames - files_selector.value = filenames[0] - selected_file = signals_file - - -# load files from disk as a group -def load_files_group(): - show_spinner() - files = open_file_dialog() - # no files selected - if not files or not files[0]: - hide_spinner() - return - - change_displayed_doc() - - if len(files) == 1: - create_files_signal(files) - else: - create_files_group_signal(files) - - change_selected_signals_in_data_selector([""]) - hide_spinner() - - -# classify the folder as containing a single file, multiple files or only folders -def classify_folder(dir_path): - files = [f for f in listdir(dir_path) if isfile(join(dir_path, f)) and f.endswith('.csv')] - folders = [d for d in listdir(dir_path) if isdir(join(dir_path, d))] - if len(files) == 1: - return FolderType.SINGLE_FILE - elif len(files) > 1: - return FolderType.MULTIPLE_FILES - elif len(folders) >= 1: - return FolderType.MULTIPLE_FOLDERS - else: - return FolderType.EMPTY - - -# finds if this is single-threaded or multi-threaded -def get_run_type(dir_path): - folder_type = classify_folder(dir_path) - if folder_type == FolderType.SINGLE_FILE: - return RunType.SINGLE_FOLDER_SINGLE_FILE - - elif folder_type == FolderType.MULTIPLE_FILES: - return RunType.SINGLE_FOLDER_MULTIPLE_FILES - - elif folder_type == FolderType.MULTIPLE_FOLDERS: - # folder contains sub dirs -> we assume we can classify the folder using only the first sub dir - sub_dirs = [d for d in listdir(dir_path) if isdir(join(dir_path, d))] - - # checking only the first folder in the root dir for its type, since we assume that all sub dirs will share the - # same structure (i.e. if one is a result of multi-threaded run, so will all the other). - folder_type = classify_folder(os.path.join(dir_path, sub_dirs[0])) - if folder_type == FolderType.SINGLE_FILE: - folder_type = RunType.MULTIPLE_FOLDERS_SINGLE_FILES - elif folder_type == FolderType.MULTIPLE_FILES: - folder_type = RunType.MULTIPLE_FOLDERS_MULTIPLE_FILES - return folder_type - - -# takes path to dir and recursively adds all it's files to paths -def add_directory_csv_files(dir_path, paths=None): - if not paths: - paths = [] - - for p in listdir(dir_path): - path = join(dir_path, p) - if isdir(path): - # call recursively for each dir - paths = add_directory_csv_files(path, paths) - elif isfile(path) and path.endswith('.csv'): - # add every file to the list - paths.append(path) - - return paths - - -# create a signal file from the directory path according to the directory underlying structure -def handle_dir(dir_path, run_type): - paths = add_directory_csv_files(dir_path) - if run_type == RunType.SINGLE_FOLDER_SINGLE_FILE: - create_files_signal(paths) - elif run_type == RunType.SINGLE_FOLDER_MULTIPLE_FILES: - create_files_group_signal(paths) - elif run_type == RunType.MULTIPLE_FOLDERS_SINGLE_FILES: - create_files_group_signal(paths) - elif run_type == RunType.MULTIPLE_FOLDERS_MULTIPLE_FILES: - sub_dirs = [d for d in listdir(dir_path) if isdir(join(dir_path, d))] - # for d in sub_dirs: - # paths = add_directory_csv_files(os.path.join(dir_path, d)) - # create_files_group_signal(paths) - create_files_group_signal([os.path.join(dir_path, d) for d in sub_dirs]) - - -# load directory from disk as a group -def load_directory_group(): - global selected_file - show_spinner() - directory = open_directory_dialog() - # no files selected - if not directory: - hide_spinner() - return - - change_displayed_doc() - - handle_dir(directory, get_run_type(directory)) - - change_selected_signals_in_data_selector([""]) - hide_spinner() - - -def create_files_signal(files): - global selected_file - new_signal_files = [] - for idx, file_path in enumerate(files): - signals_file = SignalsFile(str(file_path)) - signals_files[signals_file.filename] = signals_file - new_signal_files.append(signals_file) - - filenames = [f.filename for f in new_signal_files] - - files_selector.options += filenames - files_selector.value = filenames[0] - selected_file = new_signal_files[0] - - -# load files from disk -def load_files(): - show_spinner() - files = open_file_dialog() - - # no files selected - if not files or not files[0]: - hide_spinner() - return - - create_files_signal(files) - hide_spinner() - - change_selected_signals_in_data_selector([""]) - - -def unload_file(): - global selected_file - global signals_files - if selected_file is None: - return - selected_file.hide_all_signals() - del signals_files[selected_file.filename] - data_selector.options = [""] - filenames = cycle(files_selector.options) - files_selector.options.remove(selected_file.filename) - if len(files_selector.options) > 0: - files_selector.value = next(filenames) - else: - files_selector.value = None - update_legend() - refresh_info.text = "" - - -# reload the selected csv file -def reload_all_files(force=False): - for file_to_load in signals_files.values(): - if force or file_to_load.file_was_modified_on_disk(): - file_to_load.load() - refresh_info.text = "last update: " + str(datetime.datetime.now()).split(".")[0] - - -# unselect the currently selected signals and then select the requested signals in the data selector -def change_selected_signals_in_data_selector(selected_signals): - # the default bokeh way is not working due to a bug since Bokeh 0.12.6 (https://github.com/bokeh/bokeh/issues/6501) - # this will currently cause the signals to change color - for value in list(data_selector.value): - if value in data_selector.options: - index = data_selector.options.index(value) - data_selector.options.remove(value) - data_selector.value.remove(value) - data_selector.options.insert(index, value) - data_selector.value = selected_signals - - -# change data options according to the selected file -def change_data_selector(args, old, new): - global selected_file - if new is None: - selected_file = None - return - show_spinner() - selected_file = signals_files[new] - data_selector.options = sorted(list(selected_file.signals.keys())) - selected_signal_names = [s.name for s in selected_file.signals.values() if s.selected] - if not selected_signal_names: - selected_signal_names = [""] - change_selected_signals_in_data_selector(selected_signal_names) - averaging_slider.value = selected_file.signals_averaging_window - group_cb.active = [0 if selected_file.show_bollinger_bands else None] - group_cb.active += [1 if selected_file.separate_files else None] - hide_spinner() - - -# smooth all the signals of the selected file -def update_averaging(args, old, new): - show_spinner() - selected_file.change_averaging_window(new) - hide_spinner() - - -def change_x_axis(val): - global x_axis - show_spinner() - x_axis = x_axis_options[val] - plot.xaxis.axis_label = x_axis - reload_all_files(force=True) - update_axis_range(x_axis, plot.x_range) - hide_spinner() - - -# move the signal between the main and secondary Y axes -def toggle_second_axis(): - show_spinner() - signals = selected_file.get_selected_signals() - for signal in signals: - signal.toggle_axis() - - update_ranges() - update_legend() - - # this is just for redrawing the signals - selected_file.reload_data([signal.name for signal in signals]) - - hide_spinner() - - -def toggle_group_property(new): - # toggle show / hide Bollinger bands - selected_file.change_bollinger_bands_state(0 in new) - - # show a separate signal for each file in a group - selected_file.show_files_separately(1 in new) - - -def change_displayed_doc(): - if doc.roots[0] == landing_page: - doc.remove_root(landing_page) - doc.add_root(layout) - - -# Color selection - most of these functions are taken from bokeh examples (plotting/color_sliders.py) -def select_color(attr, old, new): - show_spinner() - signals = selected_file.get_selected_signals() - for signal in signals: - signal.set_color(rgb_to_hex(crRGBs[new['1d']['indices'][0]])) - hide_spinner() - - -def generate_color_range(N, I): - HSV_tuples = [(x*1.0/N, 0.5, I) for x in range(N)] - RGB_tuples = map(lambda x: colorsys.hsv_to_rgb(*x), HSV_tuples) - for_conversion = [] - for RGB_tuple in RGB_tuples: - for_conversion.append((int(RGB_tuple[0]*255), int(RGB_tuple[1]*255), int(RGB_tuple[2]*255))) - hex_colors = [rgb_to_hex(RGB_tuple) for RGB_tuple in for_conversion] - return hex_colors, for_conversion - - -# convert RGB tuple to hexadecimal code -def rgb_to_hex(rgb): - return '#%02x%02x%02x' % rgb - - -# convert hexadecimal to RGB tuple -def hex_to_dec(hex): - red = ''.join(hex.strip('#')[0:2]) - green = ''.join(hex.strip('#')[2:4]) - blue = ''.join(hex.strip('#')[4:6]) - return int(red, 16), int(green, 16), int(blue,16) - -color_resolution = 1000 -brightness = 0.75 # change to have brighter/darker colors -crx = list(range(1, color_resolution+1)) # the resolution is 1000 colors -cry = [5 for i in range(len(crx))] -crcolor, crRGBs = generate_color_range(color_resolution, brightness) # produce spectrum - - -# ---------------- Build Website Layout ------------------- - -# select file -file_selection_button = Button(label="Select Files", button_type="success", width=120) -file_selection_button.on_click(load_files_group) - -files_selector_spacer = Spacer(width=10) - -group_selection_button = Button(label="Select Directory", button_type="primary", width=140) -group_selection_button.on_click(load_directory_group) - -unload_file_button = Button(label="Unload", button_type="danger", width=50) -unload_file_button.on_click(unload_file) - -# files selection box -files_selector = Select(title="Files:", options=[], width=200) -files_selector.on_change('value', change_data_selector) - -# data selection box -data_selector = MultiSelect(title="Data:", options=[], size=12) -data_selector.on_change('value', select_data) - -# x axis selection box -x_axis_selector_title = Div(text="""X Axis:""") -x_axis_selector = RadioButtonGroup(labels=x_axis_options, active=0) -x_axis_selector.on_click(change_x_axis) - -# toggle second axis button -toggle_second_axis_button = Button(label="Toggle Second Axis", button_type="success") -toggle_second_axis_button.on_click(toggle_second_axis) - -# averaging slider -averaging_slider = Slider(title="Averaging window", start=1, end=101, step=10) -averaging_slider.on_change('value', update_averaging) - -# group properties checkbox -group_cb = CheckboxGroup(labels=["Show statistics bands", "Ungroup signals"], active=[]) -group_cb.on_click(toggle_group_property) - -# color selector -color_selector_title = Div(text="""Select Color:""") -crsource = ColumnDataSource(data=dict(x=crx, y=cry, crcolor=crcolor, RGBs=crRGBs)) -color_selector = figure(x_range=(0, color_resolution), y_range=(0, 10), - plot_width=300, plot_height=40, - tools='tap') -color_selector.axis.visible = False -color_range = color_selector.rect(x='x', y='y', width=1, height=10, - color='crcolor', source=crsource) -crsource.on_change('selected', select_color) -color_range.nonselection_glyph = color_range.glyph -color_selector.toolbar.logo = None -color_selector.toolbar_location = None - -# title -title = Div(text="""

Coach Dashboard

""") - -# landing page -landing_page_description = Div(text="""

Start by selecting an experiment file or directory to open:

""") -center = Div(text="""""") -center_buttons = Div(text="""""", width=0) -landing_page = column(center, - title, - landing_page_description, - row(center_buttons), - row(file_selection_button, sizing_mode='scale_width'), - row(group_selection_button, sizing_mode='scale_width'), - sizing_mode='scale_width') - -# main layout of the document -layout = row(file_selection_button, files_selector_spacer, group_selection_button, width=300) -layout = column(layout, files_selector) -layout = column(layout, row(refresh_info, unload_file_button)) -layout = column(layout, data_selector) -layout = column(layout, color_selector_title) -layout = column(layout, color_selector) -layout = column(layout, x_axis_selector_title) -layout = column(layout, x_axis_selector) -layout = column(layout, group_cb) -layout = column(layout, toggle_second_axis_button) -layout = column(layout, averaging_slider) -# layout = column(layout, legend) -layout = row(layout, plot) -layout = column(title, layout) -layout = column(layout, spinner) - -doc = curdoc() doc.add_root(landing_page) -doc.add_periodic_callback(reload_all_files, 20000) -plot.y_range = Range1d(0, 100) -plot.extra_y_ranges['secondary'] = Range1d(0, 100) +import argparse +import glob -# show load file dialog immediately on start -#doc.add_timeout_callback(load_files, 1000) +parser = argparse.ArgumentParser() +parser.add_argument('-d', '--experiment_dir', + help="(string) The path of an experiment dir to open", + default=None, + type=str) +parser.add_argument('-f', '--experiment_files', + help="(string) The path of an experiment file to open", + default=None, + type=str) +args = parser.parse_args() + +if args.experiment_dir: + doc.add_timeout_callback(lambda: display_directory_group(args.experiment_dir), 1000) +elif args.experiment_files: + files = [] + for file_pattern in args.experiment_files: + files.extend(glob.glob(args.experiment_files)) + doc.add_timeout_callback(lambda: display_files(files), 1000) if __name__ == "__main__": - # find an open port and run the server - import socket - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - port = 12345 - while True: - try: - s.bind(("127.0.0.1", port)) - break - except socket.error as e: - if e.errno == 98: - port += 1 - s.close() - os.system('bokeh serve --show dashboard.py --port {}'.format(port)) + from utils import get_open_port + + command = 'bokeh serve --show dashboard.py --port {}'.format(get_open_port()) + if args.experiment_dir or args.experiment_files: + command += ' --args' + if args.experiment_dir: + command += ' --experiment_dir {}'.format(args.experiment_dir) + if args.experiment_files: + command += ' --experiment_files {}'.format(args.experiment_files) + + os.system(command) diff --git a/dashboard_components/__init__.py b/dashboard_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard_components/boards.py b/dashboard_components/boards.py new file mode 100644 index 0000000..dcbb5a3 --- /dev/null +++ b/dashboard_components/boards.py @@ -0,0 +1,18 @@ +from bokeh.layouts import column +from bokeh.models.widgets import Panel, Tabs +from dashboard_components.experiment_board import experiment_board_layout +from dashboard_components.globals import spinner, layouts +from bokeh.models.widgets import Div + +# ---------------- Build Website Layout ------------------- + +# title +title = Div(text="""

Coach Dashboard

""") + +tab1 = Panel(child=experiment_board_layout, title='experiment board') +tabs = Tabs(tabs=[tab1]) + +layout = column(title, tabs) +layout = column(layout, spinner) + +layouts['boards'] = layout diff --git a/dashboard_components/experiment_board.py b/dashboard_components/experiment_board.py new file mode 100644 index 0000000..a37cc87 --- /dev/null +++ b/dashboard_components/experiment_board.py @@ -0,0 +1,473 @@ + +import datetime +import os +import sys +import time +from itertools import cycle +from os import listdir +from os.path import isfile, join, isdir + +from bokeh.models.callbacks import CustomJS +from bokeh.layouts import row, column, Spacer +from bokeh.models import ColumnDataSource, Range1d, LinearAxis, Legend +from bokeh.models.widgets import RadioButtonGroup, MultiSelect, Button, Select, Slider, Div, CheckboxGroup +from bokeh.plotting import figure + +from dashboard_components.globals import signals_files, x_axis_labels, x_axis_options, show_spinner, hide_spinner, \ + x_axis, dialog, FolderType, RunType, add_directory_csv_files, doc, display_boards, layouts, \ + crcolor, crx, cry, color_resolution, crRGBs, rgb_to_hex +from dashboard_components.signals_files_group import SignalsFilesGroup +from dashboard_components.signals_file import SignalsFile + + +def update_axis_range(name, range_placeholder): + max_val = -float('inf') + min_val = float('inf') + selected_signal = None + if name in x_axis_options: + selected_signal = name + for signals_file in signals_files.values(): + curr_min_val, curr_max_val = signals_file.get_range_of_selected_signals_on_axis(name, selected_signal) + max_val = max(max_val, curr_max_val) + min_val = min(min_val, curr_min_val) + if min_val != float('inf'): + range = max_val - min_val + range_placeholder.start = min_val - 0.1 * range + range_placeholder.end = max_val + 0.1 * range + + +# update axes ranges +def update_y_axis_ranges(): + update_axis_range('default', plot.y_range) + update_axis_range('secondary', plot.extra_y_ranges['secondary']) + + +def update_x_axis_ranges(): + update_axis_range(x_axis[0], plot.x_range) + + +def get_all_selected_signals(): + signals = [] + for signals_file in signals_files.values(): + signals += signals_file.get_selected_signals() + return signals + + +# update legend using the legend text dictionary +def update_legend(): + selected_signals = get_all_selected_signals() + items = [] + for signal in selected_signals: + side_sign = "◀" if signal.axis == 'default' else "▶" + items.append((side_sign + " " + signal.full_name, [signal.line])) + # the visible=false => visible=true is a hack to make the legend render again + bokeh_legend.visible = False + bokeh_legend.items = items # this step takes a long time because it is redrawing the plot + bokeh_legend.visible = True + + +# select lines to display +def select_data(args, old, new): + if selected_file is None: + return + show_spinner("Updating the signal selection...") + selected_signals = new + for signal_name in selected_file.signals.keys(): + is_selected = signal_name in selected_signals + selected_file.set_signal_selection(signal_name, is_selected) + + # update axes ranges + update_y_axis_ranges() + update_x_axis_ranges() + + # update the legend + update_legend() + + hide_spinner() + + +# add new lines to the plot +def plot_signals(signals_file, signals): + for idx, signal in enumerate(signals): + signal.line = plot.line('index', signal.name, source=signals_file.bokeh_source, + line_color=signal.color, line_width=2) + + +def open_file_dialog(): + return dialog.getFileDialog() + + +def open_directory_dialog(): + return dialog.getDirDialog() + + +# will create a group from the files +def create_files_group_signal(files): + global selected_file + signals_file = SignalsFilesGroup(files, plot) + signals_files[signals_file.filename] = signals_file + + filenames = [signals_file.filename] + files_selector.options += filenames + files_selector.value = filenames[0] + selected_file = signals_file + + +# load files from disk as a group +def load_files_group(): + show_spinner("Loading files group...") + files = open_file_dialog() + # no files selected + if not files or not files[0]: + hide_spinner() + return + + display_boards() + + if len(files) == 1: + create_files_signal(files) + else: + create_files_group_signal(files) + + change_selected_signals_in_data_selector([""]) + hide_spinner() + + +# classify the folder as containing a single file, multiple files or only folders +def classify_folder(dir_path): + files = [f for f in listdir(dir_path) if isfile(join(dir_path, f)) and f.endswith('.csv')] + folders = [d for d in listdir(dir_path) if isdir(join(dir_path, d)) and any(f.endswith(".csv") for f in os.listdir(join(dir_path, d)))] + if len(files) == 1: + return FolderType.SINGLE_FILE + elif len(files) > 1: + return FolderType.MULTIPLE_FILES + elif len(folders) == 1: + return classify_folder(join(dir_path, folders[0])) + elif len(folders) > 1: + return FolderType.MULTIPLE_FOLDERS + else: + return FolderType.EMPTY + + +# finds if this is single-threaded or multi-threaded +def get_run_type(dir_path): + folder_type = classify_folder(dir_path) + if folder_type == FolderType.SINGLE_FILE: + folder_type = RunType.SINGLE_FOLDER_SINGLE_FILE + + elif folder_type == FolderType.MULTIPLE_FILES: + folder_type = RunType.SINGLE_FOLDER_MULTIPLE_FILES + + elif folder_type == FolderType.MULTIPLE_FOLDERS: + # folder contains sub dirs -> we assume we can classify the folder using only the first sub dir + sub_dirs = [d for d in listdir(dir_path) if isdir(join(dir_path, d))] + + # checking only the first folder in the root dir for its type, since we assume that all sub dirs will share the + # same structure (i.e. if one is a result of multi-threaded run, so will all the other). + folder_type = classify_folder(os.path.join(dir_path, sub_dirs[0])) + if folder_type == FolderType.SINGLE_FILE: + folder_type = RunType.MULTIPLE_FOLDERS_SINGLE_FILES + elif folder_type == FolderType.MULTIPLE_FILES: + folder_type = RunType.MULTIPLE_FOLDERS_MULTIPLE_FILES + + return folder_type + + +# create a signal file from the directory path according to the directory underlying structure +def handle_dir(dir_path, run_type): + paths = add_directory_csv_files(dir_path) + if run_type in [RunType.SINGLE_FOLDER_SINGLE_FILE, + RunType.SINGLE_FOLDER_MULTIPLE_FILES, + RunType.MULTIPLE_FOLDERS_SINGLE_FILES]: + create_files_group_signal(paths) + elif run_type == RunType.MULTIPLE_FOLDERS_MULTIPLE_FILES: + sub_dirs = [d for d in listdir(dir_path) if isdir(join(dir_path, d))] + # for d in sub_dirs: + # paths = add_directory_csv_files(os.path.join(dir_path, d)) + # create_files_group_signal(paths) + create_files_group_signal([os.path.join(dir_path, d) for d in sub_dirs]) + + +# load directory from disk as a group +def load_directory_group(): + show_spinner("Loading directories group...") + directory = open_directory_dialog() + # no files selected + if not directory: + hide_spinner() + return + + display_directory_group(directory) + + +def display_directory_group(directory): + display_boards() + show_spinner("Loading directories group...") + + while get_run_type(directory) == FolderType.EMPTY: + show_spinner("Waiting for experiment directory to get populated...") + sys.stdout.write("Waiting for experiment directory to get populated...\r") + time.sleep(10) + + handle_dir(directory, get_run_type(directory)) + + change_selected_signals_in_data_selector([""]) + hide_spinner() + + +def create_files_signal(files): + global selected_file + new_signal_files = [] + for idx, file_path in enumerate(files): + signals_file = SignalsFile(str(file_path), plot=plot) + signals_files[signals_file.filename] = signals_file + new_signal_files.append(signals_file) + + filenames = [f.filename for f in new_signal_files] + + files_selector.options += filenames + files_selector.value = filenames[0] + selected_file = new_signal_files[0] + + +# load files from disk +def load_files(): + show_spinner("Loading files...") + files = open_file_dialog() + + # no files selected + if not files or not files[0]: + hide_spinner() + return + + display_files(files) + + +def display_files(files): + display_boards() + show_spinner("Loading files...") + + create_files_signal(files) + + change_selected_signals_in_data_selector([""]) + hide_spinner() + + +def unload_file(): + if selected_file is None: + return + selected_file.hide_all_signals() + del signals_files[selected_file.filename] + data_selector.options = [""] + filenames = cycle(files_selector.options) + files_selector.options.remove(selected_file.filename) + if len(files_selector.options) > 0: + files_selector.value = next(filenames) + else: + files_selector.value = None + update_legend() + refresh_info.text = "" + + +# reload the selected csv file +def reload_all_files(force=False): + for file_to_load in signals_files.values(): + if force or file_to_load.file_was_modified_on_disk(): + file_to_load.load() + refresh_info.text = "last update: " + str(datetime.datetime.now()).split(".")[0] + + +# unselect the currently selected signals and then select the requested signals in the data selector +def change_selected_signals_in_data_selector(selected_signals): + # the default bokeh way is not working due to a bug since Bokeh 0.12.6 (https://github.com/bokeh/bokeh/issues/6501) + # remove the data selection callback before updating the selector + data_selector.remove_on_change('value', select_data) + for value in list(data_selector.value): + if value in data_selector.options: + index = data_selector.options.index(value) + data_selector.options.remove(value) + data_selector.value.remove(value) + data_selector.options.insert(index, value) + data_selector.value = selected_signals + # add back the data selection callback + data_selector.on_change('value', select_data) + + +# change data options according to the selected file +def change_data_selector(args, old, new): + global selected_file + if new is None: + selected_file = None + return + show_spinner("Updating selection...") + selected_file = signals_files[new] + data_selector.remove_on_change('value', select_data) + data_selector.options = sorted(list(selected_file.signals.keys())) + data_selector.on_change('value', select_data) + selected_signal_names = [s.name for s in selected_file.signals.values() if s.selected] + if not selected_signal_names: + selected_signal_names = [""] + change_selected_signals_in_data_selector(selected_signal_names) + averaging_slider.value = selected_file.signals_averaging_window + if len(averaging_slider_dummy_source.data['value']) > 0: + averaging_slider_dummy_source.data['value'][0] = selected_file.signals_averaging_window + group_cb.active = [0 if selected_file.show_bollinger_bands else None] + group_cb.active += [1 if selected_file.separate_files else None] + hide_spinner() + + +# smooth all the signals of the selected file +def update_averaging(args, old, new): + show_spinner("Smoothing the signals...") + # get the actual value from the dummy source + new = averaging_slider_dummy_source.data['value'][0] + selected_file.change_averaging_window(new) + hide_spinner() + + +def change_x_axis(val): + global x_axis + show_spinner("Updating the X axis...") + x_axis[0] = x_axis_options[val] + plot.xaxis.axis_label = x_axis_labels[val] + + for file_to_load in signals_files.values(): + file_to_load.update_x_axis_index() + + update_axis_range(x_axis[0], plot.x_range) + hide_spinner() + + +# move the signal between the main and secondary Y axes +def toggle_second_axis(): + show_spinner("Switching the Y axis...") + plot.yaxis[-1].visible = True + selected_file.toggle_y_axis() + + # this is just for redrawing the signals + selected_file.reload_data() + + update_y_axis_ranges() + update_legend() + + hide_spinner() + + +def toggle_group_property(new): + # toggle show / hide Bollinger bands + selected_file.change_bollinger_bands_state(0 in new) + + # show a separate signal for each file in a group + selected_file.show_files_separately(1 in new) + + +# Color selection - most of these functions are taken from bokeh examples (plotting/color_sliders.py) +def select_color(attr, old, new): + show_spinner("Changing signal color...") + signals = selected_file.get_selected_signals() + for signal in signals: + signal.set_color(rgb_to_hex(crRGBs[new['1d']['indices'][0]])) + hide_spinner() + + +doc.add_periodic_callback(reload_all_files, 20000) + +# ---------------- Build Website Layout ------------------- + +# file refresh time placeholder +refresh_info = Div(text="""""", width=210) + +# create figures +plot = figure(plot_width=1200, plot_height=800, + tools='pan,box_zoom,wheel_zoom,crosshair,undo,redo,reset,save', + toolbar_location='above', x_axis_label='Episodes', + x_range=Range1d(0, 10000), y_range=Range1d(0, 100000)) +plot.extra_y_ranges = {"secondary": Range1d(start=-100, end=200)} +plot.add_layout(LinearAxis(y_range_name="secondary"), 'right') +plot.yaxis[-1].visible = False + +bokeh_legend = Legend( + # items=[("12345678901234567890123456789012345678901234567890", [])], # 50 letters + items=[("__________________________________________________", [])], # 50 letters + location=(0, 0), orientation="vertical", + border_line_color="black", + label_text_font_size={'value': '9pt'}, + margin=30 +) +plot.add_layout(bokeh_legend, "right") +plot.y_range = Range1d(0, 100) +plot.extra_y_ranges['secondary'] = Range1d(0, 100) + +# select file +file_selection_button = Button(label="Select Files", button_type="success", width=120) +file_selection_button.on_click(load_files_group) + +files_selector_spacer = Spacer(width=10) + +group_selection_button = Button(label="Select Directory", button_type="primary", width=140) +group_selection_button.on_click(load_directory_group) + +unload_file_button = Button(label="Unload", button_type="danger", width=50) +unload_file_button.on_click(unload_file) + +# files selection box +files_selector = Select(title="Files:", options=[]) +files_selector.on_change('value', change_data_selector) + +# data selection box +data_selector = MultiSelect(title="Data:", options=[], size=12) +data_selector.on_change('value', select_data) + +# x axis selection box +x_axis_selector_title = Div(text="""X Axis:""") +x_axis_selector = RadioButtonGroup(labels=x_axis_options, active=0) +x_axis_selector.on_click(change_x_axis) + +# toggle second axis button +toggle_second_axis_button = Button(label="Toggle Second Axis", button_type="success") +toggle_second_axis_button.on_click(toggle_second_axis) + +# averaging slider +# This data source is just used to communicate / trigger the real callback +averaging_slider_dummy_source = ColumnDataSource(data=dict(value=[])) +averaging_slider_dummy_source.on_change('data', update_averaging) +averaging_slider = Slider(title="Averaging window", start=1, end=101, step=10, callback_policy='mouseup') +averaging_slider.callback = CustomJS(args=dict(source=averaging_slider_dummy_source), code=""" + source.data = { value: [cb_obj.value] } +""") + +# group properties checkbox +group_cb = CheckboxGroup(labels=["Show statistics bands", "Ungroup signals"], active=[]) +group_cb.on_click(toggle_group_property) + +# color selector +color_selector_title = Div(text="""Select Color:""") +crsource = ColumnDataSource(data=dict(x=crx, y=cry, crcolor=crcolor, RGBs=crRGBs)) +color_selector = figure(x_range=(0, color_resolution), y_range=(0, 10), + plot_width=300, plot_height=40, + tools='tap') +color_selector.axis.visible = False +color_range = color_selector.rect(x='x', y='y', width=1, height=10, + color='crcolor', source=crsource) +crsource.on_change('selected', select_color) +color_range.nonselection_glyph = color_range.glyph +color_selector.toolbar.logo = None +color_selector.toolbar_location = None + +# main layout of the document +layout = row(file_selection_button, files_selector_spacer, group_selection_button, width=300) +layout = column(layout, files_selector) +layout = column(layout, row(refresh_info, unload_file_button)) +layout = column(layout, data_selector) +layout = column(layout, color_selector_title) +layout = column(layout, color_selector) +layout = column(layout, x_axis_selector_title) +layout = column(layout, x_axis_selector) +layout = column(layout, group_cb) +layout = column(layout, toggle_second_axis_button) +layout = column(layout, averaging_slider) +layout = row(layout, plot) + +experiment_board_layout = layout + +layouts["experiment_board"] = experiment_board_layout diff --git a/dashboard_components/globals.py b/dashboard_components/globals.py new file mode 100644 index 0000000..fcad7aa --- /dev/null +++ b/dashboard_components/globals.py @@ -0,0 +1,136 @@ +import os +from genericpath import isdir, isfile +from os import listdir +from os.path import join +from utils import Enum +from bokeh.models import Div +from bokeh.plotting import curdoc +import wx +import colorsys + +patches = {} +signals_files = {} +selected_file = None +x_axis = ['Episode #'] +x_axis_options = ['Episode #', 'Total steps', 'Wall-Clock Time'] +x_axis_labels = ['Episode #', 'Total steps', 'Wall-Clock Time (minutes)'] +current_color = 0 + +# spinner +root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +with open(os.path.join(root_dir, 'spinner.css'), 'r') as f: + spinner_style = """""".format(f.read()) + spinner_html = """""" +spinner = Div(text="""""") +displayed_doc = "landing_page" +layouts = {} + + +def generate_color_range(N, I): + HSV_tuples = [(x*1.0/N, 0.5, I) for x in range(N)] + RGB_tuples = map(lambda x: colorsys.hsv_to_rgb(*x), HSV_tuples) + for_conversion = [] + for RGB_tuple in RGB_tuples: + for_conversion.append((int(RGB_tuple[0]*255), int(RGB_tuple[1]*255), int(RGB_tuple[2]*255))) + hex_colors = [rgb_to_hex(RGB_tuple) for RGB_tuple in for_conversion] + return hex_colors, for_conversion + + +# convert RGB tuple to hexadecimal code +def rgb_to_hex(rgb): + return '#%02x%02x%02x' % rgb + + +# convert hexadecimal to RGB tuple +def hex_to_dec(hex): + red = ''.join(hex.strip('#')[0:2]) + green = ''.join(hex.strip('#')[2:4]) + blue = ''.join(hex.strip('#')[4:6]) + return int(red, 16), int(green, 16), int(blue,16) + + +color_resolution = 1000 +brightness = 0.75 # change to have brighter/darker colors +crx = list(range(1, color_resolution+1)) # the resolution is 1000 colors +cry = [5 for i in range(len(crx))] +crcolor, crRGBs = generate_color_range(color_resolution, brightness) # produce spectrum + + +def display_boards(): + global displayed_doc + if displayed_doc == "landing_page": + doc.remove_root(doc.roots[0]) + doc.add_root(layouts["boards"]) + displayed_doc = "boards" + + +def show_spinner(text="Loading..."): + spinner.text = spinner_style + spinner_html.format(text) + + +def hide_spinner(): + spinner.text = "" + + +# takes path to dir and recursively adds all it's files to paths +def add_directory_csv_files(dir_path, paths=None): + if not paths: + paths = [] + + for p in listdir(dir_path): + path = join(dir_path, p) + if isdir(path): + # call recursively for each dir + paths = add_directory_csv_files(path, paths) + elif isfile(path) and path.endswith('.csv'): + # add every file to the list + paths.append(path) + + return paths + + +class DialogApp(wx.App): + def getFileDialog(self): + with wx.FileDialog(None, "Open CSV file", wildcard="CSV files (*.csv)|*.csv", + style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST | wx.FD_CHANGE_DIR | wx.FD_MULTIPLE) as fileDialog: + if fileDialog.ShowModal() == wx.ID_CANCEL: + return None # the user changed their mind + else: + # Proceed loading the file chosen by the user + return fileDialog.GetPaths() + + def getDirDialog(self): + with wx.DirDialog (None, "Choose input directory", "", + style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST | wx.FD_CHANGE_DIR) as dirDialog: + if dirDialog.ShowModal() == wx.ID_CANCEL: + return None # the user changed their mind + else: + # Proceed loading the dir chosen by the user + return dirDialog.GetPath() + + +class RunType(Enum): + SINGLE_FOLDER_SINGLE_FILE = 1 + SINGLE_FOLDER_MULTIPLE_FILES = 2 + MULTIPLE_FOLDERS_SINGLE_FILES = 3 + MULTIPLE_FOLDERS_MULTIPLE_FILES = 4 + UNKNOWN = 0 + + +class FolderType(Enum): + SINGLE_FILE = 1 + MULTIPLE_FILES = 2 + MULTIPLE_FOLDERS = 3 + EMPTY = 4 + + +dialog = DialogApp() + +doc = curdoc() diff --git a/dashboard_components/landing_page.py b/dashboard_components/landing_page.py new file mode 100644 index 0000000..12c51f1 --- /dev/null +++ b/dashboard_components/landing_page.py @@ -0,0 +1,21 @@ +from bokeh.layouts import row, column +from bokeh.models.widgets import Div +from dashboard_components.experiment_board import file_selection_button, group_selection_button +from dashboard_components.globals import layouts + +# title +title = Div(text="""

Coach Dashboard

""") + +# landing page +landing_page_description = Div(text="""

Start by selecting an experiment file or directory to open:

""") +center = Div(text="""""") +center_buttons = Div(text="""""", width=0) +landing_page = column(center, + title, + landing_page_description, + row(center_buttons), + row(file_selection_button, sizing_mode='scale_width'), + row(group_selection_button, sizing_mode='scale_width'), + sizing_mode='scale_width') + +layouts['landing_page'] = landing_page diff --git a/dashboard_components/signals.py b/dashboard_components/signals.py new file mode 100644 index 0000000..19f10b9 --- /dev/null +++ b/dashboard_components/signals.py @@ -0,0 +1,123 @@ +import random + +import numpy as np +from bokeh.models import ColumnDataSource +from bokeh.palettes import Dark2 +from dashboard_components.globals import show_spinner, hide_spinner, current_color +from utils import squeeze_list + + +class Signal: + def __init__(self, name, parent, plot): + self.name = name + self.full_name = "{}/{}".format(parent.filename, self.name) + self.plot = plot + self.selected = False + self.color = random.choice(Dark2[8]) + self.line = None + self.bands = None + self.bokeh_source = parent.bokeh_source + self.min_val = 0 + self.max_val = 0 + self.axis = 'default' + self.sub_signals = [] + for name in self.bokeh_source.data.keys(): + if (len(name.split('/')) == 1 and name == self.name) or '/'.join(name.split('/')[:-1]) == self.name: + self.sub_signals.append(name) + if len(self.sub_signals) > 1: + self.mean_signal = squeeze_list([name for name in self.sub_signals if 'Mean' in name.split('/')[-1]]) + self.stdev_signal = squeeze_list([name for name in self.sub_signals if 'Stdev' in name.split('/')[-1]]) + self.min_signal = squeeze_list([name for name in self.sub_signals if 'Min' in name.split('/')[-1]]) + self.max_signal = squeeze_list([name for name in self.sub_signals if 'Max' in name.split('/')[-1]]) + else: + self.mean_signal = squeeze_list(self.name) + self.stdev_signal = None + self.min_signal = None + self.max_signal = None + self.has_bollinger_bands = False + if self.mean_signal and self.stdev_signal and self.min_signal and self.max_signal: + self.has_bollinger_bands = True + self.show_bollinger_bands = False + self.bollinger_bands_source = None + self.update_range() + + def set_color(self, color): + self.color = color + if self.line: + self.line.glyph.line_color = color + if self.bands: + self.bands.glyph.fill_color = color + + def plot_line(self): + global current_color + self.set_color(Dark2[8][current_color]) + current_color = (current_color + 1) % len(Dark2[8]) + if self.has_bollinger_bands: + self.set_bands_source() + self.create_bands() + self.line = self.plot.line('index', self.mean_signal, source=self.bokeh_source, + line_color=self.color, line_width=2) + self.line.visible = True + + def set_selected(self, val): + if self.selected != val: + self.selected = val + if self.line: + # self.set_color(Dark2[8][current_color]) + # current_color = (current_color + 1) % len(Dark2[8]) + self.line.visible = self.selected + if self.bands: + self.bands.visible = self.selected and self.show_bollinger_bands + elif self.selected: + # lazy plotting - plot only when selected for the first time + self.plot_line() + + def set_dash(self, dash): + self.line.glyph.line_dash = dash + + def create_bands(self): + self.bands = self.plot.patch(x='band_x', y='band_y', source=self.bollinger_bands_source, + color=self.color, fill_alpha=0.4, alpha=0.1, line_width=0) + self.bands.visible = self.show_bollinger_bands + # self.min_line = plot.line('index', self.min_signal, source=self.bokeh_source, + # line_color=self.color, line_width=3, line_dash="4 4") + # self.max_line = plot.line('index', self.max_signal, source=self.bokeh_source, + # line_color=self.color, line_width=3, line_dash="4 4") + # self.min_line.visible = self.show_bollinger_bands + # self.max_line.visible = self.show_bollinger_bands + + def set_bands_source(self): + x_ticks = self.bokeh_source.data['index'] + mean_values = self.bokeh_source.data[self.mean_signal] + stdev_values = self.bokeh_source.data[self.stdev_signal] + band_x = np.append(x_ticks, x_ticks[::-1]) + band_y = np.append(mean_values - stdev_values, mean_values[::-1] + stdev_values[::-1]) + source_data = {'band_x': band_x, 'band_y': band_y} + if self.bollinger_bands_source: + self.bollinger_bands_source.data = source_data + else: + self.bollinger_bands_source = ColumnDataSource(source_data) + + def change_bollinger_bands_state(self, new_state): + self.show_bollinger_bands = new_state + if self.bands and self.selected: + self.bands.visible = new_state + # self.min_line.visible = new_state + # self.max_line.visible = new_state + + def update_range(self): + self.min_val = np.min(self.bokeh_source.data[self.mean_signal]) + self.max_val = np.max(self.bokeh_source.data[self.mean_signal]) + + def set_axis(self, axis): + self.axis = axis + if not self.line: + self.plot_line() + self.line.visible = False + self.line.y_range_name = axis + + def toggle_axis(self): + if self.axis == 'default': + self.set_axis('secondary') + else: + self.set_axis('default') diff --git a/dashboard_components/signals_file.py b/dashboard_components/signals_file.py new file mode 100644 index 0000000..1e89e18 --- /dev/null +++ b/dashboard_components/signals_file.py @@ -0,0 +1,39 @@ +import os + +import pandas as pd +from pandas.errors import EmptyDataError + +from dashboard_components.signals_file_base import SignalsFileBase +from utils import break_file_path + + +class SignalsFile(SignalsFileBase): + def __init__(self, csv_path, load=True, plot=None): + super().__init__(plot) + self.full_csv_path = csv_path + self.dir, self.filename, _ = break_file_path(csv_path) + if load: + self.load() + # this helps set the correct x axis + self.change_averaging_window(1, force=True) + + def load_csv(self): + # load csv and fix sparse data. + # csv can be in the middle of being written so we use try - except + self.csv = None + while self.csv is None: + try: + self.csv = pd.read_csv(self.full_csv_path) + break + except EmptyDataError: + self.csv = None + continue + self.csv = self.csv.interpolate() + self.csv.fillna(value=0, inplace=True) + + self.csv['Wall-Clock Time'] /= 60. + + self.last_modified = os.path.getmtime(self.full_csv_path) + + def file_was_modified_on_disk(self): + return self.last_modified != os.path.getmtime(self.full_csv_path) \ No newline at end of file diff --git a/dashboard_components/signals_file_base.py b/dashboard_components/signals_file_base.py new file mode 100644 index 0000000..1d5628a --- /dev/null +++ b/dashboard_components/signals_file_base.py @@ -0,0 +1,132 @@ +import numpy +from bokeh.models import ColumnDataSource +from bokeh.palettes import Dark2 +from dashboard_components.globals import x_axis, x_axis_options, show_spinner +from dashboard_components.signals import Signal +import numpy as np +import copy + + +class SignalsFileBase: + def __init__(self, plot): + self.plot = plot + self.full_csv_path = "" + self.dir = "" + self.filename = "" + self.signals_averaging_window = 1 + self.show_bollinger_bands = False + self.csv = None + self.bokeh_source = None + self.bokeh_source_orig = None + self.last_modified = None + self.signals = {} + self.separate_files = False + self.last_reload_data_fix = False + + def load_csv(self): + pass + + def update_x_axis_index(self): + global x_axis + self.bokeh_source_orig.data['index'] = self.bokeh_source_orig.data[x_axis[0]] + self.bokeh_source.data['index'] = self.bokeh_source.data[x_axis[0]] + + def toggle_y_axis(self, signal_name=None): + if signal_name: + self.signals[signal_name].toggle_axis() + else: + for signal in self.signals.values(): + if signal.selected: + signal.toggle_axis() + + def update_source_and_signals(self): + # create bokeh data sources + self.bokeh_source_orig = ColumnDataSource(self.csv) + self.bokeh_source_orig.data['index'] = self.bokeh_source_orig.data[x_axis[0]] + + if self.bokeh_source is None: + self.bokeh_source = ColumnDataSource(self.csv) + else: + # smooth the data if necessary + self.change_averaging_window(self.signals_averaging_window, force=True) + + self.update_x_axis_index() + + # create all the signals + if len(self.signals.keys()) == 0: + self.signals = {} + unique_signal_names = [] + for name in self.csv.columns: + if len(name.split('/')) == 1: + unique_signal_names.append(name) + else: + unique_signal_names.append('/'.join(name.split('/')[:-1])) + unique_signal_names = list(set(unique_signal_names)) + for signal_name in unique_signal_names: + self.signals[signal_name] = Signal(signal_name, self, self.plot) + + def load(self): + self.load_csv() + self.update_source_and_signals() + + def reload_data(self): + # this function is a workaround to reload the data of all the signals + # if the data doesn't change, bokeh does not refreshes the line + temp_data = self.bokeh_source.data.copy() + for col in self.bokeh_source.data.keys(): + if not self.last_reload_data_fix: + temp_data[col] = temp_data[col][:-1] + self.last_reload_data_fix = not self.last_reload_data_fix + self.bokeh_source.data = temp_data + + def change_averaging_window(self, new_size, force=False, signals=None): + if force or self.signals_averaging_window != new_size: + self.signals_averaging_window = new_size + win = np.ones(new_size) / new_size + temp_data = self.bokeh_source_orig.data.copy() + for col in self.bokeh_source.data.keys(): + if col == 'index' or col in x_axis_options \ + or (signals and not any(col in signal for signal in signals)): + temp_data[col] = temp_data[col][:-new_size] + continue + temp_data[col] = np.convolve(self.bokeh_source_orig.data[col], win, mode='same')[:-new_size] + self.bokeh_source.data = temp_data + + # smooth bollinger bands + for signal in self.signals.values(): + if signal.has_bollinger_bands: + signal.set_bands_source() + + def hide_all_signals(self): + for signal_name in self.signals.keys(): + self.set_signal_selection(signal_name, False) + + def set_signal_selection(self, signal_name, val): + self.signals[signal_name].set_selected(val) + + def change_bollinger_bands_state(self, new_state): + self.show_bollinger_bands = new_state + for signal in self.signals.values(): + signal.change_bollinger_bands_state(new_state) + + def file_was_modified_on_disk(self): + pass + + def get_range_of_selected_signals_on_axis(self, axis, selected_signal=None): + max_val = -float('inf') + min_val = float('inf') + for signal in self.signals.values(): + if (selected_signal and signal.name == selected_signal) or (signal.selected and signal.axis == axis): + max_val = max(max_val, signal.max_val) + min_val = min(min_val, signal.min_val) + return min_val, max_val + + def get_selected_signals(self): + signals = [] + for signal in self.signals.values(): + if signal.selected: + signals.append(signal) + return signals + + def show_files_separately(self, val): + pass \ No newline at end of file diff --git a/dashboard_components/signals_files_group.py b/dashboard_components/signals_files_group.py new file mode 100644 index 0000000..579df75 --- /dev/null +++ b/dashboard_components/signals_files_group.py @@ -0,0 +1,149 @@ +import os +from os.path import basename + +import pandas as pd + +from dashboard_components.globals import x_axis_options, add_directory_csv_files, show_spinner +from dashboard_components.signals_file import SignalsFile +from dashboard_components.signals_file_base import SignalsFileBase + + +class SignalsFilesGroup(SignalsFileBase): + def __init__(self, csv_paths, plot=None): + super().__init__(plot) + self.full_csv_paths = csv_paths + self.signals_files = [] + if len(csv_paths) == 1 and os.path.isdir(csv_paths[0]): + self.signals_files = [SignalsFile(str(file), load=False, plot=plot) for file in add_directory_csv_files(csv_paths[0])] + else: + for csv_path in csv_paths: + if os.path.isdir(csv_path): + self.signals_files.append(SignalsFilesGroup(add_directory_csv_files(csv_path), plot=plot)) + else: + self.signals_files.append(SignalsFile(str(csv_path), load=False, plot=plot)) + parent_directory_path = os.path.abspath(os.path.join(os.path.dirname(csv_paths[0]), '..')) + + if len(csv_paths) == 1 and len(os.listdir(parent_directory_path)) == 1: + # get the parent directory name (since the current directory is the timestamp directory) + self.dir = os.path.abspath(os.path.join(os.path.dirname(csv_paths[0]), '..')) + else: + # get the common directory for all the experiments + self.dir = os.path.dirname(os.path.commonprefix(csv_paths)) + + self.filename = '{} - Group({})'.format(basename(self.dir), len(self.signals_files)) + + self.load() + + def load_csv(self): + corrupted_files_idx = [] + for idx, signal_file in enumerate(self.signals_files): + signal_file.load_csv() + if not all(option in signal_file.csv.keys() for option in x_axis_options): + print("Warning: {} file seems to be corrupted and does contain the necessary columns " + "and will not be rendered".format(signal_file.filename)) + corrupted_files_idx.append(idx) + + for file_idx in corrupted_files_idx: + del self.signals_files[file_idx] + + # get the stats of all the columns + if len(self.signals_files) > 1: + csv_group = pd.concat([signals_file.csv for signals_file in self.signals_files]) + columns_to_remove = [s for s in csv_group.columns if '/Stdev' in s] + \ + [s for s in csv_group.columns if '/Min' in s] + \ + [s for s in csv_group.columns if '/Max' in s] + for col in columns_to_remove: + del csv_group[col] + csv_group = csv_group.groupby(csv_group.index) + self.csv_mean = csv_group.mean() + self.csv_mean.columns = [s + '/Mean' for s in self.csv_mean.columns] + self.csv_stdev = csv_group.std() + self.csv_stdev.columns = [s + '/Stdev' for s in self.csv_stdev.columns] + self.csv_min = csv_group.min() + self.csv_min.columns = [s + '/Min' for s in self.csv_min.columns] + self.csv_max = csv_group.max() + self.csv_max.columns = [s + '/Max' for s in self.csv_max.columns] + + # get the indices from the file with the least number of indices and which is not an evaluation worker + file_with_min_indices = self.signals_files[0] + for signals_file in self.signals_files: + if signals_file.csv.shape[0] < file_with_min_indices.csv.shape[0] and \ + 'Training reward' in signals_file.csv.keys(): + file_with_min_indices = signals_file + self.index_columns = file_with_min_indices.csv[x_axis_options] + + # concat the stats and the indices columns + num_rows = file_with_min_indices.csv.shape[0] + self.csv = pd.concat([self.index_columns, self.csv_mean.head(num_rows), self.csv_stdev.head(num_rows), + self.csv_min.head(num_rows), self.csv_max.head(num_rows)], axis=1) + + # remove the stat columns for the indices columns + columns_to_remove = [s + '/Mean' for s in x_axis_options] + \ + [s + '/Stdev' for s in x_axis_options] + \ + [s + '/Min' for s in x_axis_options] + \ + [s + '/Max' for s in x_axis_options] + for col in columns_to_remove: + del self.csv[col] + else: # This is a group of a single file + self.csv = self.signals_files[0].csv + + # # convert wall clock time to minutes - isn't needed because the sub-signals are already scaled + # self.csv['Wall-Clock Time'] /= 60. + + # remove NaNs + self.csv.fillna(value=0, inplace=True) # removing this line will make bollinger bands fail + for key in self.csv.keys(): + if 'Stdev' in key and 'Evaluation' not in key: + self.csv[key] = self.csv[key].fillna(value=0) + + for signal_file in self.signals_files: + signal_file.update_source_and_signals() + + def reload_data(self): + for signal_file in self.signals_files: + signal_file.reload_data() + SignalsFileBase.reload_data(self) + + def update_x_axis_index(self): + for signal_file in self.signals_files: + signal_file.update_x_axis_index() + SignalsFileBase.update_x_axis_index(self) + + def toggle_y_axis(self, signal_name=None): + for signal in self.signals.values(): + if signal.selected: + signal.toggle_axis() + for signal_file in self.signals_files: + signal_file.toggle_y_axis(signal.name) + + def change_averaging_window(self, new_size, force=False, signals=None): + for signal_file in self.signals_files: + signal_file.change_averaging_window(new_size, force, signals) + SignalsFileBase.change_averaging_window(self, new_size, force, signals) + + def set_signal_selection(self, signal_name, val): + self.show_files_separately(self.separate_files) + SignalsFileBase.set_signal_selection(self, signal_name, val) + + def file_was_modified_on_disk(self): + for signal_file in self.signals_files: + if signal_file.file_was_modified_on_disk(): + return True + return False + + def show_files_separately(self, val): + self.separate_files = val + for signal in self.signals.values(): + if signal.selected: + if val: + signal.set_dash("4 4") + else: + signal.set_dash("") + for signal_file in self.signals_files: + try: + if val: + signal_file.set_signal_selection(signal.name, signal.selected) + else: + signal_file.set_signal_selection(signal.name, False) + except: + pass \ No newline at end of file