Source code for schrodinger.trajectory.trajectory_gui_dir.plots

"""
File containing plot related code used in the Trajectory Plots GUI
"""
import csv
import uuid
from enum import Enum
from enum import auto

import openpyxl

from schrodinger.models import mappers
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.PyQt5 import QtChart
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt import basewidgets
from schrodinger.ui.qt import filedialog
from schrodinger.utils import csv_unicode

from . import advanced_plot_ui
from . import energy_plot_ui
from . import collapsible_plot_ui
from . import shortcut_ui
from . import traj_plot_models
from . import energy_plots
from . import plots as tplots

# Plot Constants
MAX_AXIS_TICKS = 5
MIN_AXIS_SPAN = 0.1
MAX_SHORTCUTS_IN_ROW = 3
IMAGE_WIDTH = 20000
SERIES_WIDTH = 1

# Plot context menu actions
SHOW = "Show"
HIDE = "Hide"
SAVE_IMG = "Save image..."
EXPORT_CSV = "Export as CSV..."
EXPORT_EXCEL = "Export to Excel..."
DELETE = "Delete"
VIEW_PLOT = "View Plot..."

# Colors
RMSF_COLOR = QtGui.QColor.fromRgb(158, 31, 222)
TEMP_COLOR = QtGui.QColor.fromRgb(207, 105, 31)
HELIX_COLOR = QtGui.QColor.fromRgb(253, 236, 232)
STRAND_COLOR = QtGui.QColor.fromRgb(229, 246, 250)

# Series Names
B_FACTOR_SERIES = 'b_factor_series'
SS_HELIX_SERIES = 'secondary_structure_helix_series'
SS_STRAND_SERIES = 'secondary_structure_strand_series'


#############################
# ENUMS
#############################
[docs]class TrajectoryPlotType(Enum): """ Enum of plot types to generate """ MEASUREMENT_WORKSPACE = auto() MEASUREMENT_ADD = auto() MEASUREMENT_PLANAR_ANGLE = auto() MEASUREMENT_CENTROID = auto() INTERACTIONS_ALL = auto() INTERACTIONS_HYDROGEN_BONDS = auto() INTERACTIONS_HALOGEN_BONDS = auto() INTERACTIONS_SALT_BRIDGE = auto() INTERACTIONS_PI_PI = auto() INTERACTIONS_CAT_PI = auto() DESCRIPTORS_RMSD = auto() DESCRIPTORS_ATOM_RMSF = auto() DESCRIPTORS_RES_RMSF = auto() DESCRIPTORS_RADIUS_GYRATION = auto() DESCRIPTORS_PSA = auto() DESCRIPTORS_SASA = auto() DESCRIPTORS_MOLECULAR_SA = auto() ENERGY_ALL_GROUPED = auto() ENERGY_ALL_INDIVIDUAL = auto() ENERGY_INDIVIDUAL_MOLECULES = auto() ENERGY_CUSTOM_SUBSTRUCTURE_SETS = auto() ENERGY_CUSTOM_ASL_SETS = auto()
ENERGY_PLOT_TYPES = { TrajectoryPlotType.ENERGY_ALL_GROUPED, TrajectoryPlotType.ENERGY_ALL_INDIVIDUAL, TrajectoryPlotType.ENERGY_INDIVIDUAL_MOLECULES, TrajectoryPlotType.ENERGY_CUSTOM_SUBSTRUCTURE_SETS, TrajectoryPlotType.ENERGY_CUSTOM_ASL_SETS } PlotDataType = Enum('PlotDataType', ('RMSF', 'TRAJECTORY', 'ENERGY')) ############################# # Plot Formatting Functions #############################
[docs]def handle_chart_legend(chart, is_multiseries_interactions): """ Sets the chart legend depending on the type of the chart :param chart: Chart containing legend :type chart: QtChart.QChart :param is_multiseries_interactions: is this a multiseries interaction plot :type is_multiseries_interactions: bool """ legend = chart.legend() if is_multiseries_interactions: legend.setShowToolTips(True) legend.setAlignment(Qt.AlignBottom) else: legend.hide()
[docs]def set_series_width(series, width): """ Sets the pen width of the series :param series: Series to check :type series: QLineSeries :param width: Width to set :type width: int """ pen = series.pen() pen.setWidth(width) series.setPen(pen)
[docs]def slim_chart(chart): """ Removes as much unnecessary padding from a chart as possible :param chart: The chart to slim :type chart: QtChart.QChart """ chart.layout().setContentsMargins(0, 0, 0, 0) chart.setWindowFrameMargins(0, 0, 0, 0) chart.setBackgroundRoundness(0)
[docs]def format_axes(chart, task): """ Formats axes tick numbers and spacing depending on task :param chart: Chart to format axes for :type chart: QtChart.QChart :param task: Finished trajectory task :type task: TrajectoryAnalysisSubprocTask or TrajectoryAnalysisTask """ chart.createDefaultAxes() axes = chart.axes() mode = task.input.analysis_mode for axis in axes: if axis.orientation() == Qt.Orientation.Horizontal: if mode == traj_plot_models.AnalysisMode.AtomRMSF: unit_lbl = 'Atom Index' axis.setLabelFormat('%i') else: unit_lbl = 'Time (ns)' else: unit_lbl = traj_plot_models.ANALYSIS_MODE_MAP[ task.input.analysis_mode].unit if unit_lbl in [ traj_plot_models.INSTANCES, traj_plot_models.DIHEDRAL_DEGREES ]: axis.applyNiceNumbers() axis.setLabelFormat('%i') interstitial_values = max(task.output.result) - min( task.output.result) + 1 num_ticks = min(MAX_AXIS_TICKS, interstitial_values) axis.setTickCount(num_ticks) else: axis.setLabelFormat('%.1f') _generateAxisSpecifications(task.output.result, axis) if mode == traj_plot_models.AnalysisMode.Torsion: axis.setMin(-180) axis.setMax(180) elif mode in traj_plot_models.RMSF_PLOTS: axis.setMin(0) axis.setTitleText(unit_lbl)
def _is_series_ss(series): """ Returns whether series is a series representing a Secondary Structure :param series: Series to check :type series: QLineSeries """ return type(series) in [ SecondaryStructureStrandSeries, SecondaryStructureHelixSeries ] def _generateAxisSpecifications(data, axis): """ Sets axis values based on provided data :param data: Data for series on axis :type data: list :param axis: Axis to set :type axis: QValueAxis """ axis_values = set(round(val, 1) for val in data) # set min axis_min = min(axis_values) if axis.min() < axis_min: axis_min -= MIN_AXIS_SPAN axis.setMin(axis_min) # set max axis_max = max(axis_values) if axis.max() > axis_max: axis_max += MIN_AXIS_SPAN axis.setMax(axis_max) # set ticks num_ticks = min(MAX_AXIS_TICKS, (axis_max - axis_min + MIN_AXIS_SPAN) / MIN_AXIS_SPAN) axis.setTickCount(num_ticks)
[docs]def format_residue_plot_axes(chart, task): """ Formats the axes and colors series on a residue plot. :param chart: Chart to format axes for :type chart: QtChart.QChart :param task: Finished trajectory task :type task: TrajectoryAnalysisSubprocTask or TrajectoryAnalysisTask """ axes_info = { BFactorAxis: (BFactorSeries, 'B Factor', TEMP_COLOR), OutputAxis: (OutputSeries, traj_plot_models.ANGSTROMS_RMSF, RMSF_COLOR), } for axis in chart.axes(): axis.setGridLineVisible(False) if axis.orientation() == Qt.Orientation.Horizontal: unit_lbl = 'Residue Index' axis.setLabelFormat('%i') axis.setMin(0) axis.setMax(len(task.output.result) - 1) elif type(axis) in axes_info: series_type, title, color, = axes_info[type(axis)] hex_color = color.name() for chart_series in chart.series(): if type(chart_series) == series_type: series = chart_series set_series_width(series, SERIES_WIDTH) vals = [(pt.x(), pt.y()) for pt in series.pointsVector()] unit_lbl = f'<span style="color: {hex_color};">{title}</span>' series.setColor(color) axis.setLinePenColor(color) _generateAxisSpecifications([y for _, y in vals], axis) axis.setLabelFormat('%.1f') else: # Otherwise, we have a secondary structure series for series in chart.series(): series_type = type(series) if series_type == SecondaryStructureHelixSeries: color = HELIX_COLOR elif series_type == SecondaryStructureStrandSeries: color = STRAND_COLOR else: continue series.setColor(color) series.setBorderColor(color) axis.setTitleText(unit_lbl)
############################# # TRADITIONAL PLOTS #############################
[docs]class AbstractTrajectoryChartView(QtChart.QChartView): """ Subclassing of QCharts for plotting trajectory analysis data. Note that we cannot simply use QtChart.QLineSeries.clicked for this because it does not appear to trigger on OS X. :ivar displayAsl: Display the asl for the corresponding entry id Signal. args are (asl, entry_id) :type displayAsl: `QtCore.pyqtSignal(str, int)` :ivar displayFrameAndAsl: Change frame and show ASL for given entry id Signal args are (asl, entry_id, frame_number) :type displayFrameAndAsl: `QtCore.pyqtSignal(str, int, int)` """ displayAsl = QtCore.pyqtSignal(str, int) displayFrameAndAsl = QtCore.pyqtSignal(str, int, int)
[docs] def __init__(self, *args, task=None, trj=None, eid=None, settings_hash=None, **kwargs): """ :param task: Finished trajectory task :type task: tasks.AbstractTask :param trj: Trajectory associated with this plot :type trj: playertoolbar.EntryTrajectory :param eid: Entry ID for this plot :type eid: int :param settings_hash: Optional additional settings string to further identify a plot as unique beyond its analysis mode and fit ASL. :type settings_hash: str """ super().__init__(*args, **kwargs) self.task = task if self.task is not None: self.fit_asl = self.task.output.fit_asl self.settings_hash = settings_hash self.trj = trj self.eid = eid self.time_to_frame_map = None self.series_map = {} if self.trj: trj = self.trj # Traj Player widgets use 1-based indexing so we so here as well. self.time_to_frame_map = { fr.time / 1000: idx for idx, fr in enumerate(trj, start=1) } self._mouse_press_pos = None
[docs] def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self._mouse_press_pos = event.pos() super().mousePressEvent(event)
def _getNearestFrameForTime(self, time): """ Given a time value, return the frame nearest to that time. :param time: Time to get the nearest frame for :type time: float :return: 1-based frame index closest to the specified time or None if time is out of range. :rtype: int or None """ all_times = list(self.time_to_frame_map.keys()) if time < min(all_times) or time > max(all_times): return None nearest_key = None for key in self.time_to_frame_map: if nearest_key is None or abs(time - key) < abs(time - nearest_key): nearest_key = key return self.time_to_frame_map[nearest_key]
[docs] def getPlotType(self): """ Returns what type of plot this class uses. Subclasses must override. """ raise NotImplementedError
[docs] def getDataForExport(self): """ Return a list of row data to export to CSV or Excel. Subclasses must override. :return: Data to be exported :rtype: list(list) """ raise NotImplementedError
[docs] def exportToCSV(self): """ Export plot data to a CSV file """ fpath = filedialog.get_save_file_name( parent=self, caption="Save as CSV", filter="Comma-separated value (*.csv)") if not fpath: return rows = self.getDataForExport() with csv_unicode.writer_open(fpath) as fh: writer = csv.writer(fh) for row in rows: writer.writerow(row)
[docs] def exportToExcel(self): """ Export data to an .xls file """ fpath = filedialog.get_save_file_name(parent=self, caption="Save as Excel Workbook", filter='Excel (*.xls)') if not fpath: return wb = openpyxl.Workbook() ws = wb.active for row in self.getDataForExport(): ws.append(row) wb.save(fpath)
[docs] def saveImage(self): """ Save a .png file of the plot """ fpath = filedialog.get_save_file_name(parent=self, caption="Save image", filter="PNG (*.png)") if not fpath: return aspect_ratio = self.height() / self.width() # make sure image has high enough resolution for publication use. pixmap = QtGui.QPixmap(IMAGE_WIDTH, int(IMAGE_WIDTH * aspect_ratio)) pixmap.fill(Qt.transparent) painter = QtGui.QPainter(pixmap) self.render(painter) pixmap.save(fpath) painter.end()
[docs]class TrajectoryAnalysisChartView(AbstractTrajectoryChartView): """ Chart View class used for graphs with an x-axis of frames """
[docs] def mouseReleaseEvent(self, event): """ Find the frame that the user's left click selected. Display selection used in the task input. """ if event.button() == Qt.LeftButton: release_pos = event.pos() if release_pos == self._mouse_press_pos: # User has not dragged the mouse. value = self.chart().mapToValue(release_pos) if self.time_to_frame_map: time = value.x() frame_idx = self._getNearestFrameForTime(time) if frame_idx is not None and self.task is not None: self.displayFrameAndAsl.emit(self.fit_asl, self.eid, frame_idx) super().mouseReleaseEvent(event)
[docs] def getDataForExport(self): """ Return a list of row data to export to CSV or Excel. :return: Data to be exported :rtype: list(list) """ rows = [] header_row = ["Frame", "Time (ns)"] series_titles = list(self.series_map.keys()) header_row.extend(series_titles) rows.append(header_row) for time, idx in self.time_to_frame_map.items(): row = [idx, time] for series in series_titles: row.append(self.series_map[series][time]) rows.append(row) return rows
[docs] def getPlotType(self): return PlotDataType.TRAJECTORY
[docs]class RmsfPlotChartView(AbstractTrajectoryChartView): """ Chart View class for time series data. These contain callouts describing which point the user is hovering over. """
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.initializeCallouts() self._callout = None self._series = None self.id = None
[docs] def initializeCallouts(self): """ Initializes the plot to accept events """ chart = self.chart() chart.setAcceptHoverEvents(True) self.scene().addItem(chart) self.setMouseTracking(True)
[docs] def mouseReleaseEvent(self, event): """ Fire a signal to show ASL of selection on left click """ if event.button() == Qt.LeftButton: release_pos = event.pos() if release_pos == self._mouse_press_pos: value = self.chart().mapToValue(release_pos) data_x = round(value.x()) if self.task.input.analysis_mode == traj_plot_models.AnalysisMode.AtomRMSF: asl = f'atom.n {self.task.output.atom_numbers[data_x]}' elif self.task.input.analysis_mode == traj_plot_models.AnalysisMode.ResRMSF: res_lbl = self.task.output.residue_info.residue_names[ data_x] atoms = self.task.output.residue_info.residue_atoms[res_lbl] asl = f"atom.n {','.join(map(str, atoms))}" self.displayAsl.emit(asl, self.eid) super().mouseReleaseEvent(event)
[docs] def enableSeriesTracking(self): chart = self.chart() series = chart.series() for line in series: if not _is_series_ss(series): line.hovered.connect(self.onHover) # Explicitly save a reference to the series so it doesn't get destroyed (PANEL-18838) self._series = self.chart().series()
[docs] def generateCalloutText(self, pos): rmsf_info = temp_info = '' callout_text_list = [] data_x = round(pos.x()) for series in self.chart().series(): series_type = type(series) if not _is_series_ss(series): data_point = series.at(data_x) if series_type == OutputSeries: rmsf_info = f'RMSF = {data_point.y():.2f} Å' if series_type == BFactorSeries and series.isVisible(): temp_info = f'B Factor = {round(data_point.y(), 1)}' if self.task.input.analysis_mode == traj_plot_models.AnalysisMode.AtomRMSF: atom_info = self.task.input.atom_labels[data_x] callout_text_list = [atom_info, rmsf_info] elif self.task.input.analysis_mode == traj_plot_models.AnalysisMode.ResRMSF: res_info = self.task.output.residue_info.residue_names[data_x] callout_text_list = [res_info, rmsf_info] if temp_info: callout_text_list.insert(1, temp_info) return callout_text_list
[docs] def onHover(self, pos, enter): series = self.sender() if self._callout is None: text_list = self.generateCalloutText(pos) callout = Callout(self.chart(), series, pos, text_list) callout.setZValue(1) self._callout = callout if enter: self.scene().addItem(self._callout) else: self.scene().removeItem(self._callout) self._callout = None
[docs] def getPlotType(self): return PlotDataType.RMSF
[docs] def getDataForExport(self): """ Return a list of row data to export to CSV or Excel. Omits the time to frame map for RMSF plots. :return: Data to be exported :rtype: list(list) """ rows = [] index_header = 'Index' plot_types = ('Atom', 'Residue') for plot_type in plot_types: if plot_type in self.task.input.analysis_mode.name: index_header = f'{plot_type} Index' header_row = [index_header] series_titles = self.series_map.keys() header_row.extend(series_titles) rows.append(header_row) for series in series_titles: for idx, (key, value) in enumerate(self.series_map[series].items()): if idx >= len(rows) - 1: rows.append([key]) rows[idx + 1].append(value) return rows
[docs]class EnergyPlotChartView(AbstractTrajectoryChartView): """ Chart View class for energy matrix data. The plot data will be populated by the EnergyPlotPanel. """
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.energies = None self.chart_title = '' self.set_names = self.task.input.set_names
[docs] def getPlotType(self): return PlotDataType.ENERGY
[docs] def enableSeriesTracking(self): pass
[docs] def setPlotData(self, energies): """ Set self.energies array to the given data, and re-draw the chart. """ self.energies = energies chart = self.chart() chart.setTitle(self.chart_title) chart.removeAllSeries() series = tplots.OutputSeries() if not chart.axes(): # Create left/horizontal axis self.x_axis = QtChart.QValueAxis() self.x_axis.setTitleText('Time (ns)') chart.addAxis(self.x_axis, Qt.AlignBottom) series.attachAxis(self.x_axis) self.y_axis = tplots.OutputAxis() self.y_axis.setLabelFormat('%.0f') self.y_axis.setTitleText('Energy (kCal/mol)') chart.addAxis(self.y_axis, Qt.AlignLeft) series.attachAxis(self.y_axis) if self.energies is None: # No sets or terms selected return # Add data series to the plot: frame_times_ns = [time / 1000 for time in self.frame_times] for x_time, y_energy in zip(frame_times_ns, self.energies): series.append(x_time, y_energy) _generateAxisSpecifications(self.energies, self.y_axis) self.x_axis.setMin(min(frame_times_ns)) self.x_axis.setMax(max(frame_times_ns)) chart.addSeries(series)
[docs] def getDataForExport(self): """ Return a list of row data to export to CSV or Excel. :return: Data to be exported :rtype: list(list) """ rows = [] header_row = ["Frame", "Time (ns)", "Energy"] rows.append(header_row) for idx, (time, ene) in enumerate(zip(self.energies, self.frame_times)): row = [idx, time, ene] rows.append(row) return rows
[docs]class Callout(QtWidgets.QGraphicsItem): """ A callout is a rounded rectangle that displays values for a point on a QChart """
[docs] def __init__(self, chart, series, pos, text_list): self.font = QtGui.QFont() self.chart = chart self.series = series self.pos = pos self.text_list = text_list self.bounding_rect = None super().__init__()
[docs] def paint(self, painter, option, widget): rect = self.boundingRect() light_blue = QtGui.QColor(220, 220, 255) painter.setBrush(light_blue) painter.drawRoundedRect(rect, 5, 5) text_rect = rect.adjusted(5, 5, 5, 5) text = '\n'.join(self.text_list) painter.drawText(text_rect, Qt.AlignLeft, text)
[docs] def generateBoundingRect(self): """ Creates a bounding rect based on text length/height and chart position """ # Generate metrics for callout text fm = QtGui.QFontMetrics(self.font) buffer = 10 text_width = max(*[fm.width(text) for text in self.text_list], 30) + buffer text_height = max(fm.height() * len(self.text_list), 30) + buffer # Create rectangle, flipping orientation if at risk of escaping chart pt = self.chart.mapToPosition(self.pos, self.series) x0, y0 = pt.x(), pt.y() x1 = x0 - text_width if x1 < 0: x1 = x0 + text_width y1 = y0 - text_height if y1 < 0: y1 = y0 + text_height pt0 = QtCore.QPointF(min(x0, x1), min(y0, y1)) pt1 = QtCore.QPointF(max(x0, x1), max(y0, y1)) rect = QtCore.QRectF(pt0, pt1) return rect
[docs] def boundingRect(self): if self.bounding_rect is None: self.bounding_rect = self.generateBoundingRect() return self.bounding_rect
[docs]class CollapsiblePlot(QtWidgets.QWidget): """ This class defines a collapsible plot. The widget has an area for title text, a 'collapse' button and a 'close' button. :ivar widgetClosed: Signal emitted when the widget is closed. Emits a float containing the widget's id and whether it is an interaction plot. :type widgetClosed: `QtCore.pyqtSignal(str, bool)` """ widgetClosed = QtCore.pyqtSignal(str, bool)
[docs] def __init__(self, parent=None, parent_layout=None, system_title='', plot_title='', widget=None, cms_fpath=None, is_interactions=False, tooltip=None): """ :param system_title: System title for the widget :type system_title: str :param plot_title: Title to set for this title bar :type plot_title: str :param parent_layout: Parent layout this widget is contained in :type parent_layout: `QtWidgets.QLayout` :param widget: Widget to set in the collapsible area :type widget: `QtWidgets.QWidget` :param cms_fpath: Source CMS file path for this system :type cms_fpath: str :param is_interactions: Whether plot is an interactions plot :type is_interactions: bool :param tooltip: Optional tooltip for the title :type tooltip: str """ super().__init__(parent=parent) self.ui = collapsible_plot_ui.Ui_Form() self.ui.setupUi(self) self.ui.collapse_btn.clicked.connect(self.onCollapseButtonClicked) self.ui.close_btn.clicked.connect(self.close) self.ui.system_title_label.setText(system_title) self.ui.plot_title_le.setText(plot_title) if len(plot_title) >= 45: if tooltip is None: tooltip = plot_title else: tooltip = plot_title + ': ' + tooltip plot_title = plot_title[:42] + '...' self.eid = None self.ui.plot_title_le.setText(plot_title) if tooltip: tooltip += '<br><i>Double-click to edit</i>' self.ui.plot_title_le.setToolTip(tooltip) self.widget = widget self._parent_layout = parent_layout self.system_title = system_title self.cms_fpath = cms_fpath self.analysis_modes = set() self.fit_asl = None self.settings_hash = None self.id = str(uuid.uuid4()) self.is_interaction_plot = is_interactions if widget is not None: self.setWidget(widget)
[docs] def setWidget(self, widget): """ Set the widget in the collapsible area to the specified widget. :param widget: Widget to set in the collapsible area. :type widget: `QtWidget.QWidget` """ self.fit_asl = widget.fit_asl self.settings_hash = widget.settings_hash if self.widget is not None: if self.widget == widget: return else: self.ui.widget_layout.removeWidget(self.widget) self.widget.deleteLater() self.widget = widget self.widget.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) self.eid = self.widget.eid self.ui.widget_layout.addWidget(self.widget) self.widget.setVisible(True)
[docs] def setPlotToolTip(self, tooltip): self.ui.plot_title_le.setToolTip(tooltip)
[docs] def setPlotTitle(self, plot_title): self.ui.plot_title_le.setText(plot_title)
[docs] def onCollapseButtonClicked(self): """ Collapse or expand the widget depending on its current state. """ already_visible = self.widget.isVisibleTo(self) self.widget.setVisible(not already_visible)
[docs] def close(self): """ Close and remove this widget. """ if self._parent_layout is not None: self.widgetClosed.emit(self.id, self.is_interaction_plot) self._parent_layout.removeWidget(self) self.deleteLater() super().close()
[docs] def getPlotType(self): return self.widget.getPlotType()
[docs] def getDataForExport(self): """ Obtain the widget map data for export. """ return self.widget.getDataForExport()
[docs] def mousePressEvent(self, event): if event.button() == Qt.RightButton: self._showContextMenu() super().mousePressEvent(event)
def _showContextMenu(self): menu = QtWidgets.QMenu(self) if self.widget.isVisibleTo(self): menu.addAction(HIDE) else: menu.addAction(SHOW) menu.addSeparator() menu.addAction(SAVE_IMG) menu.addAction(EXPORT_CSV) menu.addAction(EXPORT_EXCEL) menu.addSeparator() menu.addAction(DELETE) res = menu.exec_(QtGui.QCursor.pos()) if not res: return res_txt = res.text() if res_txt in [SHOW, HIDE]: self.onCollapseButtonClicked() elif res_txt == DELETE: self.close() elif res_txt == EXPORT_CSV: self.widget.exportToCSV() elif res_txt == EXPORT_EXCEL: self.widget.exportToExcel() elif res_txt == SAVE_IMG: self.widget.saveImage()
############################# # ADVANCED PLOTS AND SHORTCUTS #############################
[docs]class RmsfPlotPanel(basewidgets.Panel): """ Advanced plots are for time-series data (e.x. RMSF) :cvar closeRequested: Signal emitted when the widget is closed. Emits a str containing the widget's id :type closeRequested: `QtCore.pyqtSignal(str)` """ ui_module = advanced_plot_ui model_class = traj_plot_models.RmsfPlotModel closeRequested = QtCore.pyqtSignal(str)
[docs] def __init__(self, plot, mode, parent=None): self.plot = plot self.chart = plot.chart() self.mode = mode self.id = plot.id super().__init__(parent)
[docs] def initSetUp(self): super().initSetUp() residue_mode = self.mode is traj_plot_models.AnalysisMode.ResRMSF self.ui.residue_info_wdg.setVisible(residue_mode) self.ui.residue_options_wdg.setVisible(residue_mode) self.ui.plot_layout.addWidget(self.plot) self.ui.options_link.clicked.connect(self._onOptionsToggle) self.ui.close_btn.clicked.connect(self.close)
[docs] def defineMappings(self): M = self.model_class ui = self.ui b_factor_trg = mappers.TargetSpec(ui.pdb_b_factor_cb, slot=self._onBFactorToggle) ss_trg = mappers.TargetSpec(ui.secondary_st_color_cb, slot=self._onSecondaryStructureToggle) return [ (ss_trg, M.secondary_structure_colors), (b_factor_trg, M.b_factor_plot), ] # yapf: disable
def _onBFactorToggle(self): visible = self.model.b_factor_plot if self.mode is traj_plot_models.AnalysisMode.ResRMSF: for series in self.chart.series(): if type(series) == BFactorSeries: series.setVisible(visible) for axis in self.chart.axes(): if type(axis) == BFactorAxis: axis.setVisible(visible) def _onSecondaryStructureToggle(self): visible = self.model.secondary_structure_colors if self.mode is traj_plot_models.AnalysisMode.ResRMSF: for series in self.chart.series(): if _is_series_ss(series): series.setVisible(visible) def _onOptionsToggle(self): visible = not self.ui.residue_options_wdg.isVisible() self.ui.residue_options_wdg.setVisible(visible)
[docs] def mousePressEvent(self, event): if event.button() == Qt.RightButton: self._showContextMenu() super().mousePressEvent(event)
def _showContextMenu(self): menu = QtWidgets.QMenu(self) menu.addAction(SAVE_IMG) menu.addAction(EXPORT_CSV) menu.addAction(EXPORT_EXCEL) menu.addSeparator() menu.addAction(DELETE) res = menu.exec_(QtGui.QCursor.pos()) if not res: return res_txt = res.text() if res_txt == DELETE: self.closeRequested.emit(self.id) elif res_txt == EXPORT_CSV: self.plot.exportToCSV() elif res_txt == EXPORT_EXCEL: self.plot.exportToExcel() elif res_txt == SAVE_IMG: self.plot.saveImage()
[docs]class AdvancedPlotShortcut(basewidgets.BaseWidget): """ Shortcut icon that opens an advanced plots (RMSF and Energy plots). :cvar widgetClosed: Signal emitted when the widget is closed. Emits a str containing the widget's id :type widgetClosed: `QtCore.pyqtSignal(str)` """ ui_module = shortcut_ui widgetClosed = QtCore.pyqtSignal(str)
[docs] def __init__(self, plot, shortcut_title='', window_title='', parent=None): super().__init__(parent) self.plot = plot self.plot.setWindowTitle(window_title) self.plot.closeRequested.connect(self._closeEvent) icon = QtGui.QPixmap(":/trajectory_gui_dir/icons/adv_plot.png") self.ui.icon_lbl.setPixmap(icon) self.ui.shortcut_lbl.setText(shortcut_title)
[docs] def mousePressEvent(self, event): super().mousePressEvent(event) if event.button() == QtCore.Qt.LeftButton: self.plot.show() self.plot.raise_() if event.button() == QtCore.Qt.RightButton: self._showContextMenu()
def _showContextMenu(self): menu = QtWidgets.QMenu(self) menu.addAction(VIEW_PLOT) menu.addSeparator() menu.addAction(DELETE) res = menu.exec_(QtGui.QCursor.pos()) if not res: return res_txt = res.text() if res_txt == VIEW_PLOT: self.plot.show() self.plot.raise_() elif res_txt == DELETE: self.deleteShortcut()
[docs] def deleteShortcut(self): """ Remove this shortcut, and the plot associated with it. """ self._closeEvent(self.plot.id)
def _closeEvent(self, plot_id): self.setVisible(False) self.widgetClosed.emit(plot_id) self.plot.close() self.close()
[docs]class ShortcutRow(basewidgets.BaseWidget): """ This class represents a row of advanced plot shortcuts """
[docs] def initLayOut(self): super().initLayOut() spacer = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.row_layout = QtWidgets.QHBoxLayout() self.row_layout.addItem(spacer) self.main_layout.addLayout(self.row_layout)
[docs] def hasSpace(self): """ Returns whether the shortcut row has space for another widget """ return self.widgetCount() < MAX_SHORTCUTS_IN_ROW
[docs] def addWidget(self, wdg): self.row_layout.insertWidget(self.widgetCount(), wdg)
[docs] def widgetCount(self): return self.row_layout.count() - 1
############################# # Custom Series and Axes #############################
[docs]class OutputAxis(QtChart.QValueAxis): pass
[docs]class BFactorAxis(QtChart.QValueAxis): pass
[docs]class SecondaryStructureAxis(QtChart.QValueAxis): pass
[docs]class OutputSeries(QtChart.QLineSeries): pass
[docs]class BFactorSeries(QtChart.QLineSeries): pass
[docs]class SecondaryStructureHelixSeries(QtChart.QAreaSeries): pass
[docs]class SecondaryStructureStrandSeries(QtChart.QAreaSeries): pass
[docs]class EnergyPlotPanel(basewidgets.Panel): """ Plot for energy analysis. :cvar closeRequested: Signal emitted when the widget is closed. Emits a str containing the widget's id :type closeRequested: `QtCore.pyqtSignal(str)` """ ui_module = energy_plot_ui model_class = traj_plot_models.EnergyPlotModel closeRequested = QtCore.pyqtSignal(str)
[docs] def __init__(self, plot_view, parent=None): self.plot_view = plot_view self.id = plot_view.id self.chart = plot_view.chart() super().__init__(parent)
[docs] def initSetUp(self): super().initSetUp() self.ui.close_btn.clicked.connect(self.close) self.ui.plot_layout.addWidget(self.plot_view) hheader = self.ui.sets_table.view.horizontalHeader() hheader.setStretchLastSection(True) hheader.hide() # Export options not implemented; see PANEL-20335 self.ui.options_link.setVisible(False) self.resize(self.width(), 800) # make taller
[docs] def initFinalize(self): super().initFinalize() # Populate the sets PLPTableWidget with sets from our model: sets = [] for i, name in enumerate(self.plot_view.set_names): row = traj_plot_models.SetRow() row.name = name sets.append(row) self.model.sets = sets spec = self.ui.sets_table.makeAutoSpec(self.model.sets) self.ui.sets_table.setSpec(spec) self.ui.sets_table.setPLP(self.model.sets) # By default select all sets: self.model.selected_sets = [s for s in sets]
[docs] def defineMappings(self): M = self.model_class ui = self.ui return [ (ui.sets_table, M.sets), (ui.sets_table.selection_target, M.selected_sets), (ui.exclude_self_terms_cb, M.exclude_self_terms), (ui.coulomb_cb, M.coulomb), (ui.van_der_waals_cb, M.van_der_waals), (ui.far_exclusion_cb, M.far_exclusion), (ui.bond_cb, M.bond), (ui.angle_cb, M.angle), (ui.dihedral_cb, M.dihedral), ] # yapf: disable
[docs] def getSignalsAndSlots(self, model): return [ (model.selected_setsChanged, self.updatePlotValues), (model.exclude_self_termsChanged, self.updatePlotValues), (model.coulombChanged, self.updatePlotValues), (model.van_der_waalsChanged, self.updatePlotValues), (model.far_exclusionChanged, self.updatePlotValues), (model.bondChanged, self.updatePlotValues), (model.angleChanged, self.updatePlotValues), (model.dihedralChanged, self.updatePlotValues), ] # yapf: disable
[docs] def updatePlotValues(self): """ Slot for updating the chart based on current UI selection. """ m = self.model term_name_map = { 'Coulomb': m.coulomb, 'van der Waals': m.van_der_waals, 'Far Exclusion': m.far_exclusion, 'Bond': m.bond, 'Angle': m.angle, 'Dihedral': m.dihedral, } terms_used = [name for name, param in term_name_map.items() if param] num_terms_used = len(terms_used) if num_terms_used == 0: pass elif num_terms_used == len(term_name_map): term_str = 'Total Energy' elif num_terms_used == 1: term_str = terms_used[0] + ' Energy' elif num_terms_used == 2: term_str = ' and '.join(terms_used) + ' Energies' else: term_str = ', '.join( terms_used[:-1]) + ' and ' + terms_used[-1] + ' Energies' if num_terms_used == 0 or not m.selected_sets: title = '' else: title = ' - '.join((setrow.name for setrow in m.selected_sets)) if m.exclude_self_terms: title += ' Interactions' title += ': ' + term_str self.plot_view.chart_title = title self.plot_view.setPlotData(self.getEnergyValues())
[docs] def getEnergyValues(self): """ Return the energy values based on the current panel settings. :return: """ m = self.model use_sets = [] for i, set in enumerate(m.sets): if set in m.selected_sets: result_id = f'sel_{i:03}' use_sets.append(result_id) if not use_sets: return None checked_by_term = { 'elec': m.coulomb, 'vdw': m.van_der_waals, 'far_exclusion': m.far_exclusion, 'stretch': m.bond, 'angle': m.angle, 'dihedral': m.dihedral, } use_terms = [ name for name, checked in checked_by_term.items() if checked ] if not use_terms: return None include_self = not m.exclude_self_terms return energy_plots.sum_results(self.plot_view.results, use_sets, use_terms, include_self)