"""
Analyzes the quality of the structure in the workspace
in terms of bond length, bond angles, dihedral values,
peptide geometry, and other quantities.
Copyright (c) Schrodinger, LLC. All rights reserved.
"""
# Contributors: Tyler Day, Matvey Adzhigirey, Dave Giesen
import schrodinger.protein.analysis as prosane
import schrodinger.ui.qt.filedialog as filedialog
import schrodinger.ui.qt.swidgets as swidgets
from schrodinger import get_maestro
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt import appframework
from schrodinger.ui.qt import messagebox
from schrodinger.ui.qt.utils import wait_cursor
maestro = get_maestro()
[docs]class DataModel(QtCore.QAbstractTableModel):
"""
Class for storing the table information.
"""
[docs] def __init__(self, master):
"""
Create a DataModel instance
"""
QtCore.QAbstractTableModel.__init__(self)
self.master = master
self._data = []
self._headers = []
self.summary = None
self.view_atoms = None
[docs] def rowCount(self, parent=QtCore.QModelIndex()): # noqa: M511
"""
Returns number of rows
:type parent: QtCore.QModelIndex
:param parent: unused
:rtype: int
:return: Number of rows
"""
return len(self._data)
[docs] def columnCount(self, parent=QtCore.QModelIndex()): # noqa: M511
"""
Returns number of columns
:type parent: QtCore.QModelIndex
:param parent: unused
:rtype: int
:return: Number of rows
"""
return len(self._headers)
[docs] def setData(self, data_list, headers):
"""
Sets the internal data list to the specified list.
NOTE: Will use the actual list passed to it (without making a copy)
:type data_list: list
:param data_list: the list of data for the model. Uses the actual list,
not a copy
:type headers: list of str
:param headers: The column headers
"""
self.beginResetModel()
self._headers = headers
self._data = data_list
self.endResetModel()
[docs] def data(self, index, role=Qt.DisplayRole):
"""
Given a cell index, returns the data that should be displayed in that
cell. Used by the view.
:type index: QtCore.QModelIndex
:param index: index of the cell to return data for
:type role: Qt.ItemDataRole
:param role: The data role to return
:return: the requested data for the requested index
"""
if role == Qt.DisplayRole:
row_data = self._data[index.row()]
col = index.column()
return row_data[col]
[docs] def sort(self, Ncol, order):
"""
Sort table by given column number. The first column will be sorted
based on the first chain:residue number rather than a simple
alphabetical sort. Note that this method changes the actual model._data
property.
:type Ncol: int
:param Ncol: The column index to sort
:type order: Qt.SortOrder (Qt.AscendingOrder or Qt.DescendingOrder)
:param order: The order in which to sort the column
"""
def residue_formatter(item):
"""
Column 0 labels have various formats, but all of them start with
Chain:ResName ResNum. A simple sort sorts on ResName before ResNum,
but that isn't very useful. This sorts on Chain:ResNum
:rtype: str
:return: key to sort item by
"""
try:
label = item[0]
except IndexError:
return ""
try:
chain, remainder = label.split(':', 1)
tokens = remainder.split()
number = tokens[1].strip(':')
number = number.zfill(4)
except (ValueError, IndexError):
return label
return ':'.join([chain, number])
self.layoutAboutToBeChanged.emit()
if Ncol == 0:
key = residue_formatter
else:
# assign key for sorting the row column value if value is a float or int
# assign -inf for sorting comparisons if other datatype
key = lambda row: row[Ncol] if isinstance(row[Ncol], (float, int)
) else float("-inf")
self._data = sorted(self._data, key=key)
if order == Qt.DescendingOrder:
self._data.reverse()
self.layoutChanged.emit()
[docs]class ReportFrame(QtWidgets.QFrame):
"""
A QtWidgets.QFrame that contains a Protein Report
"""
[docs] def __init__(self,
parent_layout=None,
update_button=True,
app_parent=None,
load_proteins=True):
"""
Create a ReportFrame object
:type parent_layout: QLayout or None
:param parent_layout: If given the ReportFrame will add itself to this
layout
:type update_button: bool
:param update_button: True if the report should have an update button,
False if not.
:type app_parent: Deprecated; no longer used.
:param app_parent: Deprecated; no longer used.
:type load_proteins: bool
:param load_proteins: If True, the panel will attempt to load proteins
when created. If False (default), not attempt is made - the widgets are
merely created.
"""
QtWidgets.QFrame.__init__(self)
if parent_layout is not None:
parent_layout.addWidget(self)
self.layout = swidgets.SVBoxLayout(self)
self.entry_name_label = swidgets.SLabel("", layout=self.layout)
self.models = {} # Key: title, value: Model
self.currently_displayed_title = None
self.syncd = True
self.previous_asl = None
# Must create now so it exists during initialization
self.summary_ledit = swidgets.SLabeledEdit('Structure average:')
self.summary_ledit.setReadOnly(True)
# Top button row
top_layout = swidgets.SHBoxLayout()
self.layout.addLayout(top_layout)
self.set_combo = swidgets.SLabeledComboBox('Display:',
command=self.setChanged,
layout=top_layout)
self.set_combo.setSizeAdjustPolicy(self.set_combo.AdjustToContents)
self.export_button = swidgets.SPushButton(
'Export...', command=self.exportCurrentTable, layout=top_layout)
self.export_button = swidgets.SPushButton('Export All...',
command=self.exportAllTables,
layout=top_layout)
# Setup the table:
self.table = QtWidgets.QTableView()
self.table.verticalHeader().hide()
self.table.setSelectionBehavior(self.table.SelectRows)
self.table.setSelectionMode(self.table.ExtendedSelection)
self.table.setSortingEnabled(True)
self.layout.addWidget(self.table)
# Summary area
self.bottom_layout = swidgets.SHBoxLayout()
self.layout.addLayout(self.bottom_layout)
self.bottom_layout.addLayout(self.summary_ledit.mylayout)
# Update button if requested
if update_button:
update_layout = swidgets.SHBoxLayout()
self.update_button = swidgets.SPushButton('Update',
command=self.updateTables,
layout=update_layout)
update_layout.addStretch()
self.layout.addLayout(update_layout)
else:
self.update_button = None
if maestro:
if load_proteins:
self.updateTables()
maestro.workspace_changed_function_add(self.workspaceChanged)
# Removed highlighting due to EV 117692
#self.highlight = 'Protein_Report_Highlight'
#maestro.command('highlight ' + self.highlight)
#maestro.command('highlightmethod ' + self.highlight +
#' type=silhouette')
[docs] def getSelectedDataTitles(self, model):
"""
Returns a list of selected data titles (the value in the first column)
:type model: DataModel
:param model: The model the data comes from
:rtype: list
:return: list of data titles (the value in the first column) for each
selected row
"""
selection_model = self.table.selectionModel()
selected_rows = [x.row() for x in selection_model.selectedRows()]
# Get the selected row data, first column:
selected_data = [model._data[x][0] for x in selected_rows]
return selected_data
[docs] def selectionChangedCallback(self, selected=None, deselected=None):
"""
Called when table selection is changed
:type selected: QItemSelection
:param selected: Unused callback-required parameters
:type deselected: QItemSelection
:param deselected: Unused callback-required parameters
"""
if not self.syncd:
msg = "Workspace has changed, and data may no longer be valid."
if self.update_button:
msg = msg + " Please use Update button."
maestro.warning(msg)
return
model = self.models[self.currently_displayed_title]
selected_data = self.getSelectedDataTitles(model)
selected_atom_list = []
for data in selected_data:
selected_atom_list.extend(model.view_atoms[data])
selected_atoms = set(selected_atom_list)
# Change the previously 'tubed' atoms back to wire'
if self.previous_asl:
maestro.command('repatom rep=none ' + self.previous_asl)
maestro.command('repbond rep=wire')
maestro.command('repatombonds ' + self.previous_asl)
if not selected_atoms:
maestro.command("workspaceselectionclear")
self.previous_asl = None
# Removed highlighting due to EV 117692
#maestro.command("highlighthide " + self.highlight)
return
atom_list = ",".join([str(x) for x in selected_atoms])
asl = ' atom.num %s' % atom_list
# Place the atoms front and center
maestro.command("spotcenter %s" % atom_list)
maestro.command('fit fillres(' + asl + ')')
# Select them and highlight them
maestro.command("workspaceselectionreplace" + asl)
# Removed highlighting due to EV 117692
# maestro.command('highlightatoms ' + self.highlight + asl)
# Show the atoms as tubes
maestro.command('ribbon style=none ' + asl)
maestro.command('displayatom ' + asl)
maestro.command('repatom rep=none ' + asl)
maestro.command('repbond rep=tube')
maestro.command('repatombonds ' + asl)
self.previous_asl = asl
[docs] def getDataModelClass(self):
"""
Returns the proper data model class to use, allows easy subclassing of
the Model in report subclasses
:rtype: DataModel
:return: The CLASS used for the table data model in the report. Does
not return a class instance, only the class itself.
"""
return DataModel
[docs] def getStructure(self, entry_id=None):
"""
Return the structure to report on.
:type entry_id: str or None
:param entry_id: Entry ID of the structure to use, or None (default) if
the workspace structure is to be used. If entry_id is given, the
structure is taken from the project table row for that entry_id (which
may be different from the current workspace structure). If entry_id is
None, the workspace structure is used, but crystal structure properties
are taken from the included project table row
:rtype: `schrodinger.structure.Structure`
:return: The structure requested
"""
struct = None
pt = maestro.project_table_get()
if entry_id is None:
# Use the workspace structure, but get the CT from the Project Table
# so that we can get crystal properties
pt_struct = None
title = 'Scratch Entry'
for row in pt.all_rows:
if row.in_workspace:
if pt_struct is not None: # Already have one.
messagebox.show_warning(
parent=self,
text=
"ERROR: more than 1 entry is included in the workspace."
)
return struct
pt_struct = row.getStructure()
title = row.title
# Get the structure itself from the workspace, as it may be modified
# but not synced.
struct = maestro.workspace_get()
self.entry_name_label.setText(title)
# Copy over crystal properties, if they exist.
if pt_struct is not None:
self.entry_name_label.setText(title)
for propname in [
's_pdb_PDB_CRYST1_Space_Group', 'r_pdb_PDB_CRYST1_a',
'r_pdb_PDB_CRYST1_b', 'r_pdb_PDB_CRYST1_c',
'r_pdb_PDB_CRYST1_alpha', 'r_pdb_PDB_CRYST1_beta',
'r_pdb_PDB_CRYST1_gamma'
]:
try:
struct.property[propname] = pt_struct.property[propname]
except KeyError:
pass
else:
struct = pt.getRow(entry_id).getStructure()
return struct
[docs] @wait_cursor
def updateTables(self, entry_id=None):
"""
Calculate new data for the requested structure and load the data into
the table.
:type entry_id: str or None
:param entry_id: Entry ID of the structure to use, or None (default) if
the workspace structure is to be used. If entry_id is given, the
structure is taken from the project table row for that entry_id (which
may be different from the current workspace structure). If entry_id is
None, the workspace structure is used, but crystal structure properties
are taken from the included project table row
"""
# Get the structure and the report data
struct = self.getStructure(entry_id=entry_id)
if struct is None:
return
self.analysis = prosane.Report(struct)
# Setup the models based on the data from prosane.Report:
self.models = {}
all_titles = []
all_tooltips = []
for data_set in self.analysis.data:
all_titles.append(data_set.title)
try:
all_tooltips.append(data_set.tooltip)
except AttributeError:
all_tooltips.append(data_set.title)
model = self.getDataModelClass()(self)
model.view_atoms = {}
for point in data_set.points:
model.view_atoms[point.descriptor] = point.atoms
table_data = []
for point in data_set.report_data_points():
formatted_data = []
for field in point:
if isinstance(field, float):
formatted_data.append(round(field, 3))
else:
formatted_data.append(field)
table_data.append(formatted_data)
headers = self.analysis.table_headers[data_set.title]
model.setData(table_data, headers)
if isinstance(data_set.summary, float):
model.summary = str(round(data_set.summary, 3))
else:
model.summary = str(data_set.summary)
self.models[data_set.title] = model
self.setSets(all_titles, all_tooltips)
self.syncd = True
[docs] def clear(self):
"""
Clear the widget. Used by PrepWizard.
"""
self.summary_ledit.setText('N/A')
self.table.setModel(None)
self.models = {}
self.setSets([], [])
self.currently_displayed_title = None
[docs] def setSets(self, all_titles, all_tooltips):
"""
Populate the sets menu with the given items. Also takes in a list
of tooltips, for each item.
:param all_titles: List of item texts to add.
:type all_titles: list(str)
:param all_tooltips: List of tool tips for each item.
:type: all_tooltips: list(str)
"""
self.set_combo.clear()
i = 0
for title, tooltip in zip(all_titles, all_tooltips):
self.set_combo.addItem(title)
self.set_combo.setItemData(i, tooltip, Qt.ToolTipRole)
i = i + 1
[docs] @wait_cursor
def exportTables(self, filename, tables_to_write):
"""
Export the specified tables to the <filename> file.
:type filename: str
:param filename: the path to the output file
:type tables_to_write: str
:param tables_to_write: the name of the set of data to write out
"""
filehandle = open(filename, 'w')
for table_name in tables_to_write:
model = self.models[table_name]
filehandle.write("%-40s\n" % table_name)
filehandle.write("%-30s" %
self.analysis.table_headers[table_name][0])
for header in self.analysis.table_headers[table_name][1:]:
filehandle.write("%-10s" % header)
filehandle.write("\n")
for line in model._data:
filehandle.write("%-30s" % line[0])
for value in line[1:]:
filehandle.write("%-10s" % str(value))
filehandle.write("\n")
filehandle.write("\n")
filehandle.close()
[docs] def setChanged(self, selected_title):
"""
Callback for when a new set of data is requested. Changes the data
displayed in the table. This also changes the underlying model the
table uses.
:type selected_title: str
:param selected_title: The name of the set of data requested
"""
# Convert to a Python string:
selected_title = str(selected_title)
self.currently_displayed_title = selected_title
try:
model = self.models[self.currently_displayed_title]
except KeyError:
# No data available
return
self.table.setModel(model)
self.table.resizeColumnsToContents()
self.summary_ledit.setText(str(model.summary))
selection_model = self.table.selectionModel()
selection_model.selectionChanged.connect(self.selectionChangedCallback)
# This moves the sort indicator in the header
self.table.sortByColumn(0, Qt.AscendingOrder)
[docs] def exportCurrentTable(self):
"""
Export only the current table.
"""
filename = filedialog.get_save_file_name(
self,
"Save the current table as",
".", # initial dir
"Text file (*.txt)",
)
if not filename:
return
self.exportTables(filename, [self.currently_displayed_title])
[docs] def exportAllTables(self):
"""
Export all tables.
"""
filename = filedialog.get_save_file_name(
self,
"Save all tables as",
".", # initial dir
"Text file (*.txt)",
)
if not filename:
return
self.exportTables(filename, self.analysis.table_names)
[docs] def openHelp(self):
"""
Display the help topic
"""
maestro.command("helptopic TOOLS_MENU_PROTEIN_REPORTS_PANEL")
return
[docs] def closePanel(self):
"""
Hide the panel
"""
if maestro:
maestro.workspace_changed_function_remove(self.workspaceChanged)
# Will quit if outside of Maestro, hide if in Maestro:
appframework.AppFramework.closePanel(self)
[docs] def workspaceChanged(self, changed):
"""
Keep track of when something changes in the workspace that would
invalidate the current data.
:type changed: str
:param changed: What changed in the workspace
"""
if changed in [
maestro.WORKSPACE_CHANGED_EVERYTHING,
maestro.WORKSPACE_CHANGED_GEOMETRY,
maestro.WORKSPACE_CHANGED_COORDINATES,
maestro.WORKSPACE_CHANGED_CONNECTIVITY,
maestro.WORKSPACE_CHANGED_APPEND
]:
self.syncd = False
else:
self.syncd = True
return
[docs] def close(self):
"""
Close the frame, and clean up anything that needs to happen upon closing
"""
#if maestro:
#maestro.command('highlightdelete ' + self.highlight)
return QtWidgets.QFrame.close(self)
[docs]def panel():
"""
Open (or re-open) the panel
"""
global app
if app:
app.show()
else:
app = ReportFrame() # create new App instance
app.show()
[docs]def quit():
""" To force quit the GUI in Maestro, call 'app.quit' """
global app
if app:
app.quitPanel() # will quit even if in maestro
app = None
# For debugging purposes:
if __name__ == '__main__':
app = ReportFrame()
app.show()
app.exec()
#EOF