diff --git a/.flake8 b/.flake8 index 2231b51b..ef09bcbe 100644 --- a/.flake8 +++ b/.flake8 @@ -1,7 +1 @@ [flake8] -extend-exclude = - # OptView and related files need to be fixed eventually - pyoptsparse/postprocessing/OptView.py - pyoptsparse/postprocessing/OptView_baseclass.py - pyoptsparse/postprocessing/OptView_dash.py - pyoptsparse/postprocessing/view_saved_figure.py diff --git a/pyoptsparse/meson.build b/pyoptsparse/meson.build index 0467ed19..bb4bc7a6 100644 --- a/pyoptsparse/meson.build +++ b/pyoptsparse/meson.build @@ -72,9 +72,9 @@ subdir('pyCONMIN') subdir('pyNLPQLP') subdir('pyNSGA2') subdir('pyPSQP') +subdir('postprocessing') #subdir('pyALPSO') #subdir('pyParOpt') -#subdir('postprocessing') # test imports # envdata = environment() diff --git a/pyoptsparse/postprocessing/OptView.py b/pyoptsparse/postprocessing/OptView.py index b096e9d9..539ce369 100644 --- a/pyoptsparse/postprocessing/OptView.py +++ b/pyoptsparse/postprocessing/OptView.py @@ -1,1194 +1,121 @@ -""" -Provides interactive visualization of optimization results created by -pyOptSparse. Figures produced here can be saved as images or pickled -for future customization. - -John Jasa 2015-2019 - -""" - # Standard Python modules import argparse import os -import re -import tkinter as Tk -from tkinter import font as tkFont -import warnings +import sys +from typing import List # External modules -import matplotlib -from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk -import matplotlib.pyplot as plt -from mpl_toolkits.axes_grid1 import host_subplot -import mpl_toolkits.axisartist as AA -import numpy as np - -# Local modules -from .OptView_baseclass import OVBaseClass - -matplotlib.use("TkAgg") -try: - warnings.filterwarnings("ignore", category=matplotlib.cbook.mplDeprecation) - warnings.filterwarnings("ignore", category=UserWarning) -except: - pass - - -class Display(OVBaseClass): - - """ - Container for display parameters, properties, and objects. - This includes a canvas for MPL plots and a bottom area with widgets. - """ - - def __init__(self, histList, outputDir, figsize): - - # Initialize the Tkinter object, which will contain all graphical - # elements. - self.root = Tk.Tk() - self.root.wm_title("OptView") - - # Load the OptView icon - try: - icon_dir = os.path.dirname(os.path.abspath(__file__)) - icon_name = "OptViewIcon.gif" - icon_dir_full = os.path.join(icon_dir, "assets", icon_name) - img = Tk.PhotoImage(file=icon_dir_full) - self.root.tk.call("wm", "iconphoto", self.root._w, img) - except: # bare except because error is not in standard Python - pass - - figsize = (figsize, figsize) - - # Instantiate the MPL figure - self.f = plt.figure(figsize=figsize, dpi=100, facecolor="white") +from PyQt6.QtGui import QAction, QIcon, QKeySequence, QShortcut +from PyQt6.QtWidgets import QApplication, QMenuBar, QTabWidget, QVBoxLayout - # Link the MPL figure onto the TK canvas and pack it - self.canvas = FigureCanvasTkAgg(self.f, master=self.root) - self.canvas.get_tk_widget().pack(side=Tk.TOP, fill=Tk.BOTH, expand=1) +# First party modules +from pyoptsparse.postprocessing.baseclasses import View +from pyoptsparse.postprocessing.opt_view_controller import OptViewController - # Add a toolbar to explore the figure like normal MPL behavior - toolbar = NavigationToolbar2Tk(self.canvas, self.root) - toolbar.update() - self.canvas._tkcanvas.pack(side=Tk.TOP, fill=Tk.BOTH, expand=1) - # Increase the font size - matplotlib.rcParams.update({"font.size": 16}) - - # Initialize lists, dicts, and save inputs from user - self.arr_active = 0 - self.plots = [] - self.annotate = None - self.histList = histList - self.outputDir = outputDir - self.bounds = {} - self.scaling = {} - self.color_bounds = [0.0, 0.0] - - # Actually setup and run the GUI - self.OptimizationHistory() - - def quit(self): - """ - Destroy GUI window cleanly if quit button pressed. - """ - self.root.quit() - self.root.destroy() - - def error_display(self, string="That option not supported"): +class MainView(View): + def __init__(self, *args, file_names: List = [], **kwargs): """ - Display error string on canvas when invalid options selected. - """ - self.f.clf() - a = self.f.add_subplot(111) - a.text(0.05, 0.9, "Error: " + string, fontsize=20, transform=a.transAxes) - self.canvas.draw() + OptView - A GUI designed to assist viewing optimization problems + with pyOptSparse. The app uses a MVC architecure to modularly + handle data management, functional control, and visualization. - def warning_display(self, string="That option not supported"): - """ - Display warning message on canvas as necessary. + Parameters + ---------- + file_names : List, optional + List of file names to load on startup, by default [] """ - a = plt.gca() - a.text(0.05, 0.9, "Warning: " + string, fontsize=20, transform=a.transAxes) - self.canvas.draw() + super(MainView, self).__init__(*args, **kwargs) + # Set the controller for the main OptView window + self._controller = OptViewController() + self._controller.view = self + self.file_names = file_names - def note_display(self, string=""): - """ - Display warning message on canvas as necessary. - """ - a = plt.gca() - a.text(0.05, 0.5, string, fontsize=20, transform=a.transAxes) - self.canvas.draw() + self._center() # centers the application in the middle of the screen + self.setWindowTitle("OptView") # sets the GUI title + self.resize(1200, 800) + self._initUI() # Initialize the UI - def plot_bounds(self, val, a, color): + def _initUI(self): """ - Plot the bounds if selected. + Initializes the user inteface for the MainView widget. """ + # --- Set the main layout --- + self.layout = QVBoxLayout() - if val not in self.bounds: - for ii, char in enumerate(reversed(val)): - if char == "_": - split_loc = len(val) - ii - break - val_name = val[: split_loc - 1] - val_num = int(val[split_loc:]) - lower = [self.bounds[val_name]["lower"][val_num]] - upper = [self.bounds[val_name]["upper"][val_num]] - else: - lower = self.bounds[val]["lower"] - upper = self.bounds[val]["upper"] + # ============================================================== + # Menu Bar - First item added to top-level layout + # ============================================================== + # --- Add the menu bar object --- + menu_bar = QMenuBar(self) - lower = list(lower) - upper = list(upper) + # --- Add file sub-directory with sub-actions --- + file_menu = menu_bar.addMenu("File") - a.margins(None, 0.02) - a.set_prop_cycle("color", color) - for lower_bound in lower: - if lower_bound is not None and abs(lower_bound) < 1e18: - a.plot([0, self.num_iter - 1], [lower_bound, lower_bound], "--", linewidth=2, clip_on=False) + self.new_tab_menu = QAction("New Tab...", self) + self.new_tab_menu.triggered.connect(lambda: self._controller.addTab(interactive=True)) + file_menu.addAction(self.new_tab_menu) - a.set_prop_cycle("color", color) - for upper_bound in upper: - if upper_bound is not None and abs(upper_bound) < 1e18: - a.plot( - [0, self.num_iter - 1], - [upper_bound, upper_bound], - "--", - label=val + " bounds", - linewidth=2, - clip_on=False, - ) + self.settings_menu = QAction("Help/Settings...", self) + self.settings_menu.setStatusTip("View OptView settings") + self.settings_menu.triggered.connect(self._controller.configure_settings) + file_menu.addAction(self.settings_menu) - def orig_plot(self, dat, val, values, a, i=0): - """ - Plots the original data values from the history file. - """ - cc = plt.rcParams["axes.prop_cycle"].by_key()["color"] * 10 - color = cc[i] + self.exit_menu = QAction("Close", self) + self.exit_menu.triggered.connect(QApplication.quit) + file_menu.addAction(self.exit_menu) - try: - array_size = len(dat[val][0]) - if self.var_minmax.get(): - a.set_prop_cycle("color", color) - minmax_list = [] - for minmax in dat[val]: - minmax_list.append([np.min(minmax), np.max(minmax)]) - plots = a.plot(minmax_list, "o-", label=val, markeredgecolor="none", clip_on=False) + self.settings_menu_action = QShortcut(QKeySequence("Alt+h"), self) + self.settings_menu_action.activated.connect(self._controller.configure_settings) - elif array_size < 20 or self.var_showall.get(): - if i > 0: - a.set_prop_cycle("color", color) - plots = a.plot(dat[val], "o-", label=val, markeredgecolor="none", clip_on=False) + self.layout.addWidget(menu_bar) - a.set_ylabel(val) - self.color_error_flag = 1 - else: - self.error_display("Too many values to display") + # ============================================================== + # TabView - The central widget that contains the tabs + # ============================================================== + # --- Create the tab control framework --- + self.tabs = QTabWidget() + self.tabs.setTabsClosable(True) + self.tabs.tabCloseRequested.connect(self._controller.closeTab) - except TypeError: - a.set_prop_cycle("color", color) - if self.var.get() == 0: - pass - else: - a.set_ylabel(val) - plots = a.plot(dat[val], "o-", label=val, markeredgecolor="none", clip_on=False) + # --- Add the first tab to the view --- + self._controller.addTab(interactive=False, tab_name="Home", file_names=self.file_names) + self.layout.addWidget(self.tabs) - except (KeyError, IndexError): - self.warning_display("No 'major' iterations") - try: - if len(plots) > 1: - for i, plot in enumerate(plots): - self.plots.append([plot, i]) - else: - self.plots.append([plots[0], -1]) - except UnboundLocalError: - self.error_display("Too many values to display") - try: - if self.var_bounds.get(): - self.plot_bounds(val, a, color) + # --- Set the layout --- + self.setLayout(self.layout) - except (UnboundLocalError, ValueError): - if len(values) > 1: - pass - else: - self.error_display("No bounds information") + # --- Show the view --- + self.show() - def color_plot(self, dat, labels, a): - - # If the user wants the non-constraint colormap, use viridis - if self.var_color.get(): - cmap = plt.get_cmap("viridis") - - # Otherwise, use a custom red-green colormap that has white in the middle - # to showcase which constraints are active or not - else: - # fmt: off - cdict1 = {'red': ((0.0, 0.06, 0.06), - (0.3, .11, .11), - (0.5, 1.0, 1.0), - (0.8, 0.8, 0.8), - (1.0, 0.6, 0.6)), - - 'green': ((0.0, 0.3, 0.3), - (0.3, 0.6, 0.6), - (0.5, 1.0, 1.0), - (0.8, 0.0, 0.0), - (1.0, 0.0, 0.0)), - - 'blue': ((0.0, .15, .15), - (0.3, .25, .25), - (0.5, 1.0, 1.0), - (0.8, 0.0, 0.0), - (1.0, 0.0, 0.0)) - } - # fmt: on - # External modules - from matplotlib.colors import LinearSegmentedColormap - - cmap = LinearSegmentedColormap("RedGreen", cdict1) - - # Get the numbmer of iterations and set up some lists - iter_len = len(dat[labels[0]]) - full_array = np.zeros((0, iter_len)) - tick_labels = [] - - # Loop through the data sets selected by the user - for label in labels: - - # Get the subarray for this particular data set and record its size - subarray = np.array(dat[label]).T - sub_size = subarray.shape[0] - - # Add the subarray to the total array to view later - full_array = np.vstack((full_array, subarray)) - - # Set the labels for the ticks. - # If it's a scalar, simply have the data label. - if sub_size == 1: - tick_labels.append(label) - - # Otherwise, have the data label and append a number to it. - else: - tick_labels.append(label + " 0") - - # However, if there are a large number of data points, - # only label 12 of them to space out the labels. - n = max(sub_size // 12, 1) - for i in range(1, sub_size): - if not i % n: - tick_labels.append(str(i)) - else: - tick_labels.append("") - - if len(self.min_bound.get()) or len(self.max_bound.get()): - bounds = self.color_bounds - - # If the user wants the color set by the bounds, try to get the bounds - # information from the bounds dictionary. - elif self.var_color_bounds.get(): - bounds = [0.0, 0.0] - - # Loop through the labels and extract the smallest lower bound - # and the largest upper bound. - for label in labels: - try: - bounds[0] = min(bounds[0], self.bounds[label]["lower"][0]) - bounds[1] = max(bounds[1], self.bounds[label]["upper"][0]) - except: - pass - - # If we found no bounds data, simply use the smallest and largest - # values from the arrays. - if bounds[0] == 0.0 and bounds[1] == 0.0: - self.warning_display("No bounds information, using min/max array values instead") - largest_mag_val = np.max(np.abs(full_array)) - bounds = [-largest_mag_val, largest_mag_val] - - # Otherwise, simply use the smallest and largest values from the arrays - else: - largest_mag_val = np.max(np.abs(full_array)) - bounds = [-largest_mag_val, largest_mag_val] - - # Set up a colorbar and add it to the figure - cax = a.imshow(full_array, cmap=cmap, aspect="auto", vmin=bounds[0], vmax=bounds[1]) - fig = plt.gcf() - cbar = fig.colorbar(cax) - - # Some dirty hardcoding in an attempt to get the labels to appear nicely - # for different widths of OptView windows. - # This is challenging to do correctly because of non-uniform text widths. - size = fig.get_size_inches() * fig.dpi - width, height = size - - # More dirty harcoding to try to produce a nice layout. - max_label_length = np.max([len(label) for label in labels]) - plt.subplots_adjust(left=(0.006 * max_label_length + 0.02) * (6000 - width) / 4000) - - # Set the y-tick labels for the plot based on the previously saved info - plt.yticks(range(full_array.shape[0]), tick_labels) - - def plot_selected(self, values, dat): + def _center(self): """ - Plot data based on selected keys within listboxes. + Centers the view on the screen. """ - fail = 0 - self.color_error_flag = 0 - self.f.clf() - self.plots = [] - - # Grid the checkbox options that should exist - self.c12.grid_forget() - self.c13.grid_forget() - self.min_label.grid_forget() - self.min.grid_forget() - self.max_label.grid_forget() - self.max.grid_forget() - self.c4.grid(row=0, column=1, sticky=Tk.W) - self.c5.grid(row=1, column=1, sticky=Tk.W) - self.c6.grid(row=2, column=1, sticky=Tk.W) - self.c7.grid(row=3, column=1, sticky=Tk.W) - self.c8.grid(row=4, column=1, sticky=Tk.W) - self.c9.grid(row=5, column=1, sticky=Tk.W) - - plt.subplots_adjust(left=0.1) - - try: - if self.var_bounds.get(): - try: - self.bounds - except AttributeError: - self.error_display("No bounds information in history file") - fail = 1 - - # Plot on shared axes - if self.var.get() == 0 and not fail: - a = self.f.add_subplot(111) - - # Calculate and plot the delta values if selected - if self.var_del.get(): - for idx, val in enumerate(values): - newdat = [] - if self.var_scale.get(): - if val not in self.scaling: - for ii, char in enumerate(reversed(val)): - if char == "_": - split_loc = len(val) - ii - break - val_name = val[: split_loc - 1] - val_num = int(val[split_loc:]) - scale = [self.scaling[val_name][val_num]] - else: - scale = self.scaling[val] - else: - scale = 1.0 - for i, value in enumerate(dat[val], start=1): - newdat.append(abs(value - dat[val][i - 2]) * scale) - plots = a.plot( - range(1, self.num_iter), newdat[1:], "o-", label=val, markeredgecolor="none", clip_on=False - ) - if len(plots) > 1: - for i, plot in enumerate(plots): - self.plots.append([plot, i]) - else: - self.plots.append([plots[0], -1]) - - elif self.var_scale.get(): - for idx, val in enumerate(values): - newdat = [] - if val not in self.scaling: - for ii, char in enumerate(reversed(val)): - if char == "_": - split_loc = len(val) - ii - break - val_name = val[: split_loc - 1] - val_num = int(val[split_loc:]) - scale = [self.scaling[val_name][val_num]] - else: - scale = self.scaling[val] - for i, value in enumerate(dat[val]): - newdat.append(value * scale) - plots = a.plot( - range(0, self.num_iter), newdat, "o-", label=val, markeredgecolor="none", clip_on=False - ) - if len(plots) > 1: - for i, plot in enumerate(plots): - self.plots.append([plot, i]) - else: - self.plots.append([plots[0], -1]) - - # Otherwise plot original data - else: - for i, val in enumerate(values): - self.orig_plot(dat, val, values, a, i) - - if self.color_error_flag and self.var_bounds.get(): - self.warning_display("Line color for bounds may not match data color") - - # Plot using log scale if selected - if self.var_log.get(): - a.set_yscale("log") - - if self.var_legend.get(): - a.legend(loc="best") - - plt.subplots_adjust(right=0.95) - a.set_xlabel("iteration") - a.set_xlim(0, self.num_iter - 1) - self.canvas.draw() - - # Plot on individual vertical axes - elif self.var.get() == 1 and not fail: - - # Set window sizing parameters for when additional axes are - # added - n = len(values) - plt.figure(self.f.number) - par_list = [[] for i in range(n)] # make into array - par_list[0] = host_subplot(111, axes_class=AA.Axes) - size_list = [0.95, 0.95, 0.93, 0.83, 0.73, 0.63, 0.53, 0.43, 0.33] - plt.subplots_adjust(right=size_list[n]) - - for i in range(1, n): - par_list[i] = par_list[0].twinx() - - offset = 60 - for i in range(2, n): - new_fixed_axis = par_list[i].get_grid_helper().new_fixed_axis - par_list[i].axis["right"] = new_fixed_axis( - loc="right", axes=par_list[i], offset=(offset * i**1.15, 0) - ) - par_list[i].axis["right"].toggle(all=True) - - p_list = [[] for i in range(n)] - - # Compute and plot delta values if selected - if self.var_del.get(): - for i, val in enumerate(values): - newdat = [] - for idx, value in enumerate(dat[val], start=1): - newdat.append(abs(value - dat[val][idx - 2])) - (p_list[i],) = par_list[i].plot( - range(1, self.num_iter), newdat[1:], "o-", label=val, markeredgecolor="none", clip_on=False - ) - par_list[i].set_ylabel(val) - # Otherwise plot original data - else: - for i, val in enumerate(values): - cc = plt.rcParams["axes.prop_cycle"].by_key()["color"] * 10 - par_list[i].set_prop_cycle("color", cc[i]) - (p_list[i],) = par_list[i].plot( - dat[val], "o-", label=val, markeredgecolor="none", clip_on=False - ) - par_list[i].set_ylabel(val) - - try: - if self.var_bounds.get(): - self.plot_bounds(val, par_list[i], cc[i]) - except (ValueError, UnboundLocalError): - if len(values) > 1: - pass - else: - self.error_display("No bounds information") - - # Plot using log scale if selected - if self.var_log.get(): - for ax in par_list: - ax.set_yscale("log") - - par_list[0].set_xlim(0, self.num_iter - 1) - par_list[0].set_xlabel("iteration") - if self.var_legend.get(): - par_list[0].legend(loc="best") - for i, plot in enumerate(p_list): - self.plots.append([plot, i]) - - self.canvas.draw() - - # Plot on stacked axes with shared x-axis - elif self.var.get() == 2 and not fail: - n = len(values) - - # Compute and plot delta values if selected - if self.var_del.get(): - a = [] - for i, val in enumerate(values): - newdat = [] - for idx, value in enumerate(dat[val], start=1): - newdat.append(abs(value - dat[val][idx - 2])) - a.append(self.f.add_subplot(n, 1, i + 1)) - plots = a[i].plot( - range(1, self.num_iter), newdat[1:], "o-", label=val, markeredgecolor="none", clip_on=False - ) - a[i].set_ylabel("delta " + val) - self.plots.append([plots[0], -1]) - - # Otherwise plot original data - else: - a = [] - for i, val in enumerate(values): - a.append(self.f.add_subplot(n, 1, i + 1)) - self.orig_plot(dat, val, values, a[i]) - - # Plot using log scale if selected - if self.var_log.get(): - for ax in a: - ax.set_yscale("log") - - # Turn off horiztonal axes above the bottom plot - a[-1].set_xlabel("iteration") - for ax in a: - if ax != a[-1]: - ax.spines["bottom"].set_visible(False) - ax.set_xticklabels([]) - ax.xaxis.set_major_locator(plt.NullLocator()) - ax.spines["top"].set_visible(False) - for tic in ax.xaxis.get_major_ticks(): - tic.tick2On = False - ax.tick_params(axis="y", which="both", labelleft="off", labelright="on") - ax.set_xlim(0, self.num_iter - 1) - - plt.subplots_adjust(right=0.95) - self.canvas.draw() - - # Plot color plots of rectangular pixels showing values, - # especially useful for constraints - elif self.var.get() == 3 and not fail: - - # Remove options that aren't relevant - self.c4.grid_forget() - self.c5.grid_forget() - self.c6.grid_forget() - self.c7.grid_forget() - self.c8.grid_forget() - self.c9.grid_forget() - - # Add option to change colormap - self.c12.grid(row=0, column=1, sticky=Tk.W) - self.c13.grid(row=1, column=1, sticky=Tk.W) - - # Add bounds textboxes - self.min_label.grid(row=4, column=0, pady=10, sticky=Tk.W) - self.min.grid(row=4, column=1, pady=10, sticky=Tk.W) - self.max_label.grid(row=5, column=0, pady=10, sticky=Tk.W) - self.max.grid(row=5, column=1, pady=10, sticky=Tk.W) - - a = self.f.add_subplot(111) - - self.color_plot(dat, values, a) - - plt.subplots_adjust(right=0.95) - a.set_xlabel("iteration") - a.set_xlim(0, self.num_iter - 1) - self.canvas.draw() - - except ValueError: - self.error_display() - - def onselect(self, evt, data_name): - """ - Update current plot with selected data from listboxes. - Also checks if the data is array-type and provides an - additional listbox to select data within that array. - """ - w = evt.widget - values = [w.get(int(i)) for i in w.curselection()] - self.update_graph() - if len(values) == 1: - try: - data = data_name[values[0]] - data[0][0] - self.v.set(values[0]) - self.lb_arr.delete(0, Tk.END) - for i, val in enumerate(data[0]): - self.lb_arr.insert(Tk.END, values[0] + "_" + str(i)) - self.arr_title.pack(side=Tk.TOP) - self.scrollbar_arr.pack(side=Tk.RIGHT, fill=Tk.Y) - self.lb_arr.pack(side=Tk.RIGHT) - self.arr_active = 1 - except (IndexError, TypeError): - self.lb_arr.pack_forget() - self.scrollbar_arr.pack_forget() - self.arr_title.pack_forget() - self.arr_active = 0 - except KeyError: - self.warning_display("No 'major' iterations") - - def onselect_arr(self, evt): - """ - Obtain the selected plotting values from the array-based variable listbox. - """ - w = evt.widget - values = [int(i) for i in w.curselection()] - - # Get the currently selected functions/variables - func_sel = self.lb_func.curselection() - var_sel = self.lb_var.curselection() - if len(func_sel): - values_orig = [self.lb_func.get(i) for i in func_sel] - dat = self.func_data[values_orig[0]] - elif len(var_sel): - values_orig = [self.lb_var.get(i) for i in var_sel] - dat = self.var_data[values_orig[0]] - - # Add the array-based information to the listbox for selection - self.arr_data = {} - self.val_names = [] - for i, val in enumerate(values): - self.val_names.append(values_orig[0] + f"_{val}") - self.arr_data[self.val_names[i]] = [] - for ind_dat in dat: - self.arr_data[self.val_names[i]].append(ind_dat[val]) - self.plot_selected(self.val_names, self.arr_data) - - def update_graph(self): - """ - Produce an updated graph based on user options. - """ - - if self.var_minmax.get() and self.var_showall.get(): - self.error_display("Cannot show all and min/max at same time") - - else: - func_sel = self.lb_func.curselection() - var_sel = self.lb_var.curselection() - arr_sel = self.lb_arr.curselection() - values = [] - dat = {} - - if len(arr_sel) and self.arr_active: - self.plot_selected(self.val_names, self.arr_data) - - elif len(func_sel) or len(var_sel): - values.extend([self.lb_func.get(i) for i in func_sel]) - dat = self.func_data.copy() - values.extend([self.lb_var.get(i) for i in var_sel]) - dat.update(self.var_data) - self.plot_selected(values, dat) - - def set_mask(self): - - if self.var_mask.get(): - self.func_data = self.func_data_major - self.var_data = self.var_data_major - else: - self.func_data = self.func_data_all - self.var_data = self.var_data_all - - self.num_iter = 0 - - for key in self.func_data.keys(): - length = len(self.func_data[key]) - if length > self.num_iter: - self.num_iter = length - - self.update_graph() - - def save_figure(self): - """ - Save the current figure using the selected variables as the filename. - """ - func_sel = self.lb_func.curselection() - var_sel = self.lb_var.curselection() - arr_sel = self.lb_arr.curselection() - values = [] - if len(arr_sel) and self.arr_active: - values = self.val_names - elif len(func_sel): - values = [self.lb_func.get(i) for i in func_sel] - elif len(var_sel): - values = [self.lb_var.get(i) for i in var_sel] - groups = "" - for string in values: - groups += string + "_" - fname = groups + ".png" - fpathname = os.path.join(self.outputDir, fname) - plt.savefig(fpathname) - fname = "saved_figure.pickle" - fpathname = os.path.join(self.outputDir, fname) - try: - # External modules - import dill - - dill.dump(self.f, file(fpathname, "wb")) - except ImportError: - pass - - def save_all_figues(self): - """ - Batch save all individual figures from functions and variables. - """ - for data_name in [self.func_data, self.var_data]: - for key in data_name: - fig = plt.figure() - plt.plot(data_name[key], "ko-") - plt.title(key) - fname = key + ".png" - fpathname = os.path.join(self.outputDir, fname) - plt.savefig(fpathname) - plt.clf() - - def save_tec(self): - """ - Output selected data to tec file. - """ - func_sel = self.lb_func.curselection() - var_sel = self.lb_var.curselection() - arr_sel = self.lb_arr.curselection() - dat = {} - if len(arr_sel) and self.arr_active: - for name in self.val_names: - dat[name] = self.arr_data[name] - if len(func_sel): - values = [self.lb_func.get(i) for i in func_sel] - for name in values: - dat[name] = self.func_data[name] - if len(var_sel): - values = [self.lb_var.get(i) for i in var_sel] - for name in values: - dat[name] = self.var_data[name] - - keys = list(dat.keys()) - - num_vars = len(keys) - num_iters = len(dat[keys[0]]) - full_data = np.arange(num_iters, dtype=np.float_).reshape(num_iters, 1) - var_names = ["Iteration"] - for key in keys: - small_data = np.asarray(dat[key]) - - if len(small_data.shape) == 1: - full_data = np.c_[full_data, small_data] - var_names.append(key) - - else: - m = small_data.shape[0] - n = small_data.shape[1] - indiv_data = np.empty((m, 1)) - for i in range(n): - for j in range(m): - indiv_data[j] = small_data[j][i] - full_data = np.c_[full_data, indiv_data] - var_names.append(key + f"_{i}") - - filename = "OptView_tec.dat" - self._file = open(filename, "w") - self._file.write('Title = "OptView data output"" \n') - self._file.write("Variables = ") - for name in var_names: - self._file.write('"' + name + '" ') - self._file.write("\n") - - self._file.write('Zone T= "OptView_tec_data", ' + f"I={num_iters}, " + "F=POINT\n") - np.savetxt(self._file, full_data) - self._file.close() - - def var_search(self, _): - """ - Remove listbox entries that do not contain user-inputted string, - used to search through outputted data. - """ - self.lb_func.delete(0, Tk.END) - self.lb_var.delete(0, Tk.END) - for key in sorted(self.func_data): - self.lb_func.insert(Tk.END, key) - for key in sorted(self.var_data): - self.lb_var.insert(Tk.END, key) - - search_entry = self.entry_search.get() - func_range = range(len(self.func_data)) - for i in func_range[::-1]: - if not re.search(search_entry.lower(), self.lb_func.get(i).lower()): - self.lb_func.delete(i) - - var_range = range(len(self.var_data)) - for i in var_range[::-1]: - if not re.search(search_entry.lower(), self.lb_var.get(i).lower()): - self.lb_var.delete(i) - - if not self.lb_var.get(1) and not self.lb_func.get(1): - if self.lb_var.get(0): - self.lb_var.select_set(0) - if self.lb_func.get(0): - self.lb_func.select_set(0) - self.update_graph() - - def update_font(self, val): - """ - Set the font for matplotlib based on slider. - """ - matplotlib.rcParams.update({"font.size": int(val)}) - self.update_graph() - - def refresh_history(self): - """ - Refresh opt_his data if the history file has been updated. - """ - old_funcs = [] - for key in self.func_data: - old_funcs.append(key) - old_vars = [] - for key in self.var_data: - old_vars.append(key) - - self.OptimizationHistory() - - new_funcs = [] - for key in self.func_data: - new_funcs.append(key) - new_vars = [] - for key in self.var_data: - new_vars.append(key) - - if not (old_funcs == new_funcs and old_vars == new_vars): - self.var_search("dummy") - - def refresh_history_init(self): - self.refresh_history() - self.set_mask() - - def auto_ref(self): - """ - Automatically refreshes the history file, which is - useful if examining a running optimization. - """ - if self.var_ref.get(): - self.root.after(1000, self.auto_ref) - self.refresh_history() - self.set_mask() - - def clear_selections(self): - """ - Deselects all currently-selected variables, functions, and array options - """ - self.lb_func.selection_clear(0, Tk.END) - self.lb_var.selection_clear(0, Tk.END) - self.lb_arr.selection_clear(0, Tk.END) - - def on_move(self, event): - """ - Checks to see if the cursor is over a plot and provides a - hovering label if necessary. - """ - try: - self.annotation.remove() - except (AttributeError, ValueError): - pass - if event.xdata: - visibility_changed = False - point_selected = None - for point in self.plots: - if point[0].contains(event)[0]: - point_selected = point - - # Prevent error message if we move out of bounds while hovering - # over a point on a line - if point_selected: - visibility_changed = True - ax = point_selected[0].axes - label = point_selected[0].get_label() - if point_selected[1] >= 0: - label = label + "_" + str(point_selected[1]) - - xdat = point_selected[0].get_xdata() - ydat = point_selected[0].get_ydata() - - iter_count = np.round(event.xdata, 0) - ind = np.where(xdat == iter_count)[0][0] - - label = label + f"\niter: {int(iter_count):d}\nvalue: {ydat[ind]}" - - # Get the width of the window so we can scale the label placement - size = self.f.get_size_inches() * self.f.dpi - width, height = size - - xlim = ax.get_xlim() - ylim = ax.get_ylim() - - x_coord = (event.xdata - xlim[0]) / (xlim[1] - xlim[0]) - y_coord = (event.ydata - ylim[0]) / (ylim[1] - ylim[0]) - - # Scale and position the label based on the iteration number. - x_coord -= event.xdata / (xlim[1] - xlim[0]) * len(label) * 10 / width - - self.annotation = ax.annotate( - label, - xy=(x_coord, y_coord), - xycoords="axes fraction", - xytext=(x_coord, y_coord), - textcoords="axes fraction", - horizontalalignment="left", - bbox=dict(boxstyle="round", facecolor="w", edgecolor="0.5", alpha=0.8), - ) - else: - try: - self.annotation.remove() - except (AttributeError, ValueError): - pass - - self.canvas.draw() - - def set_bounds(self, bound): - try: - if self.min_bound == bound: - self.color_bounds[0] = float(bound.get()) - else: - self.color_bounds[1] = float(bound.get()) - except ValueError: - pass - self.update_graph() - - def draw_GUI(self): - """ - Create the frames and widgets in the bottom section of the canvas. - """ - font = tkFont.Font(family="Helvetica", size=10) - - sel_frame = Tk.Frame(self.root) - sel_frame.pack(side=Tk.LEFT) - - # Produce a frame and listbox to contain function information - func_frame = Tk.Frame(sel_frame) - func_frame.pack(side=Tk.LEFT, fill=Tk.Y, padx=20) - func_title = Tk.Label(func_frame, text="Functions", font=font) - func_title.pack(side=Tk.TOP) - self.lb_func = Tk.Listbox( - func_frame, name="lb_func", selectmode=Tk.EXTENDED, font=font, width=30, exportselection=0 - ) - self.lb_func.pack(side=Tk.LEFT) - for key in sorted(self.func_data): - self.lb_func.insert(Tk.END, key) - scrollbar_func = Tk.Scrollbar(func_frame) - scrollbar_func.pack(side=Tk.RIGHT, fill=Tk.Y) - self.lb_func.config(yscrollcommand=scrollbar_func.set) - scrollbar_func.config(command=self.lb_func.yview) - self.lb_func.bind("<>", lambda event: self.onselect(event, self.func_data)) - - # Produce a frame and listbox to contain variable information - var_frame = Tk.Frame(sel_frame) - var_frame.pack(side=Tk.RIGHT, fill=Tk.Y, padx=20) - var_title = Tk.Label(var_frame, text="Design Variables", font=font) - var_title.pack(side=Tk.TOP) - scrollbar_var = Tk.Scrollbar(var_frame) - scrollbar_var.pack(side=Tk.RIGHT, fill=Tk.Y) - self.lb_var = Tk.Listbox( - var_frame, name="lb_var", selectmode=Tk.EXTENDED, font=font, width=30, exportselection=0 - ) - self.lb_var.pack(side=Tk.RIGHT) - for key in sorted(self.var_data): - self.lb_var.insert(Tk.END, key) - self.lb_var.config(yscrollcommand=scrollbar_var.set) - scrollbar_var.config(command=self.lb_var.yview) - self.lb_var.bind("<>", lambda event: self.onselect(event, self.var_data)) - - # Produce a frame and listbox to contain array-based variable - # information - arr_frame = Tk.Frame(self.root) - arr_frame.pack(side=Tk.RIGHT, fill=Tk.Y, padx=20) - self.v = Tk.StringVar() - self.arr_title = Tk.Label(arr_frame, text="Array variables", font=font, textvariable=self.v) - self.scrollbar_arr = Tk.Scrollbar(arr_frame) - self.lb_arr = Tk.Listbox( - arr_frame, name="lb_arr", selectmode=Tk.EXTENDED, font=font, width=30, exportselection=0 - ) - self.lb_arr.config(yscrollcommand=self.scrollbar_arr.set) - self.scrollbar_arr.config(command=self.lb_arr.yview) - self.lb_arr.bind("<>", self.onselect_arr) - - # Create options frame with buttons and checkboxes for user - options_frame = Tk.Frame(self.root) - options_frame.pack(side=Tk.LEFT, fill=Tk.Y, pady=10) - - button0 = Tk.Button(options_frame, text="Clear selections", command=self.clear_selections, font=font) - button0.grid(row=0, column=2, padx=5, sticky=Tk.W) - - button1 = Tk.Button(options_frame, text="Refresh history", command=self.refresh_history_init, font=font) - button1.grid(row=1, column=2, padx=5, sticky=Tk.W) - - button2 = Tk.Button(options_frame, text="Save all figures", command=self.save_all_figues, font=font) - button2.grid(row=2, column=2, padx=5, sticky=Tk.W) - - button3 = Tk.Button(options_frame, text="Save figure", command=self.save_figure, font=font) - button3.grid(row=3, column=2, padx=5, sticky=Tk.W) - - button4 = Tk.Button(options_frame, text="Save tec file", command=self.save_tec, font=font) - button4.grid(row=4, column=2, padx=5, sticky=Tk.W) - - button5 = Tk.Button(options_frame, text="Quit", command=self.quit, font=font) - button5.grid(row=5, column=2, padx=5, sticky=Tk.W) - - self.min_label = Tk.Label(options_frame, text="Min bound for colorbar:", font=font) - - # Input box to select the min bounds for the colorbar when using color plots - self.min_bound = Tk.StringVar() - self.min_bound.trace("w", lambda name, index, mode, min_bound=self.min_bound: self.set_bounds(self.min_bound)) - self.min = Tk.Entry(options_frame, text="Search", textvariable=self.min_bound, font=font) - - self.max_label = Tk.Label(options_frame, text="Max bound for colorbar:", font=font) - - # Input box to select the max bounds for the colorbar when using color plots - self.max_bound = Tk.StringVar() - self.max_bound.trace("w", lambda name, index, mode, max_bound=self.max_bound: self.set_bounds(self.max_bound)) - self.max = Tk.Entry(options_frame, text="Search", textvariable=self.max_bound, font=font) - - # Plot options - self.var = Tk.IntVar() - c1 = Tk.Radiobutton( - options_frame, text="Shared axes", variable=self.var, command=self.update_graph, font=font, value=0 - ) - c1.grid(row=0, column=0, sticky=Tk.W) - - c2 = Tk.Radiobutton( - options_frame, text="Multiple axes", variable=self.var, command=self.update_graph, font=font, value=1 - ) - c2.grid(row=1, column=0, sticky=Tk.W) - - c3 = Tk.Radiobutton( - options_frame, text="Stacked plots", variable=self.var, command=self.update_graph, font=font, value=2 - ) - c3.grid(row=2, column=0, sticky=Tk.W) - - c12 = Tk.Radiobutton( - options_frame, text="Color plots", variable=self.var, command=self.update_graph, font=font, value=3 - ) - c12.grid(row=3, column=0, sticky=Tk.W) - - self.var_del = Tk.IntVar() - self.c4 = Tk.Checkbutton( - options_frame, text="Absolute delta values", variable=self.var_del, command=self.update_graph, font=font - ) - self.c4.grid(row=0, column=1, sticky=Tk.W) - - self.var_log = Tk.IntVar() - self.c5 = Tk.Checkbutton( - options_frame, text="Log scale", variable=self.var_log, command=self.update_graph, font=font - ) - self.c5.grid(row=1, column=1, sticky=Tk.W) - - # Option to only show the min and max of array-type variables - self.var_minmax = Tk.IntVar() - self.c6 = Tk.Checkbutton( - options_frame, text="Min/max for arrays", variable=self.var_minmax, command=self.update_graph, font=font - ) - self.c6.grid(row=2, column=1, sticky=Tk.W) - - # Option to show all values for array-type variables - self.var_showall = Tk.IntVar() - self.c7 = Tk.Checkbutton( - options_frame, text="Show all for arrays", variable=self.var_showall, command=self.update_graph, font=font - ) - self.c7.grid(row=3, column=1, sticky=Tk.W) - - # Option to show legend - self.var_legend = Tk.IntVar() - self.c8 = Tk.Checkbutton( - options_frame, text="Show legend", variable=self.var_legend, command=self.update_graph, font=font - ) - self.var_legend.set(1) - self.c8.grid(row=4, column=1, sticky=Tk.W) - - # Option to show bounds - self.var_bounds = Tk.IntVar() - self.c9 = Tk.Checkbutton( - options_frame, text="Show bounds", variable=self.var_bounds, command=self.update_graph, font=font - ) - self.c9.grid(row=5, column=1, sticky=Tk.W) - - # Option to only show major iterations - self.var_mask = Tk.IntVar() - self.c10 = Tk.Checkbutton( - options_frame, text="Show major iterations", variable=self.var_mask, command=self.set_mask, font=font - ) - self.c10.grid(row=6, column=1, sticky=Tk.W, pady=6) - - # Option to automatically refresh history file - # especially useful for running optimizations - self.var_ref = Tk.IntVar() - self.c11 = Tk.Checkbutton( - options_frame, text="Automatically refresh", variable=self.var_ref, command=self.auto_ref, font=font - ) - self.c11.grid(row=7, column=1, sticky=Tk.W, pady=6) - - # Option to choose colormap for color plots - self.var_color = Tk.IntVar() - self.c12 = Tk.Checkbutton( - options_frame, text="Viridis colormap", variable=self.var_color, command=self.update_graph, font=font - ) - - # Option to choose limits of colorbar axes - self.var_color_bounds = Tk.IntVar() - self.c13 = Tk.Checkbutton( - options_frame, - text="Colorbar set by bounds", - variable=self.var_color_bounds, - command=self.update_graph, - font=font, - ) - - # Option to scale variables or constraints to how - # the optimizer sees them - self.var_scale = Tk.IntVar() - self.c14 = Tk.Checkbutton( - options_frame, text="Apply scaling factor", variable=self.var_scale, command=self.update_graph, font=font - ) - self.c14.grid(row=8, column=1, sticky=Tk.W, pady=6) - - lab = Tk.Label(options_frame, text="Search for a function/variable:", font=font) - lab.grid(row=9, column=0, columnspan=2, pady=10, sticky=Tk.W) - - # Search box to filter displayed functions/variables - vs = Tk.StringVar() - vs.trace("w", lambda name, index, mode, vs=vs: self.var_search(vs)) - self.entry_search = Tk.Entry(options_frame, text="Search", textvariable=vs, font=font) - self.entry_search.grid(row=9, column=2, pady=10, sticky=Tk.W) - - lab_font = Tk.Label(options_frame, text="Font size for plots:", font=font) - lab_font.grid(row=10, column=0, sticky=Tk.S) - - w = Tk.Scale( - options_frame, from_=6, to=24, orient=Tk.HORIZONTAL, resolution=2, command=self.update_font, font=font - ) - w.set(16) - w.grid(row=10, column=1) + qr = self.frameGeometry() + self.move(qr.center()) def main(): - # Called only if this script is run as main. - - # ====================================================================== - # Input Information - # ====================================================================== + # Create command line arguments here parser = argparse.ArgumentParser() - parser.add_argument( - "histFile", nargs="*", type=str, default="opt_hist.hst", help="Specify the history file to be plotted" - ) - parser.add_argument("--output", nargs="?", type=str, default=None, help="Specify the output directory") - parser.add_argument( - "--figsize", nargs="?", type=float, default="4", help="Specify the desired minimum figure canvas size" - ) - + parser.add_argument("files", nargs="*", type=str, default=[], help="File(s) to load into OptView on startup.") args = parser.parse_args() - histList = args.histFile - if args.output is None: - outputDir = os.getcwd() - else: - outputDir = args.output - figsize = args.figsize - histFileName = histList[0] + # Check to make sure the files exist and are history files + if args.files: + for file in args.files: + if not os.path.exists(file): + raise FileNotFoundError(f"History file: {file} doesn't exist.") + elif not file.endswith(".hst"): + raise NameError(f"File: {file} is not a readable history file.") - # Check that the output directory is available. Create it if not - if not os.path.isdir(outputDir): - os.makedirs(outputDir) - # Initialize display parameters, obtain history, and draw GUI - disp = Display(histList, outputDir, figsize) - disp.draw_GUI() - disp.root.protocol("WM_DELETE_WINDOW", disp.quit) - on_move_id = disp.f.canvas.mpl_connect("motion_notify_event", disp.on_move) - disp.note_display( - "Select functions or design variables from the listboxes \nbelow to view the optimization history." - ) - Tk.mainloop() + # Setup the app and the UI + app = QApplication(sys.argv) + app.setStyle("Fusion") + app.setWindowIcon(QIcon(os.path.join(os.path.dirname(sys.modules[__name__].__file__), "assets", "OptViewIcon.png"))) + MainView(file_names=args.files) -if __name__ == "__main__": - main() + # This launches the app + app.exec() diff --git a/pyoptsparse/postprocessing/OptView_baseclass.py b/pyoptsparse/postprocessing/OptView_baseclass.py deleted file mode 100644 index de5ad728..00000000 --- a/pyoptsparse/postprocessing/OptView_baseclass.py +++ /dev/null @@ -1,343 +0,0 @@ -""" -Shared base class for both OptView and OptView_dash. -This reduces code duplication by having both OptViews read from this baseclass. - -John Jasa 2015-2019 - -""" - -# Standard Python modules -import shelve - -# External modules -import numpy as np -from sqlitedict import SqliteDict - -# Local modules -from ..pyOpt_error import pyOptSparseWarning - - -class OVBaseClass: - - """ - Container for display parameters, properties, and objects. - This includes a canvas for MPL plots and a bottom area with widgets. - """ - - def OptimizationHistory(self): - """ - Reads in database history file and stores contents. - Function information is stored as a dict in func_data, - variable information is stored as a dict in var_data, - and bounds information is stored as a dict in bounds. - """ - - # Initialize dictionaries for design variables and unknowns. - # The data is saved redundantly in dicts for all iterations and then - # for major iterations as well. - self.func_data_all = {} - self.func_data_major = {} - self.var_data_all = {} - self.var_data_major = {} - db = {} - self.num_iter = 0 - - # Loop over each history file name provided by the user. - for histIndex, histFileName in enumerate(self.histList): - - # If they only have one history file, we don't change the keys' names - if len(self.histList) == 1: - histIndex = "" - else: # If multiple history files, append letters to the keys, - # such that 'key' becomes 'key_A', 'key_B', etc - histIndex = "_" + chr(histIndex + ord("A")) - self.histIndex = histIndex - - try: # This is the classic method of storing history files - db = shelve.open(histFileName, "r") - OpenMDAO = False - except Exception: # Bare except because error is not in standard Python. - # If the db has the 'iterations' tag, it's an OpenMDAO db. - db = SqliteDict(histFileName, "iterations") - OpenMDAO = True - - # Need to do this since in py3 db.keys() is a generator object - keys = [i for i in db.keys()] - - # If it has no 'iterations' tag, it's a pyOptSparse db. - if keys == []: - OpenMDAO = False - db = SqliteDict(histFileName) - - # Specific instructions for OpenMDAO databases - if OpenMDAO: - - # Get the number of iterations by looking at the largest number - # in the split string names for each entry in the db - for string in db.keys(): - string = string.split("|") - - nkey = int(string[-1]) - self.solver_name = string[0] - - # Initalize a list detailing if the iterations are major or minor - self.iter_type = np.zeros(nkey) - - # Get the keys of the database where derivatives were evaluated. - # These correspond to major iterations, while no derivative - # info is calculated for gradient-free linesearches. - deriv_keys = SqliteDict(histFileName, "derivs").keys() - self.deriv_keys = [int(key.split("|")[-1]) for key in deriv_keys] - - # Save information from the history file for the funcs. - self.DetermineMajorIterations(db, OpenMDAO=OpenMDAO) - - # Save information from the history file for the unknowns. - self.SaveDBData(db, self.func_data_all, self.func_data_major, OpenMDAO=OpenMDAO, data_str="Unknowns") - - # Save information from the history file for the design variables. - self.SaveDBData(db, self.var_data_all, self.var_data_major, OpenMDAO=OpenMDAO, data_str="Parameters") - - # Add labels to OpenMDAO variables. - # Corresponds to constraints, design variables, and objective. - try: - db = SqliteDict(histFileName, "metadata") - self.SaveOpenMDAOData(db) - - except KeyError: # Skip metadata info if not included in OpenMDAO hist file - pass - - else: - - # Get the number of iterations - nkey = int(db["last"]) + 1 - self.nkey = nkey - - # Initalize a list detailing if the iterations are major or minor - # 1 = major, 2 = minor, 0 = sensitivity (or duplicated info by IPOPT) - # The entries whose iter_type = 0 will be ignored. - self.iter_type = np.zeros(nkey) - - # Check to see if there is bounds information in the db file. - # If so, add them to self.bounds to plot later. - try: - try: - info_dict = db["varInfo"].copy() - info_dict.update(db["conInfo"]) - scale_info = True - except KeyError: - self.warning_display( - "This is an older optimization history file.\n" - + "Only bounds information has been stored, not scalar info." - ) - info_dict = db["varBounds"].copy() - info_dict.update(db["conBounds"]) - scale_info = False - - # Got to be a little tricky here since we're modifying - # info_dict; if we simply loop over it with the generator - # from Python3, it will contain the new keys and then the - # names will be mangled incorrectly. - bounds_dict = {} - scaling_dict = {} - for key in info_dict.keys(): - bounds_dict[key + histIndex] = { - "lower": info_dict[key]["lower"], - "upper": info_dict[key]["upper"], - } - if scale_info: - scaling_dict[key + histIndex] = info_dict[key]["scale"] - - self.bounds.update(bounds_dict) - if scale_info: - self.scaling.update(scaling_dict) - except KeyError: - pass - - # Check to see if there is proper saved info about iter type - if "isMajor" in db["0"].keys(): - self.storedIters = True - else: - self.storedIters = False - - # Raise warning for IPOPT's duplicated history - if "metadata" in db and db["metadata"]["optimizer"] == "IPOPT" and "iter" not in db["0"].keys(): - pyOptSparseWarning( - "The optimization history file has duplicated entries at every iteration, and the OptView plot is not correct. " - + "Re-run the optimization with a current version of pyOptSparse to generate a correct history file." - ) - - # Save information from the history file for the funcs. - self.DetermineMajorIterations(db, OpenMDAO=OpenMDAO) - - # Save information from the history file for the funcs. - self.SaveDBData(db, self.func_data_all, self.func_data_major, OpenMDAO=OpenMDAO, data_str="funcs") - - # Save information from the history file for the design variables. - self.SaveDBData(db, self.var_data_all, self.var_data_major, OpenMDAO=OpenMDAO, data_str="xuser") - - # Set the initial dictionaries to reference all iterations. - # Later this can be set to reference only the major iterations. - self.func_data = self.func_data_all - self.var_data = self.var_data_all - - # Find the maximum length of any variable in the dictionaries and - # save this as the number of iterations. - for data_dict in [self.func_data, self.var_data]: - for key in data_dict.keys(): - length = len(data_dict[key]) - if length > self.num_iter: - self.num_iter = length - - def DetermineMajorIterations(self, db, OpenMDAO): - - if not OpenMDAO: - - previousIterCounter = -1 - - # Loop over each optimization call - for i, iter_type in enumerate(self.iter_type): - - # If this is an OpenMDAO file, the keys are of the format - # 'rank0:SNOPT|1', etc - key = "%d" % i - - # Only actual optimization iterations have 'funcs' in them. - # pyOptSparse saves info for two iterations for every - # actual major iteration. In particular, one has funcs - # and the next has funcsSens, but they're both part of the - # same major iteration. - # For IPOPT, it saves info for four calls for every - # actual major iteration: objective, constraints, - # and sensitivities of each. - - if "funcs" in db[key].keys(): - # check if this entry is duplicated info. Only relevant for IPOPT. - # Note: old hist files don't have "iter" - if "iter" in db[key].keys() and db[key]["iter"] == previousIterCounter: - # duplicated info - self.iter_type[i] = 0 - - # if we did not store major iteration info, everything's major - elif not self.storedIters: - self.iter_type[i] = 1 - # this is major iteration - elif self.storedIters and db[key]["isMajor"]: - self.iter_type[i] = 1 - else: - self.iter_type[i] = 2 - - if "iter" in db[key].keys(): - previousIterCounter = db[key]["iter"] - else: - self.iter_type[i] = 0 # this is not a real iteration, - # just the sensitivity evaluation - - else: # this is if it's OpenMDAO - for i, iter_type in enumerate(self.iter_type): - key = f"{self.solver_name}|{i + 1}" # OpenMDAO uses 1-indexing - if i in self.deriv_keys: - self.iter_type[i] = 1.0 - - # If no derivative info is saved, we don't know which iterations are major. - # Treat all iterations as major. - if len(self.deriv_keys) < 1: - self.iter_type[:] = 1.0 - - def SaveDBData(self, db, data_all, data_major, OpenMDAO, data_str): - """Method to save the information within the database corresponding - to a certain key to the relevant dictionaries within the Display - object. This method is called twice, once for the design variables - and the other for the outputs.""" - - # Loop over each optimization iteration - for i, iter_type in enumerate(self.iter_type): - - # If this is an OpenMDAO file, the keys are of the format - # 'rank0:SNOPT|1', etc - if OpenMDAO: - key = f"{self.solver_name}|{i + 1}" # OpenMDAO uses 1-indexing - else: # Otherwise the keys are simply a number - key = "%d" % i - - # Do this for both major and minor iterations - if self.iter_type[i]: - - # Get just the info in the dict for this iteration - iter_data = db[key][data_str] - - # Loop through each key within this iteration - for key in sorted(iter_data): - - # Format a new_key string where we append a modifier - # if we have multiple history files - new_key = key + f"{self.histIndex}" - - # If this key is not in the data dictionaries, add it - if new_key not in data_all: - data_all[new_key] = [] - data_major[new_key] = [] - - # Process the data from the key. Convert it to a numpy - # array, keep only the real part, squeeze any 1-dim - # axes out of it, then flatten it. - data = np.squeeze(np.array(iter_data[key]).real).flatten() - - # Append the data to the entries within the dictionaries. - data_all[new_key].append(data) - if self.iter_type[i] == 1: - data_major[new_key].append(data) - - def SaveOpenMDAOData(self, db): - """Examine the OpenMDAO dict and save tags if the variables are - objectives (o), constraints (c), or design variables (dv).""" - - # Loop over each key in the metadata db - for tag in db: - - # Only look at variables and unknowns - if tag in ["Unknowns", "Parameters"]: - for old_item in db[tag]: - - # We'll rename each item, so we need to get the old item - # name and modify it - item = old_item + f"{self.histIndex}" - - # Here we just have an open parenthesis, and then we will - # add o, c, or dv. Note that we could add multiple flags - # to a single item. That's why we have a sort of convoluted - # process of adding the tags. - new_key = item + " (" - flag_list = [] - - # Check each flag and see if they have the relevant entries - # within the dict; if so, tag them. - for flag in db[tag][old_item]: - if "is_objective" in flag: - flag_list.append("o") - if "is_desvar" in flag: - flag_list.append("dv") - if "is_constraint" in flag: - flag_list.append("c") - - # Create the new_key based on the flags for each variable - for flag in flag_list: - if flag == flag_list[-1]: - new_key += flag + ")" - else: - new_key += flag + ", " - - # If there are actually flags to add, pop out the old items - # in the dict and re-add them with the new name. - if flag_list: - try: - if "dv" in flag_list: - self.var_data_all[new_key] = self.func_data_all.pop(item) - self.var_data_major[new_key] = self.func_data_major.pop(item) - - else: - self.func_data_all[new_key] = self.func_data_all.pop(item) - self.func_data_major[new_key] = self.func_data_major.pop(item) - - except KeyError: - pass diff --git a/pyoptsparse/postprocessing/OptView_dash.py b/pyoptsparse/postprocessing/OptView_dash.py deleted file mode 100644 index 592c3a1e..00000000 --- a/pyoptsparse/postprocessing/OptView_dash.py +++ /dev/null @@ -1,1325 +0,0 @@ -""" -This dash version of OptView makes use of the new API -to read in the history file, rather than using the -OptView baseclass. This should be more maintainable -for adding new features or displaying new information with OptView. -""" - -# Standard Python modules -import argparse -import json - -# External modules -import dash -import dash_core_components as dcc -import dash_html_components as html -import numpy as np -from plotly import graph_objs as go -from plotly import subplots - -# First party modules -from pyoptsparse import History - -# Read in the history files given by user -parser = argparse.ArgumentParser() -parser.add_argument( - "histFile", nargs="*", type=str, default="opt_hist.hst", help="Specify the history file to be plotted" -) -args = parser.parse_args() - -# List of strings (the history file names) -histListArgs = args.histFile - -# Create list of associated labels (A, B, C, etc) for each history file if there are more than one -index = ord("A") -fileLabels = ["" for file in histListArgs] -if len(fileLabels) > 1: - for i in range(len(fileLabels)): - fileLabels[i] = chr(index) - index = (index + 1 - 65) % 26 + 65 - -# Color scheme for graphing traces -colors = ["#636EFA", "#EF553B", "#00CC96", "#AB63FA", "#FFA15A", "#19D3F3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52"] - - -# ==================================================================================== -# Defining dash app & layout -# ==================================================================================== - -app = dash.Dash(__name__) -# Override exceptions for when elements are defined without initial input -app.config.suppress_callback_exceptions = True - -app.layout = html.Div( - children=[ - html.Nav( - children=[ - html.Img( - src=app.get_asset_url("/logo.png"), style={"height": "4rem", "width": "13rem", "margin": "0.2rem"} - ), - html.Div(["OptView"], style={"fontWeight": "550", "fontSize": "3.0rem", "color": "#193D72"}), - ], - style={"display": "flex", "backgroundColor": "#EEEEEE"}, - ), - html.Div( - [ - html.Div( - [ - html.Div( - [ - html.Div( - [ - html.Div( - ["History Files"], - style={"color": "#193D72", "fontWeight": "normal", "fontSize": "15px"}, - ), - html.Ul( - [ - html.Li( - [fileLabels[i] + " " + histListArgs[i]], style={"fontSize": "12px"} - ) - for i in range(len(histListArgs)) - ] - ), - ], - ), - html.Div( - [ - html.Div( - ["Design Groups"], - className="dvarGroup", - style={"color": "#193D72", "fontWeight": "normal", "fontSize": "1.5rem"}, - ), - dcc.Dropdown( - id="dvarGroup", - placeholder="Select design group(s)...", - multi=True, - style={"color": "#676464", "fontSize": "1.2rem"}, - ), - ], - ), - html.Div( - [ - html.Div( - ["Design Variables"], - style={"color": "#193D72", "fontWeight": "normal", "fontSize": "1.5rem"}, - ), - dcc.Dropdown( - id="dvarChild", - placeholder="Select design var(s)...", - multi=True, - style={"color": "#676464", "fontSize": "1.2rem"}, - ), - ], - ), - ], - style={"marginRight": "1.0rem", "width": "20.0rem"}, - ), - html.Div( - [ - html.Div( - [ - html.Div( - ["Function Groups"], - style={"color": "#193D72", "fontWeight": "normal", "fontSize": "1.5rem"}, - ), - dcc.Dropdown( - id="funcGroup", - placeholder="Select function group(s)...", - multi=True, - style={"color": "#676464", "fontSize": "1.2rem"}, - ), - ], - ), - html.Div( - [ - html.Div( - ["Function Variables"], - style={"color": "#193D72", "fontWeight": "normal", "fontSize": "1.5rem"}, - ), - dcc.Dropdown( - id="funcChild", - placeholder="Select function var(s)...", - multi=True, - style={"color": "#676464", "fontSize": "1.2rem"}, - ), - ], - ), - ], - style={"marginRight": "3.0rem", "width": "20.0rem"}, - ), - html.Div( - [ - html.Div( - [ - html.Div( - ["Optimization Groups"], - style={"color": "#193D72", "fontWeight": "normal", "fontSize": "1.5rem"}, - ), - dcc.Dropdown( - id="optGroup", - placeholder="Select optimization group(s)...", - multi=True, - style={"color": "#676464", "fontSize": "1.2rem"}, - ), - ], - ), - html.Div( - [ - html.Div( - ["Optimization Variables"], - style={"color": "#193D72", "fontWeight": "normal", "fontSize": "1.5rem"}, - ), - dcc.Dropdown( - id="optChild", - placeholder="Select optimization var(s)...", - multi=True, - style={"color": "#676464", "fontSize": "1.2rem"}, - ), - ], - ), - ], - style={"marginRight": "3.0rem", "width": "20.0rem"}, - ), - html.Div( - [ - html.Div( - ["Graph"], style={"color": "#193D72", "fontWeight": "normal", "fontSize": "1.5rem"} - ), - dcc.RadioItems( - id="plot-type", - options=[ - {"label": "Stacked", "value": "stacked"}, - {"label": "Shared", "value": "shared"}, - ], - value="shared", - style={"color": "#676464", "fontSize": "1.2rem"}, - ), - ], - style={"width": "30x", "marginRight": "2%"}, - ), - html.Div( - [ - html.Div( - ["Additional"], - style={"color": "#193D72", "fontWeight": "normal", "fontSize": "1.5rem"}, - ), - dcc.Checklist( - id="data-type", - options=[ - {"label": "Show Bounds", "value": "bounds"}, - {"label": "Show Major Iterations", "value": "major"}, - {"label": "Log Scale", "value": "log"}, - {"label": "Apply Scaling Factor", "value": "scale"}, - {"label": "Show Min/Max of Group(s)", "value": "minMax"}, - {"label": "Show Absolute Delta", "value": "delta"}, - {"label": "Auto-Refresh (Toggle on to enter Rate)", "value": "refresh"}, - ], - style={"color": "#676464", "fontSize": "1.2rem"}, - value=["major"], - ), - dcc.Input( - id="refresh-rate", - type="number", - placeholder="Refresh Rate (s)", - style={"fontSize": "1.2rem", "marginLeft": "2rem"}, - ), - # dcc.Input( - # id='figure-export-width', - # type='number', - # placeholder='Enter PX Width', - # style={"fontSize":"1.2rem"} - # ), - # dcc.Input( - # id='figure-export-height', - # type='number', - # placeholder='Enter PX Height', - # style={"fontSize":"1.2rem"} - # ) - ], - ), - ], - style={"display": "flex", "flexDirection": "column", "padding": "0.5rem"}, - ), - html.Div( - [ - dcc.Graph( - id="plot", - config={ - "scrollZoom": True, - "showTips": True, - "toImageButtonOptions": { - "format": "png", - "filename": "custom_image", - "height": 500, - "width": 700, - "scale": 10, - }, - }, - ) - ], - style={"width": "100%"}, - ), - dcc.Interval( - id="interval-component", - interval=1 * 1000, - n_intervals=0, # in milliseconds - # max_intervals=5 - ), - html.Div(id="hidden-div", style={"display": "none"}), - ], - style={"display": "flex"}, - ), - ], -) - -# ==================================================================================== -# Helper functions for Dash Callback Functions found further down -# ==================================================================================== - - -def getValues(name, dataType, hist): - """ - Uses user input and returns a data dictionary with values for the requested value of interest, by calling the getValues() method - from pyOpt_history.py, adjusting the requested values using the dataType specifications from the user. - - Parameters - ---------- - name : str - The value of interest, can be the name of any DV, objective or constraint that a user selects - - dataType : list of str - Contains dataType str values selected by user (i.e scale, major, delta) - - hist: History object - This is created with the history file for the given value of interest using pyOpt-history.py - - Returns - ------- - data dictionary - Contains the iteration data for the value requested. It returns a data dictionary with the key - as the 'name' requested and the value a numpy array where with the first dimension equal to the - number of iterations requested (depending on if major is in dataType or not). - - Example - _______ - getValues(name="xvars", dataType=[['major']], hist): - where the xvars group contains 3 variables and 5 major iterations, would return an array with the first dimension - being 5, the number of iterations, and the second dimension being 3, where there are 3 values for the 3 variables per iteration. - """ - - if dataType: - values = hist.getValues(names=name, major=("major" in dataType), scale=("scale" in dataType)) - if "delta" in dataType: - tempValues = values[name].copy() - for i in list(range(len(values[name]))): - for j in list(range(len(values[name][i]))): - if i != 0: - values[name][i][j] = abs(values[name][i][j] - tempValues[i - 1][j]) - else: - values[name][i][j] = 0 - - else: - values = hist.getValues(names=name, major=False) - return values - - -def addVarTraces(var, trace, dataType, names, groupType): - """ - Adds traces to the plotly figure trace[] list for the passed in variable, based on specified user input including data tpye - and plot type. Also adds upper and lower bounds traces if specified by user in dataType. - - If 'bounds' is in dataType, three traces are added: the var's trace, and the upper and lower bound traces for the var. - If not, one trace is added, being the passed in var's trace. - - Parameters - ---------- - var : str - The name of the variable to add a trace for - If multiple history files are being used - Formatted as: OPT/DV/FUNC-GROUPNAME_HIST-FILE-LABEL-INDEX_VAR-INDEX - EX: xvars_A_0 -> This would be the first variable in the xvars group from file A - One history file: - EX: EX: xvars_0 -> This would be the first variable in the xvars group - *NOTE* If the xvars group only has one scalar quantity, the corresponding name will only be xvars with no _INDEX - - trace: list - List containing the traces to be displayed on the plotly figure. - - dataType : list of str - Contains dataType str values selected by user (i.e scale, major, delta, bounds) - - names: list of str - List of either the function, optimization, or DV group names that have been selected by the user. - - groupType: str - 'dvar', 'func', or 'opt' depending which group the specified var is in - - Returns - ------- - Nothing - """ - # A list of the History objects for each history file argument - histList = [History(fileName) for fileName in histListArgs] - var = str(var) - # Initializing History object associated with the passed in var - hist = histList[0] - # Variable index (EX: var=xvars_0 -> indexVar=0) - indexVar = 0 - # History file index (EX: var=xvars_A_0 -> indexHist=A) - indexHist = "A" - - # # Group name for the passed in variable (EX: var=xvars_A_0 -> varGroupName=xvars_A) - # varGroupName = var - # Variable key name that is used in history file (removed _INDEX or _indexHist) (EX: var=xvars_0/xvars_A_0 -> varName=xvars) - varName = var - - # Set varName, indexHist, and varGroup name accordingly based on # of history files and # of variables in var's group - if var in names: - # If the var is in the names list, then it does not have an _INDEX appointed and must be apart of a group with one qty - if len(histList) > 1: - indexHist = var.split("_")[-1] - varName = var[::-1].replace(indexHist + "_", "", 1)[::-1] - else: - indexVar = var.split("_")[-1] - reversedIndexVar = indexVar[::-1] - # varGroupName = var[::-1].replace(indexVar + "_", "", 1)[::-1] - if len(histList) > 1: - indexHist = var.split("_")[-2] - varName = var[::-1].replace(reversedIndexVar + "_" + indexHist + "_", "", 1)[::-1] - else: - varName = var[::-1].replace(reversedIndexVar + "_", "", 1)[::-1] - - # Set hist as the appropriate History object for the var - hist = histList[ord(indexHist) % 65] - # Dictionary with DV or Func group info for the var, with keys 'scale', 'lower', 'upper', - # where each key corresponds to a numpy array of size # variables, with each index's value corresponding to that indexed variable in the group - info = hist.getDVInfo() if groupType == "dvar" else hist.getConInfo() - # Needed trace data based on var and dataType needed - data = getValues(name=varName, dataType=dataType, hist=hist) - - # Add trace for var to trace[] list - trace.append( - go.Scatter( - x=list(range(len(data[varName]))), - y=[data.real[int(indexVar)] for data in data[varName]], - name=var, - marker_color=colors[(len(trace) - 1) % len(colors)], - mode="lines+markers", - marker={"size": 3}, - line={"width": 1}, - ) - ) - # Add upper & lower bounds traces if 'bounds' was selected - if dataType and "bounds" in dataType and varName != "obj" and groupType != "opt": - # Create lower + upper bounds array to plot, and scale if scaling is needed. If no 'lower' or 'upper' information, leave trace blank - if "scale" in dataType: - # HACK/TODO: Used np.print_1d to convert all ['scale'] values to a 1d array ('scale' gives a scalar qty for groups with only one variable) - scaleData = np.atleast_1d(info[varName]["scale"]) - # HACK/TODO: ['scale'] array should be the size of the variables in a group but some seem to - # be one constant, so this checks if it should be made as an array of the size of the vars - if var not in names and len(scaleData) == 1: - scaleData = [info[varName]["scale"]] * len(data[varName]) - scaleFactor = np.atleast_1d(info[varName]["scale"])[int(indexVar)].real - lowerB = ( - [info[varName]["lower"][int(indexVar)] * scaleFactor] * len(data[varName]) - if info[varName]["lower"][0] - else [] - ) - upperB = ( - [info[varName]["upper"][int(indexVar)] * scaleFactor] * len(data[varName]) - if info[varName]["upper"][0] - else [] - ) - else: - lowerB = [info[varName]["lower"][int(indexVar)]] * len(data[varName]) - upperB = [info[varName]["upper"][int(indexVar)]] * len(data[varName]) - # Add lower + upper bound traces to trace list - trace.append( - go.Scatter( - x=list(range(len(data[varName]))), - y=lowerB, - name=var + "_LB", - marker_color=colors[(len(trace) - 2) % len(colors)], - mode="lines", - line={"dash": "dash", "width": 2}, - ) - ) - trace.append( - go.Scatter( - x=list(range(len(data[varName]))), - y=upperB, - name=var + "_UB", - marker_color=colors[(len(trace) - 3) % len(colors)], - mode="lines", - line={"dash": "dash", "width": 2}, - ) - ) - - -def addMaxMinTraces(var, trace, dataType, histList): - """ - Adds min and max to the plotly figure trace[] list for the passed in variable group. - - Parameters - ---------- - var : str - The name of the variable group to add min/max traces for. - EX: xvars - - trace: list - List containing the traces to be displayed on the plotly figure. - - dataType : list of str - Contains dataType str values selected by user (i.e scale, major, delta, bounds) - - histList: list of str - List of str file names to be turned into history objects. - - Returns - ------- - Nothing - """ - var = str(var) - # Variable key name that is used in history file (removed _INDEX or _indexHist) (EX: var=xvars_0/xvars_A_0 -> varName=xvars) - varName = var - hist = histList[0] - if len(histList) > 1: - indexHist = var.split("_")[-1] - hist = histList[ord(indexHist) % 65] - varName = var[::-1].replace(indexHist + "_", "", 1)[::-1] - data = getValues(name=varName, dataType=dataType, hist=hist) - # Append minMax traces - # if(dataType and 'minMax' in dataType): - trace.append( - go.Scatter( - x=list(range(len(data[varName]))), - y=[max(arr.real) for arr in data[varName]], - name=var + "_max", - mode="lines+markers", - marker={"size": 3}, - line={"width": 1}, - ) - ) - trace.append( - go.Scatter( - x=list(range(len(data[varName]))), - y=[min(arr.real) for arr in data[varName]], - name=var + "_min", - mode="lines+markers", - marker={"size": 3}, - line={"width": 1}, - ) - ) - - -# ==================================================================================== -# Dash Callback Functions (Triggered by front-end interface input changes) -# ==================================================================================== - - -@app.callback( - dash.dependencies.Output("interval-component", "max_intervals"), [dash.dependencies.Input("data-type", "value")] -) -def update_refresh_intervals(dataType): - """ - This determines if the interval component should run infinitely based on if the autorefresh - setting is turned on. - - Parameters - ---------- - dataType : list of str - Contains dataType str values selected by user (i.e scale, major, delta, bounds) - - Returns - ------- - int - -1 if there should be an infinite amount of intervals, 0 if it should not autorefresh - """ - if dataType and "refresh" in dataType: - return -1 - else: - return 0 - - -@app.callback( - dash.dependencies.Output("interval-component", "interval"), [dash.dependencies.Input("refresh-rate", "value")] -) -def update_refresh_rate(rate): - """ - This determines the rate that interval component should run at in ms based on user input. - - Parameters - ---------- - rate: int - The rate inputted by the user in seconds. - - Returns - ------- - int - The rate in ms for the interval component to run at. - """ - if rate: - return rate * 1000 - else: - return 10000 - - -@app.callback(dash.dependencies.Output("refresh-rate", "disabled"), [dash.dependencies.Input("data-type", "value")]) -def update_refresh_input(dataType): - """ - This prevents the user from inputting the refresh rate if the auto refresh feature is not turned on. - - Parameters - ---------- - dataType : list of str - Contains dataType str values selected by user (i.e scale, major, delta, bounds) - - Returns - ------- - bool - True to disable the input box, False to disable it - """ - if dataType and "refresh" in dataType: - return False - else: - return True - - -@app.callback( - dash.dependencies.Output("hidden-div", "children"), - [ - dash.dependencies.Input("interval-component", "n_intervals"), - dash.dependencies.Input("interval-component", "max_intervals"), - ], -) -def update_opt_history(n, max): - """ - This saves the updated function and design group names in an object in the hidden-div, that updates - based on the interval-component's number of intervals that runs on interval - - Parameters - ---------- - n: int - The current number of intervals ran. - Not explicitly used in this function, but kept as an input dependency so this function is called on interval. - - max: int - The max number of intervals the interval-component runs on. - Not explicitly used in this function, but kept as an input dependency so that if auto refresh is not on, - this function is called once when the max_intervals is set/updated as 0. - - Returns - ------- - dictionary - Saves a dictionary of the 'dvNames' and 'funcNames' for the groups as the history file refreshes. If autorefresh is not - on, this will be called once. - - If multiple history files are being used - Formatted as: DV/FUNC-GROUPNAME_HIST-FILE-LABEL-INDEX - EX: xvars_A -> This would be the the design group 'xvars' from the file indexed A - """ - # Re-reads in history list data and passes to History API - try: - # If AutoRefresh is on and the data has not yet loaded, this line will throw an error - histList = [History(fileName) for fileName in histListArgs] - except Exception: - print("History file data is not processed yet") - return {} - else: - # Re-Saving names for drop down menus - index = ord("A") - dvNames = [] - funcNames = [] - optNames = [] - if len(histList) > 1: - for hist in histList: - dvNames += [name + "_" + chr(index) for name in hist.getDVNames()] - conNames = [name + "_" + chr(index) for name in hist.getConNames()] - objNames = [name + "_" + chr(index) for name in hist.getObjNames()] - funcNames += conNames + objNames - optNames = list( - set(histList[0].getIterKeys()).difference({"xuser", "funcs", "fail", "nMajor", "nMinor", "isMajor"}) - ) - optNames = [name + "_" + chr(index) for name in optNames] - index = (index + 1 - 65) % 26 + 65 - else: - hist = histList[0] - dvNames = hist.getDVNames() - conNames = hist.getConNames() - objNames = hist.getObjNames() - funcNames = conNames + objNames - optNames = list( - set(histList[0].getIterKeys()).difference({"xuser", "funcs", "fail", "nMajor", "nMinor", "isMajor"}) - ) - - historyInfo = { - "dvNames": dvNames, - "funcNames": funcNames, - "optNames": optNames - # 'values' : [hist.getValues(major=False, scale=False) for hist in histList], - # 'valuesMajor' : [hist.getValues(major=True) for hist in histList], - # 'valuesScale' : [hist.getValues(scale=True, major=False) for hist in histList], - # 'valuesScaleMajor' : [hist.getValues(scale=True, major=True) for hist in histList], - } - - # Parses object into JSON format to return to the hidden-div. - # Returns all needed history object info to div - # obj info, con info, dv info, getValues with: major=true, major=false, major=true+scale=true, major=false+scale=true - # print(len(historyInfo['values'])) - return json.dumps(historyInfo) - - -@app.callback(dash.dependencies.Output("funcGroup", "options"), [dash.dependencies.Input("hidden-div", "children")]) -def update_funcGroupOptions(historyInfo): - """ - This populates the 'Function Group' dropdown based on the info from the dictionary saved in the - 'hidden-div' component. - - Parameters - ---------- - historyInfo: dictionary - This is a dictionary with the keys 'dvNames', 'funcNames', and 'optNames' holding a list each of the group names for the Group dropdowns. - - Returns - ------- - list of dictionaries - List of dictionaries containing the options for the 'Function Group' dropdown. - - If multiple history files are being used - Formatted as: OPT/DV/FUNC-GROUPNAME_HIST-FILE-LABEL-INDEX - EX: twist_B -> This would be the the design group 'xvars' from the file indexed B - """ - if historyInfo: - # If historyInfo isn't null, then it is ready to be parsed to update the function group dropdown options - historyInfo = json.loads(historyInfo) - options = [{"label": i, "value": i} for i in historyInfo["funcNames"]] - return options - else: - # If historyInfo is null, keep the options as empty - return [] - - -@app.callback(dash.dependencies.Output("dvarGroup", "options"), [dash.dependencies.Input("hidden-div", "children")]) -def update_dvarGroupOptions(historyInfo): - """ - This populates the 'Design Group' dropdown based on the info from the dictionary saved in the - 'hidden-div' component. - - Parameters - ---------- - historyInfo: dictionary - This is a dictionary with the keys 'dvNames', 'funcNames', and 'optNames' holding a list each of the group names for the Group dropdowns. - - Returns - ------- - list of dictionaries - List of dictionaries containing the options for the 'Design Group' dropdown. - - If multiple history files are being used - Formatted as: OPT/DV/FUNC-GROUPNAME_HIST-FILE-LABEL-INDEX - EX: twist_B -> This would be the the design group 'xvars' from the file indexed B - """ - if historyInfo: - # If historyInfo isn't null, then it is ready to be parsed to update the function group dropdown options - historyInfo = json.loads(historyInfo) - options = [{"label": i, "value": i} for i in historyInfo["dvNames"]] - return options - else: - # If historyInfo is null, keep the options as empty - return [] - - -@app.callback(dash.dependencies.Output("optGroup", "options"), [dash.dependencies.Input("hidden-div", "children")]) -def update_optGroupOptions(historyInfo): - """ - This populates the 'Optimization Group' dropdown based on the info from the dictionary saved in the - 'hidden-div' component. - - Parameters - ---------- - historyInfo: dictionary - This is a dictionary with the keys 'dvNames', 'funcNames', and 'optNames' holding a list each of the group names for the Group dropdowns. - - Returns - ------- - list of dictionaries - List of dictionaries containing the options for the 'Optimization Group' dropdown. - - If multiple history files are being used - Formatted as: OPT/DV/FUNC-GROUPNAME_HIST-FILE-LABEL-INDEX - EX: twist_B -> This would be the the design group 'xvars' from the file indexed B - """ - if historyInfo: - # If historyInfo isn't null, then it is ready to be parsed to update the function group dropdown options - historyInfo = json.loads(historyInfo) - options = [{"label": i, "value": i} for i in historyInfo["optNames"]] - return options - else: - # If historyInfo is null, keep the options as empty - return [] - - -@app.callback( - dash.dependencies.Output("dvarChild", "options"), - [ - dash.dependencies.Input("dvarGroup", "value"), - dash.dependencies.Input("dvarGroup", "options"), - ], -) -def update_dvar_child(dvarGroup, options): - """ - This populates the 'Design Variable' dropdown based on the design groups from the 'Design Group' selected. - - Parameters - ---------- - dvarGroup: list of str - The selected DV groups from the 'Design Group' dropdown. - - options: list of str - The 'Design Group' dropdown options. This is not explicitly used in this function, but is supplied - as an input dependency so that this callback is triggered to re-update when 'refresh' is turned on. When - 'refresh' is on, once the dvarGroup options finally loads, the dvarChild's options will be update. - - Returns - ------- - dictionary - List of dictionaries containing the options for the 'Design Variable' dropdown. - - If multiple history files are being used - Formatted as: OPT/DV/FUNC-GROUPNAME_HIST-FILE-LABEL-INDEX_VAR-INDEX - EX: xvars_A_0 -> This would be the first variable in the xvars group from file A - One history file: - EX: xvars_0 -> This would be the first variable in the xvars group - *NOTE* If the xvars group only has one scalar quantity, the corresponding name will only be xvars with no _INDEX - EX: If xvars only holds a scalar qty, its variable name will be simply 'xvars' - - """ - # Re-reads in history list data and passes to History API. - try: - # If AutoRefresh is on and the data has not yet loaded, this line will throw an error - histList = [History(fileName) for fileName in histListArgs] - except Exception: - print("History file data is not processed yet") - return [] - else: - strlist = [] - if dvarGroup: - for var in dvarGroup: - var = str(var) - varName = var - hist = histList[0] - if len(histList) > 1: - indexHist = var.split("_")[-1] - hist = histList[ord(indexHist) % 65] - varName = var[::-1].replace(indexHist + "_", "", 1)[::-1] - varValues = hist.getValues(names=varName, major=False) - num = len(varValues[varName][0]) - if num == 1: - strlist += [var] - else: - strlist += [var + "_" + str(i) for i in range(num)] - return [{"label": i, "value": i} for i in strlist] - - -@app.callback( - dash.dependencies.Output("funcChild", "options"), - [ - dash.dependencies.Input("funcGroup", "value"), - dash.dependencies.Input("funcGroup", "options"), - ], -) -def update_func_child(funcGroup, options): - """ - This populates the 'Function Variable' dropdown based on the design groups from the 'Function Group' selected. - - Parameters - ---------- - dvarGroup: list of str - The selected DV groups from the 'Function Group' dropdown. - - options: list of str - The 'Function Group' dropdown options. This is not explicitly used in this function, but is supplied - as an input dependency so that this callback is triggered to re-update when 'refresh' is turned on. When - 'refresh' is on, once the funcGroup options finally loads, the funcChild's options will be update. - - Returns - ------- - dictionary - List of dictionaries containing the options for the 'Function Variable' dropdown. - - If multiple history files are being used - Formatted as: OPT/DV/FUNC-GROUPNAME_HIST-FILE-LABEL-INDEX_VAR-INDEX - EX: xvars_A_0 -> This would be the first variable in the xvars group from file A - One history file: - EX: xvars_0 -> This would be the first variable in the xvars group - *NOTE* If the xvars group only has one scalar quantity, the corresponding name will only be xvars with no _INDEX - EX: Because 'obj' holds a scalar quantity, it will be displayed as simply 'obj' in the Variable dropdown. - """ - # Re-reads in history list data and passes to History API - try: - # If AutoRefresh is on and the data has not yet loaded, this line will throw an error - histList = [History(fileName) for fileName in histListArgs] - except Exception: - print("History file data is not processed yet") - return [] - else: - strlist = [] - if funcGroup: - for var in funcGroup: - var = str(var) - hist = histList[0] - varName = var - if len(histList) > 1: - indexHist = var.split("_")[-1] - hist = histList[ord(indexHist) % 65] - varName = var[::-1].replace(indexHist + "_", "", 1)[::-1] - varValues = hist.getValues(names=varName, major=False) - num = len(varValues[varName][0]) - if num == 1: - strlist += [var] - else: - strlist += [var + "_" + str(i) for i in range(num)] - - return [{"label": i, "value": i} for i in strlist] - - -@app.callback( - dash.dependencies.Output("optChild", "options"), - [ - dash.dependencies.Input("optGroup", "value"), - dash.dependencies.Input("optGroup", "options"), - ], -) -def update_opt_child(optGroup, options): - """ - This populates the 'Optimization Variable' dropdown based on the design groups from the 'Optimization Group' selected. - - Parameters - ---------- - optGroup: list of str - The selected Optimization groups from the 'Optimization Group' dropdown. - - options: list of str - The 'Optimization Group' dropdown options. This is not explicitly used in this function, but is supplied - as an input dependency so that this callback is triggered to re-update when 'refresh' is turned on. When - 'refresh' is on, once the optGroup options finally loads, the optChild's options will be update. - - Returns - ------- - dictionary - List of dictionaries containing the options for the 'Optimization Variable' dropdown. - If multiple history files are being used - Formatted as: OPT/DV/FUNC-GROUPNAME_HIST-FILE-LABEL-INDEX_VAR-INDEX - - EX: xvars_A_0 -> This would be the first variable in the xvars group from file A - One history file: - EX: xvars_0 -> This would be the first variable in the xvars group - *NOTE* If the xvars group only has one scalar quantity, the corresponding name will only be xvars with no _INDEX - EX: Because 'obj' holds a scalar quantity, it will be displayed as simply 'obj' in the Variable dropdown. - """ - # Re-reads in history list data and passes to History API - try: - # If AutoRefresh is on and the data has not yet loaded, this line will throw an error - histList = [History(fileName) for fileName in histListArgs] - except Exception: - print("History file data is not processed yet") - return [] - else: - strlist = [] - if optGroup: - for var in optGroup: - var = str(var) - hist = histList[0] - varName = var - if len(histList) > 1: - indexHist = var.split("_")[-1] - hist = histList[ord(indexHist) % 65] - varName = var[::-1].replace(indexHist + "_", "", 1)[::-1] - varValues = hist.getValues(names=varName, major=False) - num = len(varValues[varName][0]) - if num == 1: - strlist += [var] - else: - strlist += [var + "_" + str(i) for i in range(num)] - - return [{"label": i, "value": i} for i in strlist] - - -@app.callback( - dash.dependencies.Output("dvarChild", "value"), - [dash.dependencies.Input("dvarGroup", "value"), dash.dependencies.Input("dvarChild", "options")], - [dash.dependencies.State("dvarChild", "value")], -) -def update_dvar_childAutoPopulate(dvarGroup, options, dvarChild): - """ - This autopopulates the 'Design Variable' dropdown if the 'Design Group' selected has 10 or less values. - - Parameters - ---------- - dvarGroup: list of str - The selected Design groups from the 'Design Group' dropdown. - - options: list of str - The 'Design Group' dropdown options. This is not explicitly used in this function, but is supplied - as an input dependency so that this callback is triggered to re-update when 'refresh' is turned on. When - 'refresh' is on, once the dvarGroup options finally loads, the dvarChild's options will be update. - - dvarChild: list of str - This is the current state of the selected options from the 'Design Variable' dropdown. This is used - to check which design groups have already been selected and should not be re auto populated. - - Returns - ------- - list of str - List of the 'Design Variable' values to be auto selected. - """ - # Re-reads in history list data and passes to History API - try: - # If AutoRefresh is on and the data has not yet loaded, this line will throw an error - histList = [History(fileName) for fileName in histListArgs] - except Exception: - print("History file data is not processed yet") - return [] - else: - strlist = [] - if dvarGroup: - for var in dvarGroup: - var = str(var) - vName = var - hist = histList[0] - if len(histList) > 1: - indexHist = var.split("_")[-1] - hist = histList[ord(indexHist) % 65] - vName = var[::-1].replace(indexHist + "_", "", 1)[::-1] - varValues = hist.getValues(names=vName, major=False) - # This checks if a specific group already has variables selected and shouldn't be autopopulated - varAlreadyExists = False - for varName in dvarChild: - if var in varName: - strlist += [varName] - varAlreadyExists = True - if not varAlreadyExists: - if len(varValues[vName][0]) < 11: - num = len(varValues[vName][0]) - if num == 1: - strlist += [var] - else: - strlist += [var + "_" + str(i) for i in range(num)] - return [i for i in strlist] - - -@app.callback( - dash.dependencies.Output("funcChild", "value"), - [dash.dependencies.Input("funcGroup", "value"), dash.dependencies.Input("funcChild", "options")], - [dash.dependencies.State("funcChild", "value")], -) -def update_func_childAutoPopulate(funcGroup, options, funcChild): - """ - This autopopulates the 'Function Variable' dropdown if the 'Function Group' selected has 10 or less values. - - Parameters - ---------- - funcGroup: list of str - The selected Design groups from the 'Design Group' dropdown. - - options: list of str - The 'Function Group' dropdown options. This is not explicitly used in this function, but is supplied - as an input dependency so that this callback is triggered to re-update when 'refresh' is turned on. When - 'refresh' is on, once the Function options finally loads, the Function's options will be updated. - - funcChild: list of str - This is the current state of the selected options from the 'Function Variable' dropdown. This is used - to check which design groups have already been selected and should not be re auto populated. - - Returns - ------- - list of str - List of the 'Function Variable' values to be auto selected. - """ - # Re-reads in history list data and passes to History API - try: - # If AutoRefresh is on and the data has not yet loaded, this line will throw an error - histList = [History(fileName) for fileName in histListArgs] - except Exception: - print("History file data is not processed yet") - return [] - else: - strlist = [] - if funcGroup: - for var in funcGroup: - var = str(var) - vName = var - hist = histList[0] - if len(histList) > 1: - indexHist = var.split("_")[-1] - hist = histList[ord(indexHist) % 65] - vName = var[::-1].replace(indexHist + "_", "", 1)[::-1] - varValues = hist.getValues(names=vName, major=False) - # This checks if a specific group already has variables selected and shouldn't be autopopulated - varAlreadyExists = False - for varName in funcChild: - if var in varName: - strlist += [varName] - varAlreadyExists = True - if not varAlreadyExists: - if len(varValues[vName][0]) < 11: - num = len(varValues[vName][0]) - if num == 1: - strlist += [var] - else: - strlist += [var + "_" + str(i) for i in range(num)] - return [i for i in strlist] - - -@app.callback( - dash.dependencies.Output("optChild", "value"), - [dash.dependencies.Input("optGroup", "value"), dash.dependencies.Input("optChild", "options")], - [dash.dependencies.State("optChild", "value")], -) -def update_opt_childAutoPopulate(optGroup, options, optChild): - """ - This autopopulates the 'Function Variable' dropdown if the 'Optimization Group' selected has 10 or less values. - - Parameters - ---------- - optGroup: list of str - The selected Design groups from the 'Optimization Group' dropdown. - - options: list of str - The 'Optimization Group' dropdown options. This is not explicitly used in this function, but is supplied - as an input dependency so that this callback is triggered to re-update when 'refresh' is turned on. When - 'refresh' is on, once the Optimization options finally loads, the Optimization's options will be updated. - - optChild: list of str - This is the current state of the selected options from the 'Optimization Variable' dropdown. This is used - to check which optimization groups have already been selected and should not be re auto populated. - - Returns - ------- - list of str - List of the 'Optimization Variable' values to be auto selected. - """ - # Re-reads in history list data and passes to History API - try: - # If AutoRefresh is on and the data has not yet loaded, this line will throw an error - histList = [History(fileName) for fileName in histListArgs] - except Exception: - print("History file data is not processed yet") - return [] - else: - strlist = [] - if optGroup: - for var in optGroup: - var = str(var) - vName = var - hist = histList[0] - if len(histList) > 1: - indexHist = var.split("_")[-1] - hist = histList[ord(indexHist) % 65] - vName = var[::-1].replace(indexHist + "_", "", 1)[::-1] - varValues = hist.getValues(names=vName, major=False) - # This checks if a specific group already has variables selected and shouldn't be autopopulated - varAlreadyExists = False - for varName in optChild: - if var in varName: - strlist += [varName] - varAlreadyExists = True - if not varAlreadyExists: - if len(varValues[vName][0]) < 11: - num = len(varValues[vName][0]) - if num == 1: - strlist += [var] - else: - strlist += [var + "_" + str(i) for i in range(num)] - return [i for i in strlist] - - -@app.callback( - dash.dependencies.Output("plot", "figure"), - [ - dash.dependencies.Input("dvarGroup", "value"), - dash.dependencies.Input("funcGroup", "value"), - dash.dependencies.Input("optGroup", "value"), - dash.dependencies.Input("dvarChild", "value"), - dash.dependencies.Input("funcChild", "value"), - dash.dependencies.Input("optChild", "value"), - dash.dependencies.Input("plot-type", "value"), - dash.dependencies.Input("data-type", "value"), - dash.dependencies.Input("hidden-div", "children"), - ], -) -def update_plot(dvarGroup, funcGroup, optGroup, dvarChild, funcChild, optChild, plotType, dataType, hiddenDiv): - """ - This will update the plot figure accordingly in the layout if the - design groups, function groups, design variables, function variables, plot type, or data type values are changed. - If auto refresh is selected, it will also update the plot every time hidden-dv's values are changed, which occurs on interval. - - Parameters - ---------- - dvarGroup : list of str - The selected design variable groups from the DV Group dropdown. - - funcGroup: list of str - The selected function groups (or objective) from the Function Group dropdown. - - optGroup: list of str - The selected optimization groups from the Optimization Group dropdown. - - dvarChild: list of str - The selected design variables from the Design Variable dropdown. - - funcChild: list of str - The selected function variables (or objective) from the Function Variable dropdown. - - optChild: list of str - The selected optimization variable from the Optimization Variable dropdown. - - plotType: str - The selected plot type. (Options are currently 'Stacked' or 'Shared') - - dataType : list of str - Contains dataType str values selected by user (i.e scale, major, delta, bounds) - - hiddenDiv: Object with the dcc.Interval component information - This allows the plot to be updated once, or on interval if 'refresh' is selected. - - Returns - ------- - plotly figure {} object - The updated figure object based on new input values. - """ - # HiddenDiv is updated once the interval component runs, so this checks if it has been updated yet and - # the information is ready to be loaded. - if hiddenDiv: - # A list of the History objects for each history file argument - histList = [History(fileName) for fileName in histListArgs] - # List of traces to be plotted on fig - trace = [] - fig = {} - fig["layout"] = {} - # List of all func groups + DV groups selected names -> used for stacked plots axis - varsNames = [] - - # Create subplots in 'fig' if stacked plot type is chosen with 1 subplot per group selected - if plotType == "stacked": - # Determine # of plots - numRows = 0 - if not dvarGroup and not funcGroup and not optGroup: - numRows = 1 - if funcGroup: - numRows += len(funcGroup) - if dvarGroup: - numRows += len(dvarGroup) - if optGroup: - numRows += len(optGroup) - # Determine all variable group names selected for stacked plot axis' - varsNames = [] - dvarGroup = dvarGroup if dvarGroup else [] - funcGroup = funcGroup if funcGroup else [] - optGroup = optGroup if optGroup else [] - varsNames = dvarGroup + funcGroup + optGroup - fig = subplots.make_subplots( - rows=numRows, subplot_titles=varsNames, vertical_spacing=0.15, x_title="Iterations" - ) - for i in fig["layout"]["annotations"]: - i["font"] = dict(size=12) - fig.update_yaxes(type=("log" if (dataType and ("log" in dataType)) else "linear")) - - # Add overall min + max traces for each DV group - if dataType and "minMax" in dataType and dvarGroup: - for var in dvarGroup: - addMaxMinTraces(var, trace, dataType, histList) - # Add min & max traces to current var's subplot if 'stacked' plot type - if plotType == "stacked": - fig.append_trace(trace[len(trace) - 1], varsNames.index(var) + 1, 1) - fig.append_trace(trace[len(trace) - 2], varsNames.index(var) + 1, 1) - - # Add traces for each DV selected, including lower + upper bound traces if 'bounds' is selected - if dvarChild: - for var in dvarChild: - groupType = "dvar" - indexVar = var.split("_")[-1] - varGroupName = var if var in dvarGroup else var[::-1].replace(indexVar + "_", "", 1)[::-1] - addVarTraces(var, trace, dataType, dvarGroup, groupType) - # Add traces to current var's subplot if 'stacked' plot type - if plotType == "stacked": - if dataType and "bounds" in dataType: - # If bounds was selected, addVarTraces added three traces (var's trace, var's upper bound trace, var's lower bound trace) - fig.append_trace(trace[len(trace) - 3], varsNames.index(varGroupName) + 1, 1) - fig.append_trace(trace[len(trace) - 2], varsNames.index(varGroupName) + 1, 1) - fig.append_trace(trace[len(trace) - 1], varsNames.index(varGroupName) + 1, 1) - else: - fig.append_trace(trace[len(trace) - 1], varsNames.index(varGroupName) + 1, 1) - - # Add overall min + max traces for each function group - if dataType and "minMax" in dataType and funcGroup: - for var in funcGroup: - addMaxMinTraces(var, trace, dataType, histList) - # Add min & max traces to current var's subplot if 'stacked' plot type - if plotType == "stacked": - fig.append_trace(trace[len(trace) - 1], varsNames.index(var) + 1, 1) - fig.append_trace(trace[len(trace) - 2], varsNames.index(var) + 1, 1) - - # Add traces for each func selected, including lower + upper bound traces if 'bounds' is selected - if funcChild: - for var in funcChild: - groupType = "func" - indexVar = var.split("_")[-1] - varGroupName = var if var in funcGroup else var[::-1].replace(indexVar + "_", "", 1)[::-1] - addVarTraces(var, trace, dataType, funcGroup, groupType) - if plotType == "stacked": - if dataType and "bounds" in dataType: - fig.append_trace(trace[len(trace) - 3], varsNames.index(varGroupName) + 1, 1) - fig.append_trace(trace[len(trace) - 2], varsNames.index(varGroupName) + 1, 1) - fig.append_trace(trace[len(trace) - 1], varsNames.index(varGroupName) + 1, 1) - else: - fig.append_trace(trace[len(trace) - 1], varsNames.index(varGroupName) + 1, 1) - - # Add overall min + max traces for each optimization group - if dataType and "minMax" in dataType and optGroup: - for var in optGroup: - addMaxMinTraces(var, trace, dataType, histList) - # Add min & max traces to current var's subplot if 'stacked' plot type - if plotType == "stacked": - fig.append_trace(trace[len(trace) - 1], varsNames.index(var) + 1, 1) - fig.append_trace(trace[len(trace) - 2], varsNames.index(var) + 1, 1) - - # Add traces for each func selected, no bounds for opt group - if optChild: - for var in optChild: - groupType = "opt" - indexVar = var.split("_")[-1] - varGroupName = var if var in optGroup else var[::-1].replace(indexVar + "_", "", 1)[::-1] - addVarTraces(var, trace, dataType, optGroup, groupType) - if plotType == "stacked": - fig.append_trace(trace[len(trace) - 1], varsNames.index(varGroupName) + 1, 1) - - # For 'shared' plotType, set fig's 'data' key as the trace[] list, containing all the traces based on the input - if plotType == "shared": - fig["data"] = trace - - # Layout styling - fig["layout"].update( - # autosize = True, - xaxis={ - "title": { - "text": "Iterations" if (plotType == "shared") else None, - "font": {"family": "Arial, Helvetica, sans-serif"}, - }, - }, - yaxis={ - "title": { - "text": None, - }, - "type": "log" if (dataType and ("log" in dataType)) else "linear", - }, - showlegend=True, - font={"size": 12}, - ) - - return fig - - else: - return {} - - -def main(): - app.run_server(debug=True) - - -# Run if file is used directly, and not imported -if __name__ == "__main__": - main() diff --git a/pyoptsparse/postprocessing/assets/OptViewIcon.gif b/pyoptsparse/postprocessing/assets/OptViewIcon.gif deleted file mode 100644 index 3001627c..00000000 Binary files a/pyoptsparse/postprocessing/assets/OptViewIcon.gif and /dev/null differ diff --git a/pyoptsparse/postprocessing/assets/OptViewIcon.pdf b/pyoptsparse/postprocessing/assets/OptViewIcon.pdf new file mode 100644 index 00000000..8a4880da Binary files /dev/null and b/pyoptsparse/postprocessing/assets/OptViewIcon.pdf differ diff --git a/pyoptsparse/postprocessing/assets/OptViewIcon.png b/pyoptsparse/postprocessing/assets/OptViewIcon.png new file mode 100644 index 00000000..a4ba3e44 Binary files /dev/null and b/pyoptsparse/postprocessing/assets/OptViewIcon.png differ diff --git a/pyoptsparse/postprocessing/assets/base-styles.css b/pyoptsparse/postprocessing/assets/base-styles.css deleted file mode 100644 index feb92cab..00000000 --- a/pyoptsparse/postprocessing/assets/base-styles.css +++ /dev/null @@ -1,393 +0,0 @@ -/* Table of contents -–––––––––––––––––––––––––––––––––––––––––––––––––– -- Grid -- Base Styles -- Typography -- Links -- Buttons -- Forms -- Lists -- Code -- Tables -- Spacing -- Utilities -- Clearing -- Media Queries -*/ - - -/* Grid -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -.container { - position: relative; - width: 100%; - max-width: 960px; - margin: 0 auto; - padding: 0 20px; - box-sizing: border-box; } -.column, -.columns { - width: 100%; - float: left; - box-sizing: border-box; } - -/* For devices larger than 400px */ -@media (min-width: 400px) { - .container { - width: 85%; - padding: 0; } -} - -/* For devices larger than 550px */ -@media (min-width: 550px) { - .container { - width: 80%; } - .column, - .columns { - margin-left: 0.5%; } - .column:first-child, - .columns:first-child { - margin-left: 0; } - - .one.column, - .one.columns { width: 8%; } - .two.columns { width: 16.25%; } - .three.columns { width: 22%; } - .four.columns { width: 33%; } - .five.columns { width: 39.3333333333%; } - .six.columns { width: 49.75%; } - .seven.columns { width: 56.6666666667%; } - .eight.columns { width: 66.5%; } - .nine.columns { width: 74.0%; } - .ten.columns { width: 82.6666666667%; } - .eleven.columns { width: 91.5%; } - .twelve.columns { width: 100%; margin-left: 0; } - - .one-third.column { width: 30.6666666667%; } - .two-thirds.column { width: 65.3333333333%; } - - .one-half.column { width: 48%; } - - /* Offsets */ - .offset-by-one.column, - .offset-by-one.columns { margin-left: 8.66666666667%; } - .offset-by-two.column, - .offset-by-two.columns { margin-left: 17.3333333333%; } - .offset-by-three.column, - .offset-by-three.columns { margin-left: 26%; } - .offset-by-four.column, - .offset-by-four.columns { margin-left: 34.6666666667%; } - .offset-by-five.column, - .offset-by-five.columns { margin-left: 43.3333333333%; } - .offset-by-six.column, - .offset-by-six.columns { margin-left: 52%; } - .offset-by-seven.column, - .offset-by-seven.columns { margin-left: 60.6666666667%; } - .offset-by-eight.column, - .offset-by-eight.columns { margin-left: 69.3333333333%; } - .offset-by-nine.column, - .offset-by-nine.columns { margin-left: 78.0%; } - .offset-by-ten.column, - .offset-by-ten.columns { margin-left: 86.6666666667%; } - .offset-by-eleven.column, - .offset-by-eleven.columns { margin-left: 95.3333333333%; } - - .offset-by-one-third.column, - .offset-by-one-third.columns { margin-left: 34.6666666667%; } - .offset-by-two-thirds.column, - .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } - - .offset-by-one-half.column, - .offset-by-one-half.columns { margin-left: 52%; } - -} - - -/* Base Styles -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -/* NOTE -html is set to 62.5% so that all the REM measurements throughout Skeleton -are based on 10px sizing. So basically 1.5rem = 15px :) */ -html { - font-size: 62.5%; } -body { - font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ - line-height: 1.6; - font-weight: 400; - font-family: "Roboto", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; - color: rgb(50, 50, 50); } - - -/* Typography -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -h1, h2, h3, h4, h5, h6 { - margin-top: 0; - margin-bottom: 0; - font-weight: 300; } -h1 { font-size: 4.5rem; line-height: 1.2; letter-spacing: -.1rem; margin-bottom: 2rem; } -h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; margin-bottom: 1.8rem; margin-top: 1.8rem;} -h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; margin-bottom: 1.5rem; margin-top: 1.5rem;} -h4 { font-size: 2.6rem; line-height: 1.35; letter-spacing: -.08rem; margin-bottom: 1.2rem; margin-top: 1.2rem;} -h5 { font-size: 2.2rem; line-height: 1.5; letter-spacing: -.05rem; margin-bottom: 0.6rem; margin-top: 0.6rem;} -h6 { font-size: 2.0rem; line-height: 1.6; letter-spacing: 0; margin-bottom: 0.75rem; margin-top: 0.75rem;} - -p { - margin-top: 0; } - - -/* Blockquotes -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -blockquote { - border-left: 4px lightgrey solid; - padding-left: 1rem; - margin-top: 2rem; - margin-bottom: 2rem; - margin-left: 0rem; -} - - -/* Links -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -a { - color: #1EAEDB; } -a:hover { - color: #0FA0CE; } - - -/* Buttons -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -.button, -button, -input[type="submit"], -input[type="reset"], -input[type="button"] { - display: inline-block; - height: 38px; - padding: 0 30px; - color: #555; - text-align: center; - font-size: 11px; - font-weight: 600; - line-height: 38px; - letter-spacing: .1rem; - text-transform: uppercase; - text-decoration: none; - white-space: nowrap; - background-color: transparent; - border-radius: 4px; - border: 1px solid #bbb; - cursor: pointer; - box-sizing: border-box; } -.button:hover, -button:hover, -input[type="submit"]:hover, -input[type="reset"]:hover, -input[type="button"]:hover, -.button:focus, -button:focus, -input[type="submit"]:focus, -input[type="reset"]:focus, -input[type="button"]:focus { - color: #333; - border-color: #888; - outline: 0; } -.button.button-primary, -button.button-primary, -input[type="submit"].button-primary, -input[type="reset"].button-primary, -input[type="button"].button-primary { - color: #FFF; - background-color: #33C3F0; - border-color: #33C3F0; } -.button.button-primary:hover, -button.button-primary:hover, -input[type="submit"].button-primary:hover, -input[type="reset"].button-primary:hover, -input[type="button"].button-primary:hover, -.button.button-primary:focus, -button.button-primary:focus, -input[type="submit"].button-primary:focus, -input[type="reset"].button-primary:focus, -input[type="button"].button-primary:focus { - color: #FFF; - background-color: #1EAEDB; - border-color: #1EAEDB; } - - -/* Forms -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -input[type="email"], -input[type="number"], -input[type="search"], -input[type="text"], -input[type="tel"], -input[type="url"], -input[type="password"], -textarea, -select { - height: 38px; - padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ - background-color: #fff; - border: 1px solid #D1D1D1; - border-radius: 4px; - box-shadow: none; - box-sizing: border-box; - font-family: inherit; - font-size: inherit; /*https://stackoverflow.com/questions/6080413/why-doesnt-input-inherit-the-font-from-body*/} -/* Removes awkward default styles on some inputs for iOS */ -input[type="email"], -input[type="number"], -input[type="search"], -input[type="text"], -input[type="tel"], -input[type="url"], -input[type="password"], -textarea { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; } -textarea { - min-height: 65px; - padding-top: 6px; - padding-bottom: 6px; } -input[type="email"]:focus, -input[type="number"]:focus, -input[type="search"]:focus, -input[type="text"]:focus, -input[type="tel"]:focus, -input[type="url"]:focus, -input[type="password"]:focus, -textarea:focus, -select:focus { - border: 1px solid #33C3F0; - outline: 0; } -label, -legend { - display: block; - margin-bottom: 0px; } -fieldset { - padding: 0; - border-width: 0; } -input[type="checkbox"], -input[type="radio"] { - display: inline; } -label > .label-body { - display: inline-block; - margin-left: .5rem; - font-weight: normal; } - - -/* Lists -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -ul { - list-style: circle inside; } -ol { - list-style: decimal inside; } -ol, ul { - padding-left: 0; - margin-top: 0; } -ul ul, -ul ol, -ol ol, -ol ul { - margin: 1.5rem 0 1.5rem 3rem; - font-size: 90%; } -li { - margin-bottom: 1rem; } - - -/* Tables -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -th, -td { - padding: 12px 15px; - text-align: left; - border-bottom: 1px solid #E1E1E1; } -th:first-child, -td:first-child { - padding-left: 0; } -th:last-child, -td:last-child { - padding-right: 0; } - - -/* Spacing -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -button, -.button { - margin-bottom: 0rem; } -input, -textarea, -select, -fieldset { - margin-bottom: 0rem; } -pre, -dl, -figure, -table, -form { - margin-bottom: 0rem; } -p, -ul, -ol { - margin-bottom: 0.75rem; } - -/* Utilities -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -.u-full-width { - width: 100%; - box-sizing: border-box; } -.u-max-full-width { - max-width: 100%; - box-sizing: border-box; } -.u-pull-right { - float: right; } -.u-pull-left { - float: left; } - - -/* Misc -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -hr { - margin-top: 3rem; - margin-bottom: 3.5rem; - border-width: 0; - border-top: 1px solid #E1E1E1; } - - -/* Clearing -–––––––––––––––––––––––––––––––––––––––––––––––––– */ - -/* Self Clearing Goodness */ -.container:after, -.row:after, -.u-cf { - content: ""; - display: table; - clear: both; } - - -/* Media Queries -–––––––––––––––––––––––––––––––––––––––––––––––––– */ -/* -Note: The best way to structure the use of media queries is to create the queries -near the relevant code. For example, if you wanted to change the styles for buttons -on small devices, paste the mobile query code up in the buttons section and style it -there. -*/ - - -/* Larger than mobile */ -@media (min-width: 400px) {} - -/* Larger than phablet (also point when grid becomes active) */ -@media (min-width: 550px) {} - -/* Larger than tablet */ -@media (min-width: 750px) {} - -/* Larger than desktop */ -@media (min-width: 1000px) {} - -/* Larger than Desktop HD */ -@media (min-width: 1200px) {} \ No newline at end of file diff --git a/pyoptsparse/postprocessing/assets/custom-styles.css b/pyoptsparse/postprocessing/assets/custom-styles.css deleted file mode 100644 index 2a552058..00000000 --- a/pyoptsparse/postprocessing/assets/custom-styles.css +++ /dev/null @@ -1,15 +0,0 @@ -.Select-multi-value-wrapper { - max-height: 100px; - overflow: auto; - max-width: 800px; -} - -.Select-control { - max-width: 800px; -} - -.js-plotly-plot, -.plot-container { - height: 90vh; - width: 100vh; -} \ No newline at end of file diff --git a/pyoptsparse/postprocessing/assets/logo.png b/pyoptsparse/postprocessing/assets/logo.png deleted file mode 100644 index 9338162f..00000000 Binary files a/pyoptsparse/postprocessing/assets/logo.png and /dev/null differ diff --git a/pyoptsparse/postprocessing/assets/nicePlotsStyle b/pyoptsparse/postprocessing/assets/nicePlotsStyle new file mode 100644 index 00000000..4efed5cb --- /dev/null +++ b/pyoptsparse/postprocessing/assets/nicePlotsStyle @@ -0,0 +1,29 @@ +font.family : sans-serif +font.sans-serif : CMU Bright +font.size : 10 + +axes.unicode_minus : False +axes.spines.top : False +axes.spines.right : False +axes.labelpad : 8.0 + +legend.columnspacing : 0.2 +legend.frameon : False + +patch.edgecolor : w + +axes.autolimit_mode : round_numbers +axes.xmargin : 0 +axes.ymargin : 0 + +lines.linewidth : 2.0 + +axes.prop_cycle: cycler('color', ['e29400ff','1E90FF','E21A1A','00a650ff','800000ff','ff8f00','800080ff','00A6D6','000000ff','5a5758ff']) +axes.edgecolor : 5a5758ff +text.color : 5a5758ff +axes.labelcolor : 5a5758ff +axes.labelweight : 200 +xtick.color : 5a5758ff +ytick.color : 5a5758ff + +pgf.texsystem: pdflatex \ No newline at end of file diff --git a/pyoptsparse/postprocessing/assets/pyOptSparse_logo.png b/pyoptsparse/postprocessing/assets/pyOptSparse_logo.png new file mode 100644 index 00000000..daf92d23 Binary files /dev/null and b/pyoptsparse/postprocessing/assets/pyOptSparse_logo.png differ diff --git a/pyoptsparse/postprocessing/baseclasses.py b/pyoptsparse/postprocessing/baseclasses.py new file mode 100644 index 00000000..e2659f8a --- /dev/null +++ b/pyoptsparse/postprocessing/baseclasses.py @@ -0,0 +1,31 @@ +# External modules +from PyQt6.QtWidgets import QWidget + + +class View(QWidget): + def __init__(self, *args, **kwargs) -> None: + super(View, self).__init__(*args, **kwargs) + + +class Controller(object): + def __init__(self, *args, **kwargs): + """ + Base class for controller objects. + """ + self._view = None + + @property + def view(self) -> View: + return self._view + + @view.setter + def view(self, view: View): + self._view = view + + +class Model(object): + def __init__(self, *args, **kwargs) -> None: + """ + Base class for model objects. + """ + pass diff --git a/pyoptsparse/postprocessing/colors.py b/pyoptsparse/postprocessing/colors.py new file mode 100644 index 00000000..135694f6 --- /dev/null +++ b/pyoptsparse/postprocessing/colors.py @@ -0,0 +1,7 @@ +# External modules +from PyQt6.QtGui import QColor + +# --- Color constants --- +GREEN = QColor(0, 255, 0, 20) +BLUE = QColor(0, 150, 255, 20) +WHITE = QColor(255, 255, 255) diff --git a/pyoptsparse/postprocessing/data_structures.py b/pyoptsparse/postprocessing/data_structures.py new file mode 100644 index 00000000..74fa1642 --- /dev/null +++ b/pyoptsparse/postprocessing/data_structures.py @@ -0,0 +1,416 @@ +# Standard Python modules +from pathlib import PurePath +from typing import Dict, List + +# External modules +import numpy as np + +# First party modules +from pyoptsparse.pyOpt_history import History + + +class Bounds: + def __init__(self): + self._lower = None + self._upper = None + + @property + def lower(self): + return self._lower + + @property + def upper(self): + return self._upper + + @lower.setter + def lower(self, lower: float): + self._lower = lower + + @upper.setter + def upper(self, upper: float): + self._upper = upper + + +class Variable: + def __init__(self, var_name: str): + """ + Data structure for storing variables. + + Parameters + ---------- + var_name : str + The name of the variable. + """ + self._name = var_name + self._idx = 0 + self._full_name = None + self._options = {"scale": False, "bounds": False, "major": True} + self._bounds = Bounds() + self._scaled_bounds = Bounds() + self._data_major = None + self._data_minor = None + self._file = None + self._scale = 1.0 + self._label = None + + def __eq__(self, other): + return self.name == other.name and self.idx == other.idx + + @property + def name(self): + return self._name + + @property + def idx(self): + return self._idx + + @property + def full_name(self): + return self._full_name + + @property + def options(self): + return self._options + + @property + def bounds(self): + return self._bounds + + @property + def scaled_bounds(self): + return self._scaled_bounds + + @property + def data_major(self): + return self._data_major + + @property + def data_minor(self): + return self._data_minor + + @property + def file(self): + return self._file + + @property + def scale(self): + return self._scale + + @property + def label(self): + return self._label + + @name.setter + def name(self, name: str): + self._name = name + + @idx.setter + def idx(self, idx: int): + self._idx = idx + + @full_name.setter + def full_name(self, full_name: str): + self._full_name = full_name + + @options.setter + def options(self, options: Dict): + self._options = options + + @bounds.setter + def bounds(self, bounds: Bounds): + self._bounds = bounds + + @scaled_bounds.setter + def scaled_bounds(self, scaled_bounds: Bounds): + self._scaled_bounds = scaled_bounds + + @data_major.setter + def data_major(self, data_major: np.ndarray): + self._data_major = data_major + + @data_minor.setter + def data_minor(self, data_minor: np.ndarray): + self._data_minor = data_minor + + @file.setter + def file(self, file): + self._file = file + + @scale.setter + def scale(self, scale: float): + self._scale = scale + + @label.setter + def label(self, label: str): + self._label = label + + def compute_scaled_bounds(self): + if self._bounds.upper is not None: + self._scaled_bounds.upper = self._bounds.upper * self.scale + + if self._bounds.lower is not None: + self._scaled_bounds.lower = self._bounds.lower * self.scale + + +class File: + def __init__(self): + """ + Data structure for storing and accessing pyoptparse history + files. + """ + self._name = None + self._short_name = None + self._reader = None + self._dv_names = [] + self._con_names = [] + self._obj_names = [] + self._func_names = [] + self._all_names = [] + self._y_vars = {} + self._x_vars = {} + self._metadata = None + + @property + def name(self): + return self._name + + @property + def short_name(self): + return self._short_name + + @property + def reader(self): + return self._reader + + @property + def dv_names(self): + return self._dv_names + + @property + def con_names(self): + return self._con_names + + @property + def obj_names(self): + return self._obj_names + + @property + def func_names(self): + return self._func_names + + @property + def all_names(self): + return self._all_names + + @property + def y_vars(self): + return self._y_vars + + @property + def x_vars(self): + return self._x_vars + + @property + def metadata(self): + return self._metadata + + @name.setter + def name(self, name: str): + self._name = name + self._short_name = PurePath(name).name + + @reader.setter + def reader(self, reader: History): + self._reader = reader + + @dv_names.setter + def dv_names(self, dv_names: List[str]): + self._dv_names = dv_names + + @con_names.setter + def con_names(self, con_names: List[str]): + self._con_names = con_names + + @obj_names.setter + def obj_names(self, obj_names: List[str]): + self._obj_names = obj_names + + @func_names.setter + def func_names(self, func_names: List[str]): + self._func_names = func_names + + @all_names.setter + def all_names(self, all_names: List[str]): + self._all_names = all_names + + @y_vars.setter + def y_vars(self, y_vars: Dict): + self._y_vars = y_vars + + @x_vars.setter + def x_vars(self, x_vars: Dict): + self._x_vars = x_vars + + @metadata.setter + def metadata(self, metadata: Dict): + self._metadata = metadata + + def load_file(self, file_name: str): + """ + Loads a file using the pyoptsparse history api. + + Parameters + ---------- + file_name : str + The name of the file. + """ + self.reader = History(file_name) + self.name = file_name + self.dv_names = self.reader.getDVNames() + self.obj_names = self.reader.getObjNames() + self.con_names = self.reader.getConNames() + self.func_names = self.reader.getExtraFuncsNames() + self.all_names = [k for k in self.reader.getValues().keys()] + self.metadata = self.reader.getMetadata() + self.x_vars = self._get_x_variables() + self.y_vars = self._get_y_variables() + + def refresh(self): + """ + Calls load file to refresh the data. + """ + self.load_file(self.name) + + def _get_y_variables(self): + y_var_names = self._get_all_y_var_names() + variables = {} + for name in y_var_names: + data_major = self.reader.getValues(name, major=True, scale=False)[name] + data_minor = self.reader.getValues(name, major=False, scale=False)[name] + + # If the major iteration data has array dimensions greater + # than 1, the variable is vector valued and we need to + # create a variable object for each column of the array. + if data_major.shape[1] > 1: + for i, cols in enumerate(zip(data_major.T, data_minor.T)): + var = Variable(name) + var.idx = i + var.data_major = cols[0] + var.data_minor = cols[1] if len(cols[0]) != len(cols[1]) else None + var.file = self + self._set_bounds(var) + self._set_scale(var) + var.compute_scaled_bounds() + var.full_name = f"{var.name}_{var.idx}" + variables[var.full_name] = var + + # If the major iteration data is one dimensional we can + # simply create a single variable and set the data directly + else: + var = Variable(name) + var.data_major = data_major + var.data_minor = data_minor if len(data_minor) != len(data_major) else None + var.file = self + self._set_bounds(var) + self._set_scale(var) + var.compute_scaled_bounds() + var.full_name = f"{var.name}" + variables[var.full_name] = var + + return variables + + def _get_x_variables(self): + x_var_names = self._get_all_x_var_names() + variables = {} + for name in x_var_names: + data_major = self.reader.getValues(name, major=True, scale=False)[name] + data_minor = self.reader.getValues(name, major=False, scale=False)[name] + if data_major.shape[1] > 1: + for i, cols in enumerate(zip(data_major.T, data_minor.T)): + var = Variable(name) + var.idx = i + var.data_major = cols[0] + var.data_minor = cols[1] if len(cols[0]) != len(cols[1]) else None + var.file = self + var.full_name = f"{var.name}_{var.idx}" + variables[var.full_name] = var + else: + var = Variable(name) + var.data_major = data_major + var.data_minor = data_minor if len(data_minor) != len(data_major) else None + var.file = self + var.full_name = f"{var.name}" + variables[var.full_name] = var + + return variables + + def _set_data(self, var: Variable): + """ + Gets all iterations (minor and major), bounds, and scaling + of a single variable from either the OpenMDAO case reader or the + pyOptSparse history API and stores them in a variable object. + + Parameters + ---------- + var: Variable + A Variable object which stores all data required for + plotting. + """ + + data_major = self.reader.getValues(var.name, major=True, scale=False)[var.name] + data_minor = self.reader.getValues(var.name, major=False, scale=False)[var.name] + + if data_major.ndim > 1: + for i, cols in enumerate(zip(data_major.T, data_minor.T)): + if var.idx == i: + var.data_major = cols[0] + var.data_minor = cols[1] if len(cols[0]) != len(cols[1]) else None + else: + var.data_major = data_major + var.data_minor = data_minor if len(data_minor) != len(data_major) else None + + def _set_bounds(self, var: Variable): + bounded_var_names = self.dv_names + self.con_names + var_info = None + if var.name in bounded_var_names: + if var.name in self.dv_names: + var_info = self.reader.getDVInfo(var.name) + else: + var_info = self.reader.getConInfo(var.name) + + if var_info is not None: + if "lower" in var_info: + lower_bound = var_info["lower"][var.idx] + var.bounds.lower = lower_bound + if "upper" in var_info: + upper_bound = var_info["upper"][var.idx] + var.bounds.upper = upper_bound + + def _set_scale(self, var: Variable): + scaled_var_names = self.dv_names + self.con_names + self.obj_names + if var.name in scaled_var_names: + if var.name in self.obj_names: + var.scale = self.reader.getObjInfo(var.name)["scale"] + elif var.name in self.con_names: + var.scale = self.reader.getConInfo(var.name)["scale"] + elif var.name in self.dv_names: + var.scale = self.reader.getDVInfo(var.name)["scale"][var.idx] + + def _get_all_x_var_names(self): + """ + Returns all the possible x-variable names listed in the filter. + """ + x_name_filter = ["iter", "time"] + self.dv_names + return [name for name in self.all_names if name in x_name_filter] + + def _get_all_y_var_names(self): + """ + Returns all the possible y-variable names listed in the filter + """ + y_name_filter = ( + ["step", "time", "optimality", "feasibility", "merit", "penalty"] + + self.dv_names + + self.con_names + + self.obj_names + ) + return [name for name in self.all_names if name in y_name_filter] diff --git a/pyoptsparse/postprocessing/general_widgets.py b/pyoptsparse/postprocessing/general_widgets.py new file mode 100644 index 00000000..3dd34859 --- /dev/null +++ b/pyoptsparse/postprocessing/general_widgets.py @@ -0,0 +1,399 @@ +# External modules +from PyQt6.QtCore import QPropertyAnimation, QRectF, QSize, QSortFilterProxyModel, Qt, pyqtProperty +from PyQt6.QtGui import QColor, QDropEvent, QKeySequence, QPainter, QShortcut +from PyQt6.QtWidgets import ( + QAbstractButton, + QComboBox, + QCompleter, + QHBoxLayout, + QLineEdit, + QListWidget, + QPushButton, + QSizePolicy, + QTableWidgetItem, + QTreeWidgetItem, + QWidget, +) + +# First party modules +from pyoptsparse.postprocessing.baseclasses import Controller, View +from pyoptsparse.postprocessing.data_structures import File + +# --- Color constants --- +GREEN = QColor(0, 255, 0, 20) +BLUE = QColor(0, 150, 255, 20) +WHITE = QColor(255, 255, 255) + + +class Button(QPushButton): + def __init__(self, *args, **kwargs): + """ + Inherits the PyQt6 push button class and implements a custom + button format. + """ + super().__init__(*args, **kwargs) + self.resize(150, 30) + + +class ExtendedComboBox(QComboBox): + def __init__(self, parent: QWidget = None): + """ + Combobox view with custom autocomplete functionality for storing and + searching for variables + + Parameters + ---------- + parent : PyQt6.QtWidgets.QWidget, optional + The parent view, by default None + """ + super(ExtendedComboBox, self).__init__(parent) + + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setEditable(True) + + # add a filter model to filter matching items + self.pFilterModel = QSortFilterProxyModel(self) + self.pFilterModel.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.pFilterModel.setSourceModel(self.model()) + + # add a completer, which uses the filter model + self.completer = QCompleter(self.pFilterModel, self) + # always show all (filtered) completions + self.completer.setCompletionMode(QCompleter.CompletionMode.UnfilteredPopupCompletion) + self.setCompleter(self.completer) + + # connect signals + self.lineEdit().textEdited.connect(self.pFilterModel.setFilterFixedString) + self.completer.activated.connect(self.on_completer_activated) + + # on selection of an item from the completer, select the + # corresponding item from combobox + def on_completer_activated(self, text): + if text: + index = self.findText(text) + self.setCurrentIndex(index) + + # on model change, update the models of the filter and completer + # as well + def setModel(self, model): + super(ExtendedComboBox, self).setModel(model) + self.pFilterModel.setSourceModel(model) + self.completer.setModel(self.pFilterModel) + + # on model column change, update the model column of the filter + # and completer as well + def setModelColumn(self, column): + self.completer.setCompletionColumn(column) + self.pFilterModel.setFilterKeyColumn(column) + super(ExtendedComboBox, self).setModelColumn(column) + + +class Switch(QAbstractButton): + def __init__(self, parent: QWidget = None, track_radius: int = 10, thumb_radius: int = 8): + """ + On/off slider switch Widget. + Source code found at: + https://stackoverflow.com/questions/14780517/toggle-switch-in-qt/51023362 + + Parameters + ---------- + parent : PyQt6.QtWidgets.QWidget + The parent view, default = None + track_radius : int + The radius of the slider track, default = 10 + thumb_radius : int + The radius of the thumb slider, default = 8 + """ + super().__init__(parent=parent) + self.setCheckable(True) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + + self._track_radius = track_radius + self._thumb_radius = thumb_radius + + self._margin = max(0, self._thumb_radius - self._track_radius) + self._base_offset = max(self._thumb_radius, self._track_radius) + self._end_offset = { + True: lambda: self.width() - self._base_offset, + False: lambda: self._base_offset, + } + self._offset = self._base_offset + + palette = self.palette() + if self._thumb_radius > self._track_radius: + self._track_color = { + True: palette.highlight(), + False: palette.dark(), + } + self._thumb_color = { + True: palette.highlight(), + False: palette.light(), + } + self._text_color = { + True: palette.highlightedText().color(), + False: palette.dark().color(), + } + self._thumb_text = { + True: "", + False: "", + } + self._track_opacity = 0.5 + else: + self._thumb_color = { + True: palette.highlightedText(), + False: palette.light(), + } + self._track_color = { + True: palette.highlight(), + False: palette.dark(), + } + self._text_color = { + True: palette.highlight().color(), + False: palette.dark().color(), + } + self._thumb_text = { + True: "\u2713", + False: "\u2716", + } + self._track_opacity = 1 + + @pyqtProperty(int) + def offset(self): + """ + Returns the offset. + """ + return self._offset + + @offset.setter + def offset(self, value): + """ + Sets the offset. + + Parameters + ---------- + value + Offset value. + """ + self._offset = value + self.update() + + def sizeHint(self): + """ + Returns the size. + """ + return QSize( + 4 * self._track_radius + 2 * self._margin, + 2 * self._track_radius + 2 * self._margin, + ) + + def setChecked(self, checked: bool): + """ + Sets the widget to the checked state. + + Parameters + ---------- + checked : bool + True for checked, False for unchecked. + """ + super().setChecked(checked) + self.offset = self._end_offset[checked]() + + def resizeEvent(self, event): + """ + Handles resizing the widget. + + Parameters + ---------- + event + The event that triggered the resize call. + """ + super().resizeEvent(event) + self.offset = self._end_offset[self.isChecked()]() + + def paintEvent(self, event): + """ + Handles painting the widget (setting the style). + + Parameters + ---------- + event + The event that triggered the paint call. + """ + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing, True) + p.setPen(Qt.PenStyle.NoPen) + track_opacity = self._track_opacity + thumb_opacity = 1.0 + text_opacity = 1.0 + if self.isEnabled(): + track_brush = self._track_color[self.isChecked()] + thumb_brush = self._thumb_color[self.isChecked()] + text_color = self._text_color[self.isChecked()] + else: + track_opacity *= 0.8 + track_brush = self.palette().shadow() + thumb_brush = self.palette().mid() + text_color = self.palette().shadow().color() + + p.setBrush(track_brush) + p.setOpacity(track_opacity) + p.drawRoundedRect( + self._margin, + self._margin, + self.width() - 2 * self._margin, + self.height() - 2 * self._margin, + self._track_radius, + self._track_radius, + ) + p.setBrush(thumb_brush) + p.setOpacity(thumb_opacity) + p.drawEllipse( + self.offset - self._thumb_radius, + self._base_offset - self._thumb_radius, + 2 * self._thumb_radius, + 2 * self._thumb_radius, + ) + p.setPen(text_color) + p.setOpacity(text_opacity) + font = p.font() + font.setPixelSize(1.5 * self._thumb_radius) + p.setFont(font) + p.drawText( + QRectF( + self.offset - self._thumb_radius, + self._base_offset - self._thumb_radius, + 2 * self._thumb_radius, + 2 * self._thumb_radius, + ), + Qt.AlignmentFlag.AlignCenter, + self._thumb_text[self.isChecked()], + ) + + def mouseReleaseEvent(self, event): + """ + Specifies what should happen when the mouse is released while + using the slider. + + Parameters + ---------- + event + The mouse release event that triggered the call. + """ + super().mouseReleaseEvent(event) + if event.button() == Qt.MouseButton.LeftButton: + anim = QPropertyAnimation(self, b"offset", self) + anim.setDuration(120) + anim.setStartValue(self.offset) + anim.setEndValue(self._end_offset[self.isChecked()]()) + anim.start() + + def enterEvent(self, event): + """ + Specifies what should happen when the enter key is pressed while + using the slider. + + Parameters + ---------- + event + The enter key event that triggered the call. + """ + self.setCursor(Qt.CursorShape.PointingHandCursor) + super().enterEvent(event) + + +class PlotList(QListWidget, View): + def __init__(self, parent: QWidget = None, controller: Controller = None) -> None: + super(PlotList, self).__init__(parent) + + self.controller = controller + + self.plot_up_action = QShortcut(QKeySequence("Ctrl+Up"), self) + self.plot_down_action = QShortcut(QKeySequence("Ctrl+Down"), self) + + self.plot_up_action.activated.connect(self.movePlotUp) + self.plot_down_action.activated.connect(self.movePlotDown) + + def dropEvent(self, event: QDropEvent) -> None: + super(PlotList, self).dropEvent(event) + self.controller.reorder_plots() + + def movePlotUp(self) -> None: + self.controller.move_plot_up() + + def movePlotDown(self) -> None: + self.controller.move_plot_down() + + +class PlotListWidget(View): + def __init__(self, parent: QWidget = None, controller: Controller = None, idx: int = 0): + """ + Custom list widget that adds functionality for configuring and + removing plots. The widget needs access to the tab window + controller so the embedded buttons can call functions for + configuring and removing plots. + + Parameters + ---------- + parent : PyQt6.QtWidgets.QWidget, optional + The parent view, by default None + controller : Controller, optional + Tab controller linked to the tab view., by default None + idx : int, optional + The index of the plot in the tab model, by default 0 + """ + super(PlotListWidget, self).__init__(parent) + + self.controller = controller + self.idx = idx + + # Set the plot title + self.title = QLineEdit(f"Plot {idx}") + + # Add the configure plot button + self.configure_button = QPushButton("Configure/Add Variables") + self.configure_button.clicked.connect(self.configure) + + # Add the remove plot button + self.remove_button = QPushButton("Remove Plot") + self.remove_button.clicked.connect(self.remove) + + # Configure the layout + layout = QHBoxLayout() + layout.addWidget(self.title, 1) + layout.addWidget(self.configure_button, 1) + layout.addWidget(self.remove_button, 1) + + # Set the layout + self.setLayout(layout) + + def remove(self): + """ + Calls the remove_plot function in the tab controller. + """ + self.controller.remove_plot(self.idx) + + def configure(self): + """ + Calls teh configure_view function in the tab controller. + """ + self.controller.configure_view(self.idx) + + +class FileTreeWidgetItem(QTreeWidgetItem): + def __init__(self, *args, **kwargs): + """ + Custom tree widget item that can store file objects. + """ + self.file = None + super().__init__(*args, **kwargs) + + def setFile(self, file: File): + self.file = file + + +class FileTableWidgetItem(QTableWidgetItem): + def __init__(self, *args, **kwargs): + """ + Custom file table widget item. + """ + super(FileTableWidgetItem, self).__init__(*args, **kwargs) diff --git a/pyoptsparse/postprocessing/meson.build b/pyoptsparse/postprocessing/meson.build index 837a8257..e97193dc 100644 --- a/pyoptsparse/postprocessing/meson.build +++ b/pyoptsparse/postprocessing/meson.build @@ -1,10 +1,13 @@ python_sources = [ - 'OptView.py', - 'OptView_baseclass.py', - 'OptView_dash.py', - '__init__.py', - 'view_saved_figure.py' + '__init__.py', + 'OptView.py', + 'opt_view_controller.py', + 'baseclasses.py', + 'data_structures.py', + 'colors.py', + 'general_widgets.py', + 'utils.py', ] py3_target.install_sources( @@ -14,14 +17,15 @@ py3_target.install_sources( ) asset_sources = [ - 'assets/OptViewIcon.gif', - 'assets/base-styles.css', - 'assets/custom-styles.css', - 'assets/logo.png' + 'assets/nicePlotsStyle', + 'assets/OptViewIcon.png', + 'assets/pyOptSparse_logo.png', ] py3_target.install_sources( asset_sources, pure: true, subdir: 'pyoptsparse/postprocessing/assets' -) \ No newline at end of file +) + +subdir('sub_windows') \ No newline at end of file diff --git a/pyoptsparse/postprocessing/opt_view_controller.py b/pyoptsparse/postprocessing/opt_view_controller.py new file mode 100644 index 00000000..cbdd5111 --- /dev/null +++ b/pyoptsparse/postprocessing/opt_view_controller.py @@ -0,0 +1,54 @@ +# Standard Python modules +from typing import List + +# External modules +from PyQt6.QtWidgets import QInputDialog, QLineEdit + +# First party modules +from pyoptsparse.postprocessing.baseclasses import Controller +from pyoptsparse.postprocessing.sub_windows.settings_window import SettingsController, SettingsView +from pyoptsparse.postprocessing.sub_windows.tab_window import TabController, TabView + + +class OptViewController(Controller): + def __init__(self): + """ + Main OptView controller. Communicates between the main view and + the different plotting tabs created by the user. + + Parameters + ---------- + view : PyQt6.QtWidgets.QWidget + MainView instance. + """ + super(OptViewController, self).__init__() + self._dark_mode = False + self._settings_controller = SettingsController() + + def addTab(self, interactive: bool = True, tab_name: str = "Home", file_names: List = []): + """ + Adds a tab to the main view. + """ + if interactive: + tab_name, ok_pressed = QInputDialog.getText( + self.view, "Enter Tab Name", "Tab Name:", QLineEdit.EchoMode.Normal, "" + ) + tab_controller = TabController(self.view, file_names=file_names) + tab_view = TabView(self.view, tab_controller) + self.view.tabs.addTab(tab_view, tab_name) + + def closeTab(self, current_index: int): + """ + Closes a tab in the main view. + + Parameters + ---------- + current_index : int + The index of the current tab. + """ + self.view.tabs.removeTab(current_index) + + def configure_settings(self): + SettingsView(parent=self.view, controller=self._settings_controller) + self._settings_controller.populate_rc_params() + self._settings_controller.populate_shortcuts() diff --git a/pyoptsparse/postprocessing/sub_windows/__init__.py b/pyoptsparse/postprocessing/sub_windows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyoptsparse/postprocessing/sub_windows/configure_plot_window.py b/pyoptsparse/postprocessing/sub_windows/configure_plot_window.py new file mode 100644 index 00000000..8cdf0814 --- /dev/null +++ b/pyoptsparse/postprocessing/sub_windows/configure_plot_window.py @@ -0,0 +1,720 @@ +# External modules +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QColor +from PyQt6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QFileDialog, + QHBoxLayout, + QLineEdit, + QMessageBox, + QStackedWidget, + QTableWidget, + QTableWidgetItem, + QTreeWidget, + QVBoxLayout, + QWidget, +) + +# First party modules +from pyoptsparse.postprocessing.baseclasses import Controller, Model, View +from pyoptsparse.postprocessing.colors import BLUE, GREEN +from pyoptsparse.postprocessing.data_structures import File, Variable +from pyoptsparse.postprocessing.general_widgets import Button, ExtendedComboBox, FileTreeWidgetItem, Switch + + +class VarTableWidgetItem(QTableWidgetItem): + def __init__(self, *args, **kwargs): + """ + Custom variable table widget item. + """ + super(VarTableWidgetItem, self).__init__(*args, **kwargs) + self._var = None + self._row_color = None + self._default_row_color = self.background() + + def __lt__(self, other): + if self._var.name == other._var.name: + return self._var.idx < other._var.idx + else: + return self._var.name < other._var.name + + @property + def var(self) -> Variable: + return self._var + + @var.setter + def var(self, var: Variable) -> None: + self._var = var + + @property + def row_color(self) -> QColor: + return self._row_color + + @row_color.setter + def row_color(self, row_color: QColor) -> None: + self._row_color = row_color + + @property + def default_row_color(self) -> QColor: + return self._default_row_color + + +class YTableWidget(QTableWidget, View): + def __init__(self, parent: QDialog = None): + """ + Defines the y-variable table view. + + Parameters + ---------- + parent : PyQt6.QtWidgets.QDialog, optional + The parent view, by default None + """ + super(YTableWidget, self).__init__(parent) + self.setShowGrid(False) + self.verticalHeader().setVisible(False) + self.setColumnCount(6) + self.setHorizontalHeaderLabels(["Name", "Index", "Scaled", "Bounds", "Add", "Remove"]) + self._controller = None + + def setController(self, controller): + self._controller = controller + + +class YController(Controller): + def __init__(self, plot_model: Model, parent_model: Model): + """ + The controller for the y-variable table view. + + Parameters + ---------- + plot_model : Model + The plot model to add the y-variables. + parent_model : Model + The parent model of the plot. + """ + super(YController, self).__init__() + self._view = None + self._parent_model = parent_model + self._plot_model = plot_model + + def populate_vars(self, current_file: File): + """ + Populates the y-variable table view with all the y-variables + in the current file. + + Parameters + ---------- + current_file : File + The current file selected by the user. + """ + for var in current_file.y_vars.values(): + self.add_row(var) + + self._view.sortItems(0, Qt.SortOrder.AscendingOrder) + + # Find all variables that are in the plot, highlight them in + # green, and link the table variable to the plot variable + for row in range(self._view.rowCount()): + var_item = self._view.item(row, 0) + self.set_alternating_color(var_item, row) + if any(var_item.var == y_var for y_var in self._plot_model.y_vars): + var_item.row_color = GREEN + self.set_row_color(row) + + def set_alternating_color(self, item, row): + if row > 0: + item_prev = self._view.item(row - 1, 0) + color_prev = item_prev.row_color + + if color_prev == BLUE: + if item.var.name == item_prev.var.name: + item.row_color = BLUE + self.set_row_color(row) + + else: + if item.var.name != item_prev.var.name: + item.row_color = BLUE + self.set_row_color(row) + + def add_row(self, var: Variable): + """ + Adds a row to the y-variable table view. + + Parameters + ---------- + file_item : FileTableWidgetItem + The file widget item being added. + var_item : VarTableWidgetItem + The variable widget item being added. + """ + row = self._view.rowCount() + self._view.setRowCount(row + 1) + + var_item = VarTableWidgetItem(var.name) + var_item.var = var + self._view.setItem(row, 0, var_item) + + idx_item = QTableWidgetItem(f"{var_item.var.idx}") + idx_item.setFlags(Qt.ItemFlag.ItemIsEnabled) + self._view.setItem(row, 1, idx_item) + + scaled_opt_item = QTableWidgetItem() + scaled_opt_item.setFlags(Qt.ItemFlag.ItemIsEnabled) + scaled_opt_chbx = QCheckBox(self._view) + scaled_opt_chbx.setChecked(False) + scaled_opt_chbx.stateChanged.connect(self.scale_opt_set) + self._view.setItem(row, 2, scaled_opt_item) + self._view.setCellWidget(row, 2, scaled_opt_chbx) + + bounds_opt_item = QTableWidgetItem() + bounds_opt_item.setFlags(Qt.ItemFlag.ItemIsEnabled) + bounds_opt_chbx = QCheckBox(self._view) + bounds_opt_chbx.setChecked(False) + bounds_opt_chbx.stateChanged.connect(self.bounds_opt_set) + self._view.setItem(row, 3, bounds_opt_item) + self._view.setCellWidget(row, 3, bounds_opt_chbx) + + add_btn = Button("Add", self._view) + add_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) + add_item = QTableWidgetItem() + add_item.setFlags(Qt.ItemFlag.ItemIsEnabled) + add_btn.clicked.connect(self.add_var_to_plot) + self._view.setItem(row, 4, add_item) + self._view.setCellWidget(row, 4, add_btn) + + rem_btn = Button("Remove", self._view) + rem_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) + rem_item = QTableWidgetItem() + rem_item.setFlags(Qt.ItemFlag.ItemIsEnabled) + rem_btn.clicked.connect(self.remove_var_from_plot) + self._view.setItem(row, 5, rem_item) + self._view.setCellWidget(row, 5, rem_btn) + + self._view.setHorizontalHeaderLabels(["Name", "Index", "Scaled", "Bounds", "Add", "Remove"]) + self._view.resizeColumnsToContents() + + def clear_vars(self): + """ + Clears the y-variable table view and resets the row count to + zero. + """ + self._view.clear() + self._view.setRowCount(0) + + def scale_opt_set(self): + """ + Sets the scale option for the selected variable and re-plots. + """ + checkbox = self._view.sender() + index = self._view.indexAt(checkbox.pos()) + selected_item = self._view.item(index.row(), 0) + scaled_opt = checkbox.checkState() + + selected_item.var.options["scaled"] = scaled_opt + + self._plot_model.plot() + self._parent_model.draw_canvas() + + def bounds_opt_set(self): + """ + Sets the bounds options for the selected variable and re-plots + """ + checkbox = self._view.sender() + index = self._view.indexAt(checkbox.pos()) + selected_item = self._view.item(index.row(), 0) + bounds_opt = checkbox.checkState() + + selected_item.var.options["bounds"] = bounds_opt + + self._plot_model.plot() + self._parent_model.draw_canvas() + + def add_var_to_plot(self): + """ + Adds a y-variable to the plot model and re-plots. + """ + button = self._view.sender() + index = self._view.indexAt(button.pos()) + selected_item = self._view.item(index.row(), 0) + var = selected_item.var + + if not self._plot_model.x_var.options["major"]: + var.options["major"] = False + + self._plot_model.add_var(var, "y") + + self._plot_model.plot() + self._parent_model.draw_canvas() + + selected_item.row_color = GREEN + self.set_row_color(index.row()) + + def remove_var_from_plot(self): + """ + Removes a y-variable from the plot model and re-plots + """ + button = self._view.sender() + index = self._view.indexAt(button.pos()) + selected_item = self._view.item(index.row(), 0) + self._plot_model.remove_var(selected_item.var, "y") + + self._view.cellWidget(index.row(), 3).setChecked(False) + self._view.cellWidget(index.row(), 4).setChecked(False) + + # Update the plot + self._plot_model.plot() + self._parent_model.draw_canvas() + + selected_item.row_color = selected_item.default_row_color + self.set_row_color(index.row()) + + def set_row_color(self, row: int): + """ + Sets a given row to a specific color. + + Parameters + ---------- + row : int + The row being colored. + color : QtGui.QColor + The color for the row. + """ + color = self._view.item(row, 0).row_color + for j in range(self._view.columnCount()): + self._view.item(row, j).setBackground(color) + + +class XTableWidget(QTableWidget, View): + def __init__(self, parent: QDialog = None): + """ + Defines a x-variable table view. + + Parameters + ---------- + parent : PyQt6.QtWidgets.QDialog, optional + The parent view, by default None + """ + super(XTableWidget, self).__init__(parent) + self.setColumnCount(2) + self.setShowGrid(False) + self.verticalHeader().setVisible(False) + self._controller = None + + def setController(self, controller: Controller): + """ + Sets the controller for this view. + + Parameters + ---------- + controller : Controller + The controller for this view. + """ + self._controller = controller + + +class XController(Controller): + def __init__(self, plot_model: Model, parent_model: Model): + """ + The controller for the x-variables view. + + Parameters + ---------- + plot_model : Model + The plot model where variables will be added. + parent_model : Model + The tab view model that contains the plot. + """ + super(XController, self).__init__() + self._view = None + self._parent_model = parent_model + self._plot_model = plot_model + + def add_row(self, var_item: VarTableWidgetItem): + """ + Adds a row to the table view formatted specifically for + x-variables. + + Parameters + ---------- + file_item : FileTableWidgetItem + The file table widget item being added. + var_item : VarTableWidgetItem + The variable table widget item being added. + """ + row = self._view.rowCount() + self._view.setRowCount(row + 1) + self._view.setItem(row, 0, var_item) + + iter_switch = Switch(self._view) + iter_switch.clicked.connect(self.iter_switch_togg) + iter_switch.setToolTip("Turn on for minor iterations, off for major iterations") + self._view.setCellWidget(row, 1, iter_switch) + + # Turn off the switch if the x-variable doesn't allow for + # minor iterations. + if var_item.var.data_minor is None and var_item.var.name != "iter": + iter_switch.setEnabled(False) + + self._view.setHorizontalHeaderLabels(["Name", "Major <-> Minor"]) + self._view.resizeColumnsToContents() + self._view.resizeRowsToContents() + + def iter_switch_togg(self): + """ + Controls the functionality when the major/minor iteration switch + is toggled on/off. + + In the off position, only the major iterations are included. + If the switch is on, we attempt to plot minor iterations unless + they do not exist for one or more of the x or y-variables. + """ + switch = self._view.sender() + x_var = self._plot_model.x_var + + if switch.isChecked(): + flag = False + for y_var in self._plot_model.y_vars.values(): + if y_var.data_minor is not None: + y_var.options["major"] = False + else: + flag = True + + if not flag: + x_var.options["major"] = False + else: + msg_title = "Minor Iterations Warning" + msg_text = ( + "One of the y-variables does not support minor iterations.\n\nSwitching back to major iterations." + ) + QMessageBox.warning(self._view, msg_title, msg_text) + switch.setChecked(False) + + else: + switch.setChecked(False) + x_var.options["major"] = True + for y_var in self._plot_model.y_vars.values(): + y_var.options["major"] = True + + self._plot_model.plot() + self._parent_model.canvas.draw() + + def clear_vars(self): + """ + Clears all the variables in the table view and resets the row + count to zero. + """ + self._view.clear() + self._view.setRowCount(0) + + +class ConfigurePlotView(QDialog, QStackedWidget, View): + def __init__(self, parent: QWidget, controller: Controller, name: str): + """ + The view for the plot configuration window. + + Parameters + ---------- + parent : PyQt6.QtWidgets.QWidget + The parent tab view. + controller : Controller + The configure plot controller linked to this view. + name : str + The name of the window, should be the same as the plot. + """ + super(ConfigurePlotView, self).__init__(parent) + self._center() # centers the application in the middle of the screen + self.setWindowTitle(name) # sets the GUI title + self._controller = controller + self._controller.view = self + self.resize(1200, 800) + self._initView() + + # --- Anything that needs to be done upon re-opening the window --- + self._controller.setup_var_tables() + self._controller.populate_files() + + def _initView(self): + """ + Initializes the view layout. + """ + # --- Create top layout + layout = QHBoxLayout() + + # --- Create sub layout for files --- + left_layout = QVBoxLayout() + layout.addLayout(left_layout, 1) + + # --- Create sub layout for variables --- + right_layout = QVBoxLayout() + layout.addLayout(right_layout, 2) + + # ============================================================== + # File Management - Top Left Layout + # ============================================================== + # --- Add file(s) button --- + self.add_file_btn = Button("Add file(s)", self) + self.add_file_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.add_file_btn.clicked.connect(self._controller.add_file) + left_layout.addWidget(self.add_file_btn) + + # --- File list --- + self.file_tree = QTreeWidget(self) + self.file_tree.setColumnCount(1) + self.file_tree.setHeaderLabels(["File Name"]) + self.file_tree.itemClicked.connect(self._controller.file_selected) + left_layout.addWidget(self.file_tree) + + # ============================================================== + # Y-Variables - Top Right Layout + # ============================================================== + # --- Add y-vars combobox --- + self.y_query = QLineEdit(self) + self.y_query.setPlaceholderText("Search...") + self.y_query.textChanged.connect(self._controller.y_var_search) + right_layout.addWidget(self.y_query) + + # --- Create button layout for y-variables --- + right_button_layout = QHBoxLayout() + right_layout.addLayout(right_button_layout) + + # --- Add selected variables button --- + self.add_sel_btn = Button("Add Selected Variables") + self.add_sel_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.add_sel_btn.clicked.connect(self._controller.add_selected_vars) + right_button_layout.addWidget(self.add_sel_btn) + + # --- Remove selected variables button --- + self.rem_sel_btn = Button("Remove Selected Variables") + self.rem_sel_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.rem_sel_btn.clicked.connect(self._controller.rem_selected_vars) + right_button_layout.addWidget(self.rem_sel_btn) + + # --- Add y-vars variable list --- + self.y_table = YTableWidget(self) + right_layout.addWidget(self.y_table, 5) + + # ============================================================== + # X-Variables - Middle Right Layout + # ============================================================== + self.x_cbox = ExtendedComboBox(self) + self.x_cbox.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.x_cbox.setToolTip("Type to search for variables") + self.x_cbox.activated.connect(self._controller.add_x_var) + right_layout.addWidget(self.x_cbox) + + self.x_table = XTableWidget(self) + right_layout.addWidget(self.x_table) + + # --- Set the main layout --- + self.setLayout(layout) + + def _center(self): + """ + Centers the window on the screen. + """ + qr = self.frameGeometry() + self.move(qr.center()) + + +class ConfigureModel(Model): + def __init__(self): + """ + Model for the configure view and controller. + """ + super(ConfigureModel, self).__init__() + + +class ConfigureController(Controller): + def __init__(self, parent_model: Model, plot_model: Model): + """ + Controller for the plot configuration view. This controller + facilitates adding x and y variables, file management, and + plotting. + + Parameters + ---------- + parent_model : Model + The tab model that holds the files. + plot_model : Model + The plot model linked to the configuration view. + """ + super(ConfigureController, self).__init__() + self._parent_model = parent_model + self._plot_model = plot_model + self._model = ConfigureModel() + self._current_file = None + + self._xtable_controller = None + self._ytable_controller = None + + def setup_var_tables(self): + """ + Sets up the x and y variable tables. Creates the controllers + for each variable table and passes the table views, plot model, + and tab model. Finally, sets the controllers for the variable + table views. + """ + self._xtable_controller = XController(self._plot_model, self._parent_model) + self._ytable_controller = YController(self._plot_model, self._parent_model) + + self._view.x_table.setController(self._xtable_controller) + self._view.y_table.setController(self._ytable_controller) + + self._xtable_controller.view = self._view.x_table + self._ytable_controller.view = self._view.y_table + + def add_file(self): + """ + Opens a file dialog to get user input for adding a new history + file. + """ + # --- Open file dialog and get selected user files --- + file_names, _ = QFileDialog.getOpenFileNames( + self._view, "Open History File", "", "History File (*.hst)", options=QFileDialog.Option.DontUseNativeDialog + ) + + # --- Load files into the model --- + self._parent_model.load_files(file_names) + + # --- Populate the files --- + self.populate_files() + + def populate_files(self): + """ + Adds all the files to the file tree widget display. If files + are loaded, the data from the first file in the tab model is + displayed by default. + """ + self._view.file_tree.clear() + for file in self._parent_model.files: + file_item = FileTreeWidgetItem(self._view.file_tree) + file_item.setFile(file) + file_item.setText(0, file.short_name) + self._view.file_tree.addTopLevelItem(file_item) + + if len(self._parent_model.files) > 0: + self._current_file = self._parent_model.files[0] + self.populate_vars() + + def file_selected(self, item: FileTreeWidgetItem, column: int): + """ + Grabs the selected file, clears the plot associated with the + configuration view, and then updates the variables in the + configuration view based on the selected file's data. + + Parameters + ---------- + item : FileTreeWidgetItem + The file tree widget item that is selected. + column : int + The tree widget column number. + """ + self._current_file = item.file + self._plot_model.clear_vars() + self._plot_model.clear_axis() + self._parent_model.canvas.draw() + self.populate_vars() + + def populate_vars(self): + """ + Adds all the variables to the x and y variable tables based + on the current file selection. If there is no x-variable in + the plot model, the default is set to 'iter' and added to the + plot model. + """ + self.populate_x_var_combobox() + self._ytable_controller.clear_vars() + self._xtable_controller.clear_vars() + self._ytable_controller.populate_vars(self._current_file) + + if self._plot_model.x_var is None: + x_var = self._current_file.x_vars["iter"] + self._view.x_cbox.setCurrentText(x_var.full_name) + self._plot_model.add_var(x_var, "x") + + var_item = VarTableWidgetItem(x_var.name) + var_item.var = x_var + + self._xtable_controller.add_row(var_item) + + def populate_x_var_combobox(self): + """ + Adds the x-variable names to the selection box. + """ + self._view.x_cbox.clear() + + for var in self._current_file.x_vars.values(): + self._view.x_cbox.addItem(var.full_name) + + def add_x_var(self): + """ + Adds an x-variable to the plot model and the x-variable table. + """ + self._xtable_controller.clear_vars() + var_name = self._view.x_cbox.currentText() + + x_var = self._current_file.x_vars[var_name] + self._plot_model.add_var(x_var, "x") + var_item = VarTableWidgetItem(x_var.full_name) + var_item.var = x_var + self._xtable_controller.add_row(var_item) + + self._plot_model.plot() + self._parent_model.canvas.draw() + + def y_var_search(self, s: str): + """ + Searches the y-variable table for the string input. + + Parameters + ---------- + s : str + User string search input. + """ + table = self._view.y_table + row_count = table.rowCount() + sel_items = table.findItems(s, Qt.MatchFlag.MatchContains) + + rows_to_show = set() + for item in sel_items: + rows_to_show.add(item.row()) + + for row in rows_to_show: + table.setRowHidden(row, False) + + for row in range(row_count): + if row not in rows_to_show: + table.setRowHidden(row, True) + + def add_selected_vars(self): + items = self._view.y_table.selectedItems() + for item in items: + if item.column() == 0: + label = self._view.y_table.cellWidget(item.row(), 2).text() + if str(label) != "": + item.var.set_label(str(label)) + self._plot_model.add_var(item.var, "y") + + item.row_color = GREEN + self._ytable_controller.set_row_color(item.row()) + + self._plot_model.plot() + self._parent_model.canvas.draw() + + def rem_selected_vars(self): + items = self._view.y_table.selectedItems() + for item in items: + if item.column() == 0: + self._plot_model.remove_var(item.var, "y") + + self._view.y_table.cellWidget(item.row(), 3).setChecked(False) + self._view.y_table.cellWidget(item.row(), 4).setChecked(False) + + item.row_color = item.default_row_color + self._ytable_controller.set_row_color(item.row()) + + # Update the plot + self._plot_model.plot() + self._parent_model.canvas.draw() diff --git a/pyoptsparse/postprocessing/sub_windows/meson.build b/pyoptsparse/postprocessing/sub_windows/meson.build new file mode 100644 index 00000000..76e87c77 --- /dev/null +++ b/pyoptsparse/postprocessing/sub_windows/meson.build @@ -0,0 +1,15 @@ +python_sources = [ + '__init__.py', + 'configure_plot_window.py', + 'metadata_window.py', + 'mpl_figureoptions.py', + 'plotting.py', + 'settings_window.py', + 'tab_window.py', +] + +py3_target.install_sources( + python_sources, + pure: true, + subdir: 'pyoptsparse/postprocessing/sub_windows' +) diff --git a/pyoptsparse/postprocessing/sub_windows/metadata_window.py b/pyoptsparse/postprocessing/sub_windows/metadata_window.py new file mode 100644 index 00000000..9f4130a1 --- /dev/null +++ b/pyoptsparse/postprocessing/sub_windows/metadata_window.py @@ -0,0 +1,241 @@ +# External modules +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QDialog, + QFileDialog, + QHBoxLayout, + QLineEdit, + QTableWidget, + QTableWidgetItem, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +# First party modules +from pyoptsparse.postprocessing.baseclasses import Controller, Model, View +from pyoptsparse.postprocessing.general_widgets import Button, FileTreeWidgetItem + + +class OptTreeWidgetItem(QTreeWidgetItem): + def __init__(self, *args, **kwargs): + """ + Custom tree widget item for metadata options. + """ + super().__init__(*args, **kwargs) + + +class OptTableWidgetItem(QTableWidgetItem): + def __init__(self, *args, **kwargs): + """ + Custom table widget item for metatdata options. + """ + super().__init__(*args, **kwargs) + + +class MetadataModel(Model): + def __init__(self): + """ + The metadata window model. + """ + super(MetadataModel, self).__init__() + + +class MetadataController(Controller): + def __init__(self, model: Model, parent_model: Model): + """ + The controller for the metadata options view. + + Parameters + ---------- + model : Model + The metadata options model. + files : List + The list of files. + """ + self._model = model + self._parent_model = parent_model + self._current_file = None + + def add_file(self): + """ + Opens a file dialog to get user input for adding a new history + file. + """ + # --- Open file dialog and get selected user files --- + file_names, _ = QFileDialog.getOpenFileNames( + self._view, "Open History File", "", "History File (*.hst)", options=QFileDialog.Option.DontUseNativeDialog + ) + + # --- Load files into the model --- + self._parent_model.load_files(file_names) + + # --- Populate the files --- + self.populate_files() + + def populate_files(self): + """ + Populates the file tree with loaded files from the tab view model. + """ + for file in self._parent_model.files: + file_item = FileTreeWidgetItem(self._view.file_tree) + file_item.setFile(file) + file_item.setText(0, file.short_name) + self._view.file_tree.addTopLevelItem(file_item) + + if len(self._parent_model.files) > 0: + self._current_file = self._parent_model.files[0] + self.populate_opts() + + def file_selected(self, item: FileTreeWidgetItem, column: int): + """ + Populates the options display widgets when a new file is + selected. + + Parameters + ---------- + item : FileTreeWidgetItem + The selected file tree widget item + column : int + The file tree column index + """ + self._current_file = item.file + self.populate_opts() + + def search(self, s: str): + """ + Searches the options for the string input. + + Parameters + ---------- + s : str + String search input. + """ + table = self._view.opt_prob_table + row_count = table.rowCount() + sel_items = table.findItems(s, Qt.MatchFlag.MatchContains) + + rows_to_show = set() + for item in sel_items: + rows_to_show.add(item.row()) + + for row in rows_to_show: + table.setRowHidden(row, False) + + for row in range(row_count): + if row not in rows_to_show: + table.setRowHidden(row, True) + + def populate_opts(self): + """ + Populates the options widgets with the history file's + metadata. + """ + self.clear_opts() + + metadata = self._current_file.metadata + for key, val in metadata.items(): + if key != "optOptions": + item = OptTreeWidgetItem(self._view.opt_tree) + item.setText(0, key) + item.setText(1, f"{val}") + self._view.opt_tree.addTopLevelItem(item) + + self._view.opt_prob_table.setRowCount(len(metadata["optOptions"].keys())) + self._view.opt_prob_table.setColumnCount(2) + for i, (key, val) in enumerate(metadata["optOptions"].items()): + option = OptTableWidgetItem(key) + value = OptTableWidgetItem(f"{val}") + + self._view.opt_prob_table.setItem(i, 0, option) + self._view.opt_prob_table.setItem(i, 1, value) + self._view.opt_prob_table.resizeColumnsToContents() + self._view.opt_prob_table.setHorizontalHeaderLabels(["Option", "Value"]) + self._view.opt_prob_table.verticalHeader().setVisible(False) + + def clear_opts(self): + # Clear everything + self._view.opt_tree.clear() + self._view.opt_prob_table.clear() + + +class MetadataView(QDialog, View): + def __init__(self, parent: QWidget, controller: MetadataController, name: str): + """ + The view for the displaying metadata options. + + Parameters + ---------- + parent : PyQt6.QtWidgets.QWidget + The parent tab view. + controller : Controller + The metadata view controller linked to this view. + name : str + The name of the window. + """ + super(MetadataView, self).__init__(parent) + self._center() + self.setWindowTitle(name) + self._controller = controller + self._controller.view = self + self.resize(1000, 800) + self._initView() + + def _initView(self): + """ + Initializes the view. + """ + # --- Create layout --- + layout = QHBoxLayout() + + left_layout = QVBoxLayout() + layout.addLayout(left_layout) + + right_layout = QVBoxLayout() + layout.addLayout(right_layout, 3) + + # ============================================================== + # File List - Left Layout + # ============================================================== + # --- Add file button --- + self.add_file_btn = Button("Add file(s)", self) + self.add_file_btn.clicked.connect(self._controller.add_file) + left_layout.addWidget(self.add_file_btn) + + # --- File list --- + self.file_tree = QTreeWidget(self) + self.file_tree.setColumnCount(1) + self.file_tree.setHeaderLabels(["File Name"]) + self.file_tree.itemClicked.connect(self._controller.file_selected) + left_layout.addWidget(self.file_tree) + + # ============================================================== + # Options Table - Right Layout + # ============================================================== + self.opt_tree = QTreeWidget(self) + self.opt_tree.setColumnCount(2) + self.opt_tree.setHeaderLabels(["Name", "Value"]) + right_layout.addWidget(self.opt_tree) + + self.query = QLineEdit() + self.query.setPlaceholderText("Search...") + self.query.textChanged.connect(self._controller.search) + right_layout.addWidget(self.query) + + self.opt_prob_table = QTableWidget(self) + self.opt_prob_table.setShowGrid(False) + right_layout.addWidget(self.opt_prob_table) + + self._controller.populate_files() + + self.setLayout(layout) + + self.show() + + def _center(self): + """ + Centers the view on the screen. + """ + qr = self.frameGeometry() + self.move(qr.center()) diff --git a/pyoptsparse/postprocessing/sub_windows/mpl_figureoptions.py b/pyoptsparse/postprocessing/sub_windows/mpl_figureoptions.py new file mode 100644 index 00000000..fb982618 --- /dev/null +++ b/pyoptsparse/postprocessing/sub_windows/mpl_figureoptions.py @@ -0,0 +1,306 @@ +# Copyright © 2009 Pierre Raybaut +# Licensed under the terms of the MIT License +# see the Matplotlib licenses directory for a copy of the license + + +"""Module that provides a GUI-based editor for Matplotlib's figure options.""" + +# External modules +# import matplotlib +from matplotlib import cbook, cm +from matplotlib import colors as mcolors +from matplotlib import image as mimage +from matplotlib import markers +from matplotlib.backends.qt_compat import QtGui +from matplotlib.backends.qt_editor import _formlayout +from matplotlib.dates import DateConverter, num2date + +LINESTYLES = { + "-": "Solid", + "--": "Dashed", + "-.": "DashDot", + ":": "Dotted", + "None": "None", +} + +DRAWSTYLES = { + "default": "Default", + "steps-pre": "Steps (Pre)", + "steps": "Steps (Pre)", + "steps-mid": "Steps (Mid)", + "steps-post": "Steps (Post)", +} + +MARKERS = markers.MarkerStyle.markers + + +def figure_edit(axes, parent=None): + """Edit matplotlib figure options""" + sep = (None, None) # separator + + # Get / General + def convert_limits(lim, converter): + """Convert axis limits for correct input editors.""" + if isinstance(converter, DateConverter): + return map(num2date, lim) + # Cast to builtin floats as they have nicer reprs. + return map(float, lim) + + xconverter = axes.xaxis.converter + xmin, xmax = convert_limits(axes.get_xlim(), xconverter) + yconverter = axes.yaxis.converter + ymin, ymax = convert_limits(axes.get_ylim(), yconverter) + general = [ + ("Title", axes.get_title()), + sep, + (None, "X-Axis"), + ("Left", xmin), + ("Right", xmax), + ("Label", axes.get_xlabel()), + ("Scale", [axes.get_xscale(), "linear", "log", "symlog", "logit"]), + sep, + (None, "Y-Axis"), + ("Bottom", ymin), + ("Top", ymax), + ("Label", axes.get_ylabel()), + ("Scale", [axes.get_yscale(), "linear", "log", "symlog", "logit"]), + sep, + ("Show Legend", True if axes.legend_ is not None else False), + ] + + if axes.legend_ is not None: + old_legend = axes.get_legend() + _draggable = old_legend._draggable is not None + _ncol = old_legend._ncol if hasattr(old_legend, "_ncol") else 1 + _fontsize = int(old_legend._fontsize) + _frameon = old_legend.get_frame_on() + _framecolor = mcolors.to_hex(mcolors.to_rgba(old_legend.get_frame().get_edgecolor()), keep_alpha=True) + _bgcolor = mcolors.to_hex( + mcolors.to_rgba(old_legend.get_frame().get_facecolor(), old_legend.get_frame().get_alpha()), + keep_alpha=True, + ) + _framealpha = old_legend.get_frame().get_alpha() + else: + _draggable = False + _ncol = 1 + _fontsize = 15 + _frameon = False + _framecolor = "000000" + _bgcolor = "#ffffff" + _framealpha = 0.5 + + legend = [ + ("Draggable", _draggable), + ("columns", _ncol), + ("Font Size", _fontsize), + ("Frame", _frameon), + ("Frame Color", _framecolor), + ("Background Color", _bgcolor), + ("Alpha", _framealpha), + ] + + # Save the unit data + xunits = axes.xaxis.get_units() + yunits = axes.yaxis.get_units() + + # Get / Curves + labeled_lines = [] + for line in axes.get_lines(): + label = line.get_label() + if label == "_nolegend_": + continue + labeled_lines.append((label, line)) + curves = [] + + def prepare_data(d, init): + """ + Prepare entry for FormLayout. + + *d* is a mapping of shorthands to style names (a single style may + have multiple shorthands, in particular the shorthands `None`, + `"None"`, `"none"` and `""` are synonyms); *init* is one shorthand + of the initial style. + + This function returns an list suitable for initializing a + FormLayout combobox, namely `[initial_name, (shorthand, + style_name), (shorthand, style_name), ...]`. + """ + if init not in d: + d = {**d, init: str(init)} + # Drop duplicate shorthands from dict (by overwriting them during + # the dict comprehension). + name2short = {name: short for short, name in d.items()} + # Convert back to {shorthand: name}. + short2name = {short: name for name, short in name2short.items()} + # Find the kept shorthand for the style specified by init. + canonical_init = name2short[d[init]] + # Sort by representation and prepend the initial value. + return [canonical_init] + sorted(short2name.items(), key=lambda short_and_name: short_and_name[1]) + + for label, line in labeled_lines: + color = mcolors.to_hex(mcolors.to_rgba(line.get_color(), line.get_alpha()), keep_alpha=True) + ec = mcolors.to_hex(mcolors.to_rgba(line.get_markeredgecolor(), line.get_alpha()), keep_alpha=True) + fc = mcolors.to_hex(mcolors.to_rgba(line.get_markerfacecolor(), line.get_alpha()), keep_alpha=True) + curvedata = [ + ("Label", label), + sep, + (None, "Line"), + ("Line style", prepare_data(LINESTYLES, line.get_linestyle())), + ("Draw style", prepare_data(DRAWSTYLES, line.get_drawstyle())), + ("Width", line.get_linewidth()), + ("Color (RGBA)", color), + sep, + (None, "Marker"), + ("Style", prepare_data(MARKERS, line.get_marker())), + ("Size", line.get_markersize()), + ("Face color (RGBA)", fc), + ("Edge color (RGBA)", ec), + ] + curves.append([curvedata, label, ""]) + # Is there a curve displayed? + has_curve = bool(curves) + + # Get ScalarMappables. + labeled_mappables = [] + for mappable in [*axes.images, *axes.collections]: + label = mappable.get_label() + if label == "_nolegend_" or mappable.get_array() is None: + continue + labeled_mappables.append((label, mappable)) + mappables = [] + cmaps = [(cmap, name) for name, cmap in sorted(cm._colormaps.items())] + for label, mappable in labeled_mappables: + cmap = mappable.get_cmap() + if cmap not in cm._colormaps.values(): + cmaps = [(cmap, cmap.name), *cmaps] + low, high = mappable.get_clim() + mappabledata = [ + ("Label", label), + ("Colormap", [cmap.name] + cmaps), + ("Min. value", low), + ("Max. value", high), + ] + if hasattr(mappable, "get_interpolation"): # Images. + interpolations = [(name, name) for name in sorted(mimage.interpolations_names)] + mappabledata.append(("Interpolation", [mappable.get_interpolation(), *interpolations])) + mappables.append([mappabledata, label, ""]) + # Is there a scalarmappable displayed? + has_sm = bool(mappables) + + datalist = [(general, "Axes", ""), (legend, "Legend", "")] + if curves: + datalist.append((curves, "Curves", "")) + if mappables: + datalist.append((mappables, "Images, etc.", "")) + + def apply_callback(data): + """A callback to apply changes.""" + orig_xlim = axes.get_xlim() + orig_ylim = axes.get_ylim() + + general = data.pop(0) + legend = data.pop(0) + curves = data.pop(0) if has_curve else [] + mappables = data.pop(0) if has_sm else [] + if data: + raise ValueError("Unexpected field") + + # Set / General + (title, xmin, xmax, xlabel, xscale, ymin, ymax, ylabel, yscale, show_legend) = general + + if axes.get_xscale() != xscale: + axes.set_xscale(xscale) + if axes.get_yscale() != yscale: + axes.set_yscale(yscale) + + axes.set_title(title) + axes.set_xlim(xmin, xmax) + axes.set_xlabel(xlabel) + axes.set_ylim(ymin, ymax) + axes.set_ylabel(ylabel) + + # Restore the unit data + axes.xaxis.converter = xconverter + axes.yaxis.converter = yconverter + axes.xaxis.set_units(xunits) + axes.yaxis.set_units(yunits) + axes.xaxis._update_axisinfo() + axes.yaxis._update_axisinfo() + + # Set / Curves + for index, curve in enumerate(curves): + line = labeled_lines[index][1] + ( + label, + linestyle, + drawstyle, + linewidth, + color, + marker, + markersize, + markerfacecolor, + markeredgecolor, + ) = curve + line.set_label(label) + line.set_linestyle(linestyle) + line.set_drawstyle(drawstyle) + line.set_linewidth(linewidth) + rgba = mcolors.to_rgba(color) + line.set_alpha(None) + line.set_color(rgba) + if marker != "none": + line.set_marker(marker) + line.set_markersize(markersize) + line.set_markerfacecolor(markerfacecolor) + line.set_markeredgecolor(markeredgecolor) + + # Set ScalarMappables. + for index, mappable_settings in enumerate(mappables): + mappable = labeled_mappables[index][1] + if len(mappable_settings) == 5: + label, cmap, low, high, interpolation = mappable_settings + mappable.set_interpolation(interpolation) + elif len(mappable_settings) == 4: + label, cmap, low, high = mappable_settings + mappable.set_label(label) + mappable.set_cmap(cm.get_cmap(cmap)) + mappable.set_clim(*sorted([low, high])) + + # Set / Legend + if show_legend: + ( + leg_draggable, + leg_ncol, + leg_fontsize, + leg_frameon, + leg_framecolor, + leg_bgcolor, + leg_framealpha, + ) = legend + + new_legend = axes.legend( + ncol=leg_ncol, + fontsize=float(leg_fontsize), + frameon=leg_frameon, + framealpha=leg_framealpha, + ) + frame = new_legend.get_frame() + frame.set_edgecolor(leg_framecolor) + frame.set_facecolor(leg_bgcolor) + new_legend.set_draggable(leg_draggable) + else: + axes.legend().set_visible(False) + + # Redraw + figure = axes.get_figure() + figure.canvas.draw() + if not (axes.get_xlim() == orig_xlim and axes.get_ylim() == orig_ylim): + figure.canvas.toolbar.push_current() + + _formlayout.fedit( + datalist, + title="Figure options", + parent=parent, + icon=QtGui.QIcon(str(cbook._get_data_path("images", "qt4_editor_options.svg"))), + apply=apply_callback, + ) diff --git a/pyoptsparse/postprocessing/sub_windows/plotting.py b/pyoptsparse/postprocessing/sub_windows/plotting.py new file mode 100644 index 00000000..39acbcbd --- /dev/null +++ b/pyoptsparse/postprocessing/sub_windows/plotting.py @@ -0,0 +1,232 @@ +# Standard Python modules +import os + +# External modules +from PIL import Image +from PyQt6.QtWidgets import QSizePolicy, QVBoxLayout, QWidget +import matplotlib +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg +from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +from matplotlib.backends.qt_editor import figureoptions +from matplotlib.figure import Figure +import matplotlib.patheffects as patheffects +import matplotlib.pyplot as plt +import numpy as np + +# First party modules +from pyoptsparse.postprocessing.baseclasses import Model, View +from pyoptsparse.postprocessing.data_structures import Variable +from pyoptsparse.postprocessing.sub_windows.mpl_figureoptions import figure_edit +from pyoptsparse.postprocessing.utils import ASSET_PATH + +# ====================================================================== +# Set matplotlib backend and plt style +# ====================================================================== +matplotlib.use(backend="Qt5Agg") +plt.style.use(os.path.join(ASSET_PATH, "nicePlotsStyle")) +figureoptions.figure_edit = figure_edit + + +class MplCanvas(FigureCanvasQTAgg): + def __init__(self, parent: Figure = None): + """ + Matplotlib canvas using the QTAgg backend. + + Parameters + ---------- + parent : Figure, optional + Matplotlib figure used to set the canvas + """ + super(MplCanvas, self).__init__(parent) + self.fig = parent + + self.addImage() # Add the pyoptparse image + + # Set the size policy so the plot can be resized with the parent + # view + FigureCanvasQTAgg.setSizePolicy(self, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + FigureCanvasQTAgg.updateGeometry(self) + + def addImage(self): + """ + Adds the pyoptsparse logo to the canvas as an axis. + """ + self.img = Image.open(os.path.join(ASSET_PATH, "pyOptSparse_logo.png")) + axes = self.fig.add_subplot(111) + axes.imshow(self.img, alpha=0.5) + axes.axis("off") + + +class PlotView(View): + def __init__(self, parent: QWidget = None, width: int = 10, height: int = 5, dpi: int = 100): + """ + Constructor + + Parameters + ---------- + parent : PyQt6.QtWidgets.QWidget, optional + Parent Window, by default None + width : int, optional + Figure Width, by default 10 + height : int, optional + Figure Height, by default 5 + dpi : int, optional + Figure dpi, by default 100 + """ + super(PlotView, self).__init__(parent) + + # Create three "plot" QPushButton widgets + + # Create a maptlotlib FigureCanvas object, + # which defines a single set of axes as its axes attribute + fig = Figure(figsize=(width, height), dpi=dpi) + self.canvas = MplCanvas(parent=fig) + + # Create toolbar for the figure: + # * First argument: the canvas that the toolbar must control + # * Second argument: the toolbar's parent (self, the PlotterWidget) + self.toolbar = NavigationToolbar(self.canvas, self) + + # Define and apply widget layout + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.toolbar) + layout.addWidget(self.canvas) + self.setLayout(layout) + + +class PlotModel(Model): + def __init__(self): + """ + Model for each plot in the tab view. + """ + super(PlotModel, self).__init__() + self.y_vars = {} + self.x_var = None + self.axis = None + + def add_var(self, var: Variable, axis: str): + """ + Adds a variable to the data model + + Parameters + ---------- + var: Variable object + The variable object to be added + axis: str + Either "x" or "y" depending on the axis to which the + variable will be added. + """ + if axis == "x": + self.x_var = var + elif axis == "y": + if var.full_name not in self.y_vars: + self.y_vars[var.full_name] = var + + def remove_var(self, var: Variable, axis: str): + """ + Removes a variable from the data model + + Parameters + ---------- + selected_var: Variable + The variable to be removed from the model. + idx: int + The index of the variable to be removed + """ + if axis == "x": + self.x_var = None + elif axis == "y": + if var.full_name in self.y_vars: + del self.y_vars[var.full_name] + + def clear_vars(self): + """ + Clears the x and y variable data + """ + self.x_var = None + self.y_vars = {} + + def update_axis(self, axis): + """ + Updates the matplotlib axis + + Parameters + ---------- + axis : matplotlib.axis.Axis + The subplot axis to be updated. + """ + self.axis = axis + + def clear_axis(self): + """ + Clears the current axis. + """ + for artist in self.axis.lines + self.axis.collections: + artist.remove() + + def plot(self): + """ + Plots the x and y variables currently stored in the model. + """ + self.clear_axis() + + for y_var in self.y_vars.values(): + if self.x_var.name == "iter": + if self.x_var.options["major"]: + x_data = self.x_var.data_major + else: + x_data = np.arange(0, len(y_var.data_minor), 1) + else: + if self.x_var.options["major"]: + x_data = self.x_var.data_major + else: + x_data = self.x_var.data_minor + + if y_var.options["major"]: + y_data = y_var.data_major + else: + y_data = y_var.data_minor + + if y_var.options["scale"]: + y_data = y_data * y_var.scale + + self.axis.plot( + x_data, + y_data, + marker=".", + label=y_var.full_name if y_var.label is None else y_var.label, + ) + + if y_var.options["bounds"]: + label = y_var.full_name + ":upper_bound" if y_var.label is None else y_var.label + ":upper_bound" + if y_var.bounds.upper is not None: + if y_var.options["scale"]: + self.axis.axhline( + y=y_var.scaled_bounds.upper, path_effects=[patheffects.withTickedStroke()], label=label + ) + else: + self.axis.axhline( + y=y_var.bounds.upper, path_effects=[patheffects.withTickedStroke()], label=label + ) + + if y_var.bounds.lower is not None: + label = y_var.full_name + ":lower_bound" if y_var.label is None else y_var.label + ":lower_bound" + if y_var.options["scale"]: + self.axis.axhline( + y=y_var.scaled_bounds.lower, + path_effects=[patheffects.withTickedStroke(angle=-135)], + label=label, + ) + else: + self.axis.axhline( + y=y_var.bounds.lower, + path_effects=[patheffects.withTickedStroke(angle=-135)], + label=label, + ) + + if self.axis.legend_ is not None: + self.axis.legend() + + self.axis.relim() + self.axis.autoscale_view() diff --git a/pyoptsparse/postprocessing/sub_windows/settings_window.py b/pyoptsparse/postprocessing/sub_windows/settings_window.py new file mode 100644 index 00000000..6840d01e --- /dev/null +++ b/pyoptsparse/postprocessing/sub_windows/settings_window.py @@ -0,0 +1,197 @@ +# Standard Python modules +import os +from typing import Dict + +# External modules +from PyQt6.QtGui import QFont, QKeySequence +from PyQt6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QHeaderView, + QTableWidget, + QTableWidgetItem, + QTabWidget, + QVBoxLayout, + QWidget, +) + +# First party modules +from pyoptsparse.postprocessing.baseclasses import Controller, Model, View +from pyoptsparse.postprocessing.utils import ASSET_PATH + + +class SettingsModel(Model): + def __init__(self, *args, **kwargs): + super(SettingsModel, self).__init__(*args, **kwargs) + self._shortcuts = {} + + @property + def shortcuts(self) -> Dict: + return self._shortcuts + + def add_shortcut(self, shortcut: Dict): + self._shortcuts = {**self._shortcuts, **shortcut} + + +class SettingsController(Controller): + def __init__(self): + """ + The controller for the tab view. + + Parameters + ---------- + root : PyQt6.QtWidgets.QWidget + The OptView main view + file_names : List, optional + Names of files to be pre-loaded in to the model, + by default [] + """ + super(SettingsController, self).__init__() + self._appearance_view = None + self._keyshort_view = None + self._rc_param_view = None + self._model = SettingsModel() + + @property + def appearance_view(self): + return self._appearance_view + + @property + def keyshort_view(self): + return self._keyshort_view + + @property + def rc_param_view(self): + return self._rc_param_view + + @appearance_view.setter + def appearance_view(self, view): + self._appearance_view = view + + @keyshort_view.setter + def keyshort_view(self, view): + self._keyshort_view = view + + @rc_param_view.setter + def rc_param_view(self, view): + self._rc_param_view = view + + def populate_rc_params(self): + with open(os.path.join(ASSET_PATH, "nicePlotsStyle"), "r") as file: + for line in file: + if line != "\n": + current_row_count = self._rc_param_view.rc_table.rowCount() + self._rc_param_view.rc_table.insertRow(current_row_count) + param_item = QTableWidgetItem(line.split(":")[0]) + val_item = QTableWidgetItem(line.split(":")[1].strip("\n")) + self._rc_param_view.rc_table.setItem(current_row_count, 0, param_item) + self._rc_param_view.rc_table.setItem(current_row_count, 1, val_item) + + def populate_shortcuts(self): + self._add_shortcut(QKeySequence(QKeySequence.StandardKey.Open).toString(), "Opens the add file menu.") + self._add_shortcut(QKeySequence(QKeySequence.StandardKey.New).toString(), "Add a subplot to the figure.") + self._add_shortcut(QKeySequence(QKeySequence.StandardKey.Find).toString(), "Opens the figure options menu.") + self._add_shortcut("Ctrl+T", "Applies the tight layout format to the figure.") + self._add_shortcut(QKeySequence(QKeySequence.StandardKey.Save).toString(), "Opens the save figure menu.") + self._add_shortcut( + QKeySequence(QKeySequence.StandardKey.SelectStartOfDocument).toString(), + "Resets the figure to the default home view.", + ) + self._add_shortcut("Ctrl+Up", "Moves a subplot up.") + self._add_shortcut("Ctrl+Down", "Moves a subplot down.") + self._add_shortcut(QKeySequence("Alt+h").toString(), "Opens the help/settings menu") + + def _add_shortcut(self, key, val): + self._model.add_shortcut({key: val}) + current_row_count = self._keyshort_view.shortcut_table.rowCount() + self._keyshort_view.shortcut_table.insertRow(current_row_count) + key_item = QTableWidgetItem(key) + val_item = QTableWidgetItem(val) + self._keyshort_view.shortcut_table.setItem(current_row_count, 0, key_item) + self._keyshort_view.shortcut_table.setItem(current_row_count, 1, val_item) + + +class SettingsView(QDialog, View): + def __init__(self, parent: QWidget = None, controller: Controller = None): + super(SettingsView, self).__init__(parent) + self._controller = controller + self._controller.view = self + self.resize(800, 800) + + self._initUI() + + def _initUI(self): + self.setWindowTitle("OptView Settings") + layout = QVBoxLayout() + + # --- Create the tab control framework --- + self.tabs = QTabWidget() + self.tabs.setTabsClosable(False) + + # --- Add the first tab to the view --- + self.tabs.addTab(KeyboardShortuctsView(parent=self, controller=self._controller), "Keyboard Shortcuts") + self.tabs.addTab(MplRcParametersView(parent=self, controller=self._controller), "Matplotlib RC Parameters") + layout.addWidget(self.tabs, 2) + + btnBox = QDialogButtonBox() + btnBox.setStandardButtons(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + btnBox.accepted.connect(self.accept) + btnBox.rejected.connect(self.reject) + layout.addWidget(btnBox) + + # --- Set the layout --- + self.setLayout(layout) + + # --- Show the view --- + self.show() + + +class KeyboardShortuctsView(View): + def __init__(self, parent: QDialog = None, controller: SettingsController = None): + super(KeyboardShortuctsView, self).__init__(parent) + self._controller = controller + self._controller.keyshort_view = self + + self._initUI() + + def _initUI(self): + layout = QVBoxLayout() + self.shortcut_table = QTableWidget(self) + font = QFont() + font.setPointSize(16) + self.shortcut_table.setFont(font) + self.shortcut_table.setShowGrid(False) + self.shortcut_table.verticalHeader().setVisible(False) + self.shortcut_table.setColumnCount(2) + self.shortcut_table.setHorizontalHeaderLabels(["Sequence", "Description"]) + self.shortcut_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + self.shortcut_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + layout.addWidget(self.shortcut_table) + + self.setLayout(layout) + + +class MplRcParametersView(View): + def __init__(self, parent: QDialog = None, controller: SettingsController = None): + super(MplRcParametersView, self).__init__(parent) + + self._controller = controller + self._controller.rc_param_view = self + + self._initUI() + + def _initUI(self): + layout = QVBoxLayout() + self.rc_table = QTableWidget(self) + font = QFont() + font.setPointSize(16) + self.rc_table.setFont(font) + self.rc_table.setShowGrid(False) + self.rc_table.verticalHeader().setVisible(False) + self.rc_table.setColumnCount(2) + self.rc_table.setHorizontalHeaderLabels(["Parameter", "Value"]) + self.rc_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + self.rc_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + layout.addWidget(self.rc_table) + + self.setLayout(layout) diff --git a/pyoptsparse/postprocessing/sub_windows/tab_window.py b/pyoptsparse/postprocessing/sub_windows/tab_window.py new file mode 100644 index 00000000..b269618d --- /dev/null +++ b/pyoptsparse/postprocessing/sub_windows/tab_window.py @@ -0,0 +1,507 @@ +# Standard Python modules +from typing import List + +# External modules +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QKeySequence, QShortcut +from PyQt6.QtWidgets import ( + QAbstractItemView, + QDialog, + QFileDialog, + QHBoxLayout, + QInputDialog, + QLabel, + QListWidgetItem, + QMessageBox, + QTreeWidget, + QVBoxLayout, + QWidget, +) +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg + +# First party modules +from pyoptsparse.postprocessing.baseclasses import Controller, Model, View +from pyoptsparse.postprocessing.data_structures import File +from pyoptsparse.postprocessing.general_widgets import Button, FileTreeWidgetItem, PlotList, PlotListWidget, Switch +from pyoptsparse.postprocessing.sub_windows.configure_plot_window import ConfigureController, ConfigurePlotView +from pyoptsparse.postprocessing.sub_windows.metadata_window import MetadataController, MetadataModel, MetadataView +from pyoptsparse.postprocessing.sub_windows.plotting import PlotModel, PlotView + + +class TabModel(Model): + def __init__(self, file_names: List = []): + """ + The model for the tab view. + + Parameters + ---------- + file_names : List, optional + List of files to be pre-loaded, by default [] + """ + super(TabModel, self).__init__() + self.canvas = None + self.files = [] + self.plots = [] + self.sub_views = [] + self.timer = QTimer() + + if file_names: + self.load_files(file_names) + + def load_files(self, file_names: List): + """ + Loads files into the model. + + Parameters + ---------- + file_names : List + List of file names to be loaded. + """ + curr_file_names = [file.name for file in self.files] + for fp in file_names: + if fp not in curr_file_names: + file = File() + file.load_file(fp) + self.files.append(file) + + def add_plot(self, plot: Model, view: QDialog): + """ + Adds a plot and the corresponding sub view to the model. + + Parameters + ---------- + plot : Model + The plot model being added. + view : PyQt6.QtWidgets.QDialog + The plot configuration sub view being added. + """ + self.plots.append(plot) + self.sub_views.append(view) + self.canvas.draw() + + def remove_plot(self, idx: int): + """ + Removes the plot and sub view at the given index. + + Parameters + ---------- + idx : int + The index of the plot being removed. + + Returns + ------- + PyQt6.QtWidgets.QDialog + The sub view associated with the plot being removed. + """ + # --- Remove the plot object and clear the figure --- + self.plots.pop(idx) + view = self.sub_views.pop(idx) + self.canvas.fig.clf() + + # --- Loop over existing plots and update the axes --- + self.update_axes() + + self.canvas.draw() + + # --- If no plots exist then draw pyOptSparse logo --- + if not self.plots: + self.canvas.addImage() + + # --- Draw the canvas to show updates --- + self.canvas.draw() + + return view + + def reorder(self, mapping): + self.plots[:] = [self.plots[i] for i in mapping] + self.sub_views = [self.sub_views[i] for i in mapping] + + self.update_axes() + + def swap(self, idx1, idx2): + self.plots[idx1], self.plots[idx2] = self.plots[idx2], self.plots[idx1] + self.sub_views[idx1], self.sub_views[idx2] = self.sub_views[idx2], self.sub_views[idx1] + + self.update_axes() + + def update_axes(self): + num_plots = len(self.plots) + self.canvas.fig.clf() + for i, p in enumerate(self.plots): + p.update_axis(self.canvas.fig.add_subplot(int(f"{num_plots}1{i+1}"), label=f"Plot {i}")) + + def draw_canvas(self): + self.canvas.draw() + + +class TabController(Controller): + def __init__(self, root: QWidget, file_names: List = []): + """ + The controller for the tab view. + + Parameters + ---------- + root : PyQt6.QtWidgets.QWidget + The OptView main view + file_names : List, optional + Names of files to be pre-loaded in to the model, + by default [] + """ + super(TabController, self).__init__() + self._root = root + self._model = TabModel(file_names=file_names) + self._sub_views = [] + + def open_files(self): + """ + Opens a file dialog for the user to load files into the model. + """ + # --- Open file dialog and get selected user files --- + file_names, _ = QFileDialog.getOpenFileNames( + self._view, "Open History File", "", "History File (*.hst)", options=QFileDialog.Option.DontUseNativeDialog + ) + + # --- Load files into the model --- + self._model.load_files(file_names) + + self.populate_files() + + def populate_files(self): + self._view.file_tree.clear() + for file in self._model.files: + file_item = FileTreeWidgetItem(self._view.file_tree) + file_item.setFile(file) + file_item.setText(0, file.short_name) + self._view.file_tree.addTopLevelItem(file_item) + + def add_plot(self): + """ + Adds a plot to the tab model. + """ + # --- Get the number of the plot --- + idx = len(self._model.plots) + + try: + # --- Only allow 3 plots per tab --- + if idx > 2: + raise ValueError("Only 3 plots allowed per tab.") + + # --- Clear the plot to prepare for axis update --- + self._model.canvas.fig.clf() + + # --- Update previous plots to reflect new number of axis --- + for i, p in enumerate(self._model.plots): + p.update_axis(self._model.canvas.fig.add_subplot(int(f"{idx+1}1{i+1}"), label=f"Plot {i}")) + + # --- Create a plot object and set its axis --- + plot = PlotModel() + label = f"Plot {idx}" + plot.axis = self._model.canvas.fig.add_subplot(int(f"{idx+1}1{idx+1}"), label=label) + + # --- Create sub view and controller --- + configure_plot_controller = ConfigureController(self._model, plot) + sub_view = ConfigurePlotView(self._view, configure_plot_controller, label) + self._model.add_plot(plot, sub_view) + + # --- Create socket for custom widget --- + item = QListWidgetItem(self._view.plot_list) + + # --- Create custom plot list widget --- + plot_list_widget = PlotListWidget(self._view, self, idx) + + # --- Size the list row to fit custom widget --- + item.setSizeHint(plot_list_widget.sizeHint()) + item.setToolTip("Click and drag to re-order plots.") + + # --- Add the item and custom widget to the list --- + self._view.plot_list.addItem(item) + self._view.plot_list.setItemWidget(item, plot_list_widget) + + self.refresh_plots() + + except ValueError: + # --- Show warning if more than 3 plots are added --- + QMessageBox.warning(self._view, "Subplot Value Warning", "OptView can only handle 3 subplots") + + def remove_plot(self, idx: int): + """ + Removes a plot at the given index from the model. + + Parameters + ---------- + idx : int + The index of the plot to be removed. + """ + # --- Remove the plot from the model --- + sub_view = self._model.remove_plot(idx) + self._view.plot_list.takeItem(idx) + + if sub_view is not None: + sub_view.close() + sub_view.destroy() + + # --- Loop over custom widgets and update the index and plot number --- + for i in range(len(self._model.plots)): + item = self._view.plot_list.item(i) + widget = self._view.plot_list.itemWidget(item) + widget.idx = i + widget.title.setText(f"Plot {i}") + self._model.sub_views[i].setWindowTitle(f"Plot {i}") + + self.refresh_plots() + + def reorder_plots(self): + mapping = [] + for i in range(self._view.plot_list.count()): + item = self._view.plot_list.item(i) + widget = self._view.plot_list.itemWidget(item) + mapping.append(widget.idx) + widget.idx = i + + self._model.reorder(mapping) + self.refresh_plots() + + def configure_view(self, idx: int): + """ + Opens the configuration view for the plot at the given index. + + Parameters + ---------- + idx : int + The index of the plot for which the configuration window + is associated. + """ + self._model.sub_views[idx].show() + + def auto_refresh(self): + """ + Turns on auto refresh mode. When activated, this function + will refresh the history file and the plots every 5 seconds. + """ + switch = self._view.auto_refresh_togg + if switch.isChecked(): + time, ok = QInputDialog.getInt(self._view, "Refresh Time", "Enter refresh interval in seconds:") + if ok: + if time: + time = time * 1000 + else: + time = 5000 + + self._model.timer.start(time) + self._model.timer.timeout.connect(self.refresh) + else: + switch.setChecked(False) + else: + self._model.timer.stop() + + def refresh(self): + """ + Performs a single refresh operation on the history file + and the plots. + """ + for file in self._model.files: + file.refresh() + + for plot_model in self._model.plots: + for y_var in plot_model.y_vars.values(): + y_var.file._set_data(y_var) + + x_var = plot_model.x_var + if x_var is not None: + x_var.file._set_data(x_var) + + self.refresh_plots() + + def refresh_plots(self): + """ + Loops over all the plots in the model re-plots them with the + new data from the refreshed history file. + """ + for p in self._model.plots: + p.plot() + + self._model.canvas.draw() + + def set_model_canvas(self, canvas: FigureCanvasQTAgg): + """ + Sets the canvas for the model. + + Parameters + ---------- + canvas : matplotlib.backends.backend_qt5agg.FigureCanvasQTAgg + The backend matplotlib canvas configured for qt5 + """ + self._model.canvas = canvas + + def meta_view(self): + """ + Creates a meta data controller and spawns the meta data view. + """ + meta_controller = MetadataController(MetadataModel(), self._model) + MetadataView(self._root, meta_controller, "Metadata Viewer") + + def move_plot_up(self): + item = self._view.plot_list.currentItem() + row = self._view.plot_list.currentRow() + if item is not None: + widget = self._view.plot_list.itemWidget(item) + if row > 0: + self._model.swap(row, row - 1) + + # --- Create socket for custom widget --- + new_item = item.clone() + + self._view.plot_list.insertItem(row - 1, new_item) + self._view.plot_list.setItemWidget(new_item, widget) + widget.idx = row - 1 + + self._view.plot_list.takeItem(row + 1) + self._view.plot_list.setCurrentRow(row - 1) + + self.refresh_plots() + + def move_plot_down(self): + item = self._view.plot_list.currentItem() + row = self._view.plot_list.currentRow() + if item is not None: + widget = self._view.plot_list.itemWidget(item) + if row < self._view.plot_list.count() - 1: + self._model.swap(row, row + 1) + + # --- Create socket for custom widget --- + new_item = item.clone() + + self._view.plot_list.insertItem(row + 2, new_item) + self._view.plot_list.setItemWidget(new_item, widget) + widget.idx = row + 1 + + self._view.plot_list.takeItem(row) + self._view.plot_list.setCurrentRow(row + 1) + + self.refresh_plots() + + def mpl_figure_options(self): + self._view.plot_view.toolbar.edit_parameters() + + def mpl_tight_layout(self): + self._view.plot_view.canvas.fig.tight_layout() + self.refresh_plots() + + def mpl_save(self): + self._view.plot_view.toolbar.save_figure() + + def mpl_home(self): + self._view.plot_view.toolbar.home() + + +class TabView(View): + def __init__(self, parent: QWidget = None, controller: TabController = None): + """ + The view for new tabs. + + Parameters + ---------- + parent : PyQt6.QtWidgets.QWidget, optional + The parent view, by default None + controller : Controller, optional + The tab controller, by default None + """ + super(TabView, self).__init__(parent) + self._controller = controller + self._controller.view = self + self._initView() + self._controller.populate_files() + + def _initView(self): + """ + Initializes the tab view. + """ + # --- Create top level layout --- + layout = QVBoxLayout() + + # --- Create plot view and add to layout --- + self.plot_view = PlotView(self) + self._controller.set_model_canvas(self.plot_view.canvas) + layout.addWidget(self.plot_view) + + # --- Create layout underneath the plot --- + bottom_layout = QHBoxLayout() + layout.addLayout(bottom_layout) + + # ============================================================== + # Keyboard Shortcuts + # ============================================================== + self.add_file_action = QShortcut(QKeySequence.StandardKey.Open, self) + self.add_plot_action = QShortcut(QKeySequence(QKeySequence.StandardKey.New), self) + self.figure_options_action = QShortcut(QKeySequence.StandardKey.Find, self) + self.tight_layout_action = QShortcut(QKeySequence("Ctrl+t"), self) + self.save_figure_action = QShortcut(QKeySequence(QKeySequence.StandardKey.Save), self) + self.figure_home_action = QShortcut(QKeySequence(QKeySequence.StandardKey.SelectStartOfDocument), self) + + self.add_file_action.activated.connect(self._controller.open_files) + self.add_plot_action.activated.connect(self._controller.add_plot) + self.figure_options_action.activated.connect(self._controller.mpl_figure_options) + self.tight_layout_action.activated.connect(self._controller.mpl_tight_layout) + self.save_figure_action.activated.connect(self._controller.mpl_save) + self.figure_home_action.activated.connect(self._controller.mpl_home) + + # ============================================================== + # Plot List - Left most column of Sub Layout + # ============================================================== + self.plot_list = PlotList(self, self._controller) + self.plot_list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.plot_list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + bottom_layout.addWidget(self.plot_list, 3) + + # ============================================================== + # File List - Middle column of Sub Layout + # ============================================================== + self.file_tree = QTreeWidget(self) + self.file_tree.setColumnCount(1) + self.file_tree.setHeaderLabels(["File Name"]) + bottom_layout.addWidget(self.file_tree, 1) + + # ============================================================== + # Button Layout - Sub-layout column for buttons + # ============================================================== + # --- Create sublayout for buttons --- + button_layout = QVBoxLayout() + bottom_layout.addLayout(button_layout, 1) + + # --- Add file --- + self.add_file_btn = Button("Add file(s)", self) + self.add_file_btn.clicked.connect(self._controller.open_files) + button_layout.addWidget(self.add_file_btn) + + # --- Add Plot --- + self.plot_btn = Button("Add Plot", self) + self.plot_btn.clicked.connect(self._controller.add_plot) + button_layout.addWidget(self.plot_btn) + + # --- Opt Problem Metadata --- + self.meta_btn = Button("View Metadata", self) + self.meta_btn.clicked.connect(self._controller.meta_view) + button_layout.addWidget(self.meta_btn) + + # --- Manually refresh history file --- + self.refresh_btn = Button("Refresh Files", self) + self.refresh_btn.clicked.connect(self._controller.refresh) + button_layout.addWidget(self.refresh_btn) + + # --- Auto refresh file Toggle --- + # Need to add a sub layout for the toggle switch + refresh_layout = QHBoxLayout() + button_layout.addLayout(refresh_layout) + + # Create and add the switch to the layout + self.auto_refresh_togg = Switch(self) + self.auto_refresh_togg.clicked.connect(self._controller.auto_refresh) + self.auto_refresh_lbl = QLabel("Auto Refresh Files") + self.auto_refresh_lbl.setBuddy(self.auto_refresh_togg) # This attaches the label to the switch widget + + # Still need to add and align each widget even though they are set as buddies + refresh_layout.addWidget(self.auto_refresh_lbl) + refresh_layout.addWidget(self.auto_refresh_togg, alignment=Qt.AlignmentFlag.AlignRight) + + # --- Set the main layout --- + self.setLayout(layout) diff --git a/pyoptsparse/postprocessing/utils.py b/pyoptsparse/postprocessing/utils.py new file mode 100644 index 00000000..c457c068 --- /dev/null +++ b/pyoptsparse/postprocessing/utils.py @@ -0,0 +1,7 @@ +# Standard Python modules +import os + +# External modules +import pkg_resources + +ASSET_PATH = pkg_resources.resource_filename("pyoptsparse", os.path.join("postprocessing", "assets")) diff --git a/pyoptsparse/postprocessing/view_saved_figure.py b/pyoptsparse/postprocessing/view_saved_figure.py deleted file mode 100644 index a62c4e91..00000000 --- a/pyoptsparse/postprocessing/view_saved_figure.py +++ /dev/null @@ -1,35 +0,0 @@ -# External modules -import Tkinter as Tk -import dill -import matplotlib -from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2TkAgg - -matplotlib.use("TkAgg") - -root = Tk.Tk() - -# Load figure from disk and display -fig = dill.load(open("saved_figure.pickle", "rb")) - -""" -The above code loads in the figure that was saved in OptView. -fig is a matplotlib object that can be altered and saved like any -regular figure. -The code at the bottom renders the image for immediate display. -Add your specific plot formatting code as necessary below this comment -string but before the bottom code. -""" - -# Add customization code below -ax = fig.axes[0] -ax.set_title("Example title") -ax.set_ylabel("Example y-axis") - - -# Display the altered figure -canvas = FigureCanvasTkAgg(fig, master=root) -canvas.get_tk_widget().pack(side=Tk.TOP, fill=Tk.BOTH, expand=1) -toolbar = NavigationToolbar2TkAgg(canvas, root) -toolbar.update() -canvas.show() -Tk.mainloop() diff --git a/setup.py b/setup.py index d7860e7d..50564b65 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,12 @@ +# Standard Python modules import os import re import shutil -import setuptools import subprocess +# External modules +import setuptools + def run_meson_build(): # check if ipopt dir is specified @@ -108,13 +111,9 @@ def copy_shared_libraries(): "mdolab-baseclasses>=1.3.1", ], extras_require={ - "optview": [ - "dash", - "plotly", - "matplotlib", - ], + "testing": ["testflo>=1.4.5", "parametrized"], + "optview": ["PyQt6>=6.4.0", "matplotlib>=3.5.1"], "docs": docs_require, - "testing": ["testflo>=1.4.5", "parameterized"], }, classifiers=[ "Development Status :: 5 - Production/Stable", @@ -138,7 +137,6 @@ def copy_shared_libraries(): entry_points={ "gui_scripts": [ "optview = pyoptsparse.postprocessing.OptView:main", - "optview_dash = pyoptsparse.postprocessing.OptView_dash:main", ] }, )