Source code for schrodinger.ui.qt.ligand_list

"""
Present a list of all ligands that have been included in the Workspace or
selected in the project, and allow the user to select their desired ligand or
ligands.

`LigandListWidget` is the standard widget for including this list in a panel.
"""

import contextlib
import enum
from functools import total_ordering

import schrodinger
from schrodinger import project
from schrodinger.infra import mm
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.structutils import analyze
from schrodinger.ui.qt import table_helper
from schrodinger.ui.qt.appframework2 import maestro_callback
from schrodinger.ui.qt.appframework2 import markers
from schrodinger.ui.qt.utils import maestro_required

maestro = schrodinger.get_maestro()

LigSource = enum.Enum("LigSource", ["included", "selected"])


[docs]@total_ordering class Ligand(object): """ An object representing one ligand in the list. Contains a reference to the analyze.Ligand object. """
[docs] def __init__(self, found_lig, proj_row): """ :param found_lig: The ligand object found using `schrodinger. structutils.analyze.AslLigandSearcher` :type found_lig: `schrodinger.structutils.analyze.Ligand` :param proj_row: The Project Table row :type proj_row: `schrodinger.project.ProjectRow` """ self.entry_id = proj_row.entry_id self.entry_title = proj_row.title self._found_lig = found_lig # Atom indices BY ENTRY (they may have different atom numbers in the # Workspace structure): self.atom_indexes = found_lig.atom_indexes self._entire_entry = len(self.atom_indexes) == len( proj_row.getStructure().atom) self._res_names = self._getResNames()
@property def struc(self): return self._found_lig.st
[docs] def getName(self, only_one_entry): """ Get the name to use for this ligand in the ligand list. If the ligand is the entire entry, the structure title will be used. Otherwise, the entry title followed by the residue name and number will be used (or a multiple residue names and numbers for ligands that span multiple residues). :param only_one_entry: Whether only one entry is included in the workspace (all ligands in the list came from it). In this case, the entry title will be excluded from the row name. Note that this setting will be ignored when the ligand is the entire entry. :type only_one_entry: bool :return: The properly formatted name :rtype: str """ # Need to also show the entry ID, not just title, because it's # fairly common for multiple entries to have the same title. # Title is reported last because it can be very long entry_info = "Entry %s, Title: %s" % (self.entry_id, self.entry_title) if self._entire_entry: return entry_info elif only_one_entry: # All ligands came from the same entry, so show only residue info: return self._res_names else: return self._res_names + "; " + entry_info
def __eq__(self, other): return (self.entry_id == other.entry_id and self._found_lig.sort_key() == other._found_lig.sort_key()) def __lt__(self, other): return (self.entry_id < other.entry_id or self._found_lig.sort_key() < other._found_lig.sort_key()) def __repr__(self): return "%s" % self.entry_title def _getResNames(self): """ Create a string containing residue name and numbers for all residues in this ligand's structure. :return: The residue names and numbers :rtype: str """ res_names = [ "%s (%i%s)" % (res.pdbres, res.resnum, res.inscode.strip()) for res in self.struc.residue ] chain = self.struc.atom[1].chain if chain == " ": chain = "_" return "%s:%s" % (chain, " - ".join(res_names))
[docs]class LigandListView(markers.MarkerMixin, QtWidgets.QListView): """ A list view for ligands. Note that multiple ligand selection can be enabled via `ligand_list_view.setSelectionMode(QtWidgets.QListView.ExtendedSelection)`. :cvar ligandSelectionChanged: A signal emitted when the selected ligands have changed. :vartype ligandSelectionChanged: `QtCore.pyqtSignal` """ ligandSelectionChanged = QtCore.pyqtSignal()
[docs] def __init__(self, parent=None): # See Qt documentation for argument documentation super(LigandListView, self).__init__(parent) self._auto_include = True self._auto_fit = True self._use_markers = False self._marker_color = (1, 1, 1) self._including_entries = False
[docs] def setAutoInclude(self, auto_include): """ Specify whether ligands should be included in the Workspace when they are selected in the list. Only relevant when selecting ligands that are selected the Project Table. :param auto_include: True if ligands should be included. False otherwise. :type auto_include: bool """ self._auto_include = auto_include
[docs] def setAutoFit(self, auto_fit): """ Specify whether ligands should be zoomed in on in the Workspace when they are selected in the list. Requires auto-include. :param auto_fit: True if ligands should be zoomed in on. False otherwise. :type auto_fit: bool """ self._auto_fit = auto_fit
[docs] def setMarkSelectedLigands(self, use_markers): """ Specify whether ligands that are selected in the ligand list should be marked in the workspace. See `setMarkerColor` to control the marker color. :param use_markers: True if workspace markers should be used. False otherwise. :type use_markers: bool """ self._use_markers = use_markers if use_markers: self._updateLigandMarkers() else: self.removeAllMarkers()
[docs] def setMarkerColor(self, color): """ Specify the color of the workspace markers used to mark selected ligands. Only has an effect if `setMarkSelectedLigands` has been set to True. :param color: A tuple of RGB float values for the marker color. :type color: tuple """ self._marker_color = color if self._use_markers: self._updateLigandMarkers()
[docs] def selectedLigands(self): """ Return a list of `Ligand` objects for the selected rows. """ return [ index.data(table_helper.ROW_OBJECT_ROLE) for index in self.selectedIndexes() ]
[docs] def selectLigandsFromAtoms(self, atoms): """ Select all ligands containing the specified atom(s). :param atoms: A list of atoms (`schrodinger.structure._StructureAtom`) or a single `schrodinger.structure._StructureAtom`. This atom must be from a Workspace or Project Table structure. :type atoms: list or `schrodinger.structure._StructureAtom` :raise ValueError: If `atoms` doesn't specify any ligands. ValueError will also be raised if `atoms` specified more than one ligand and the view is in SingleSelection selection mode. """ if isinstance(atoms, schrodinger.structure._StructureAtom): atoms = [atoms] model = self.model() atom_data = [(atom.entry_id, atom.number_by_entry) for atom in atoms] to_lig = model.atomToLigNumMapping() row_nums = {to_lig.get(atom) for atom in atom_data} # Ignore any atoms that don't correspond to a ligand row_nums.discard(None) if not row_nums: raise ValueError("No ligands included in selected atoms") if len(row_nums) > 1 and self.selectionMode() == self.SingleSelection: raise ValueError("Multiple ligands specified by selected atoms") indices = [model.index(row, 0) for row in row_nums] sel = QtCore.QItemSelection() for index in indices: sel.select(index, index) sel_model = self.selectionModel() # Don't auto-zoom while the user is interacting with the Workspace with self._disableAutoFit(): sel_model.select(sel, sel_model.ClearAndSelect)
@contextlib.contextmanager def _disableAutoFit(self): """ Temporarily disable auto-zooming """ old_auto_fit = self._auto_fit self._auto_fit = False try: yield finally: self._auto_fit = old_auto_fit
[docs] def selectLigandsFromWorkspaceAtomNums(self, atom_nums): """ Select all ligands containing the specified atom(s). :param atoms: A list of Workspace atom numbers (ints) or a single Workspace atom number. :type atoms: list or int :raise ValueError: If `atom_nums` doesn't specify any ligands. ValueError will also be raised if `atom_nums` specified more than one ligand and the view is in SingleSelection selection mode. """ if isinstance(atom_nums, int): atom_nums = [atom_nums] ws_struc = maestro.workspace_get() atoms = [ws_struc.atom[i] for i in atom_nums] self.selectLigandsFromAtoms(atoms)
[docs] def selectionChanged(self, selected, deselected): # See Qt documentation for method documentation super(LigandListView, self).selectionChanged(selected, deselected) atom_data = self._getSelectedAtomData() if self._auto_include: self._includeEntries(atom_data.keys()) # PANEL-5967 - Also exclude the rows that have ben de-selected. if self.model()._source == LigSource.selected: desel_idxs = deselected.indexes() eids = [ index.data(table_helper.ROW_OBJECT_ROLE).entry_id for index in desel_idxs ] self._excludeEntries(eids) if self._auto_fit: self._fitTo(atom_data) if self._use_markers: self._updateLigandMarkers() self.ligandSelectionChanged.emit()
def _getSelectedAtomData(self): """ Return a dictionary of {entry id: set of atom indices} containing all atoms in all ligands currently selected in the ligand list. """ atom_data = {} for lig in self.selectedLigands(): atom_data.setdefault(lig.entry_id, set()).update(lig.atom_indexes) return atom_data def _updateLigandMarkers(self): """ Update the workspace markers used to mark selected ligands. """ self.removeAllMarkers() atoms_to_mark = [] for cur_ligand in self.selectedLigands(): atoms_to_mark.extend(cur_ligand.struc.atom) if atoms_to_mark: self.addMarker(atoms_to_mark, self._marker_color)
[docs] def selectIfNoSelection(self): """ When called this function will select the first ligand in the list if no other ligand in the list is currently selected. """ if self.selectionMode() in (self.NoSelection, self.MultiSelection): return model = self.model() if not self.selectedIndexes() and model.rowCount(): index = model.index(0, 0) sel_model = self.selectionModel() sel_model.select(index, sel_model.SelectCurrent)
[docs] def setModel(self, model): # See Qt documentation for method documentation super(LigandListView, self).setModel(model) model.modelReset.connect(self.selectIfNoSelection) model.rowsInserted.connect(self.selectIfNoSelection) model.rowsRemoved.connect(self.selectIfNoSelection) self.selectIfNoSelection()
def _includeEntries(self, eids): """ Make sure that all specified entry ids are included in the Workspace. :param eids: An iterable of entry ids to include :type eids: iterable """ self._changeEntryInclusion(eids, project.NOT_IN_WORKSPACE, project.IN_WORKSPACE) def _excludeEntries(self, eids): """ Make sure that the specified eids are excluded in the Workspace. :param eids: An iterable of entry ids to exclude. :type eids: iterable """ self._changeEntryInclusion(eids, project.IN_WORKSPACE, project.NOT_IN_WORKSPACE) @maestro_required def _changeEntryInclusion(self, eids, current_state, new_state): """ Check the workspace inclusion state of each entry id in an iterable. If the state matches current_state, change the state to new_state. :param eids: An iterable of entry ids to exclude. :type eids: iterable :param current_state: Inclusion state to check in each entry. :type current_state: int, should be `project.IN_WORKSPACE` or `project.NOT_IN_WORKSPACE` :param new_state: State to change entries that match current_state to. :type new_state: int, should be one of `project.IN_WORKSPACE` or `project.NOT_IN_WORKSPACE` """ self._including_entries = True try: proj = maestro.project_table_get() except project.ProjectException: # Project may have been closed during operation pass else: for cur_eid in eids: row = proj[cur_eid] if row.in_workspace == current_state: row.in_workspace = new_state self._including_entries = False @maestro_required def _fitTo(self, atom_data): """ Fit the Workspace to all specified atoms :param atom_data: A dictionary of {entry id: set of atom indices} for the atoms to zoom in on. :type atom_data: dict """ if atom_data: asl = self._createAsl(atom_data) maestro.command("fit %s" % asl) else: maestro.command("fit") def _createAsl(self, atom_data): """ Create and ASL specifying the given atoms :param atom_data: A dictionary of {entry id: set of atom indices} for the atoms to include in the ASL. :type atom_data: dict :return: The ASL :rtype: str """ asl_per_eid = [] for eid in sorted(atom_data.keys(), key=int): atom_nums = sorted(atom_data[eid]) joined_nums = ",".join(map(str, atom_nums)) cur_asl = "(entry.id %s AND atom.entrynum %s)" % (eid, joined_nums) asl_per_eid.append(cur_asl) return " OR ".join(asl_per_eid)
[docs]class LigandListWidget(maestro_callback.MaestroCallbackWidgetMixin, LigandListView): """ A list view for ligands that contains its own model. The model will be automatically updated whenever the project changes. As with `LigandListView`, multiple ligand selection can be enabled via `ligand_list_widget.setSelectionMode(QtWidgets.QListView.ExtendedSelection)`. """
[docs] def __init__(self, parent=None): super(LigandListWidget, self).__init__(parent) self._model = LigandListModel(parent) self.setModel(self._model)
[docs] def setLigandSource(self, source): """ Specify whether ligands should be taken from selected or included Project Table entries. :param source: The desired ligand source. :type source: `LigSource` """ self._model.setLigandSource(source)
[docs] def ligandSource(self): """ Return whether ligands are currently being taken from selected or included Project Table entries. :return: The current ligand source. :rtype: `LigSource` """ return self._model.ligandSource()
[docs] @maestro_callback.project_changed def projectChanged(self): if not self._including_entries: with self.ignoreMaestroCallbacks(): self._model.updateLigandList()
[docs] @maestro_callback.project_close def projectClose(self): self._model.reset()
[docs]class LigandListModel(table_helper.RowBasedListModel):
[docs] def __init__(self, parent=None, source=LigSource.included): """ :param parent: The Qt parent widget. :type parent: `QtWidgets.QWidget` or NoneType :param source: The desired ligand source. :type source: `LigSource` """ super(LigandListModel, self).__init__(parent) self._only_one_entry = True self.setLigandSource(source)
[docs] def setLigandSource(self, source): """ Specify whether ligands should be taken from selected or included Project Table entries. :param source: The desired ligand source. :type source: `LigSource` """ self._source = source self.updateLigandList()
[docs] def ligandSource(self): """ Return whether ligands are currently being taken from selected or included Project Table entries. :return: The current ligand source. :rtype: `LigSource` """ return self._source
[docs] def updateLigandList(self): """ Update the contents of the model. This method should be called whenever project inclusion or selection changes. """ if not maestro: # Do not populate the table when run outside of Maestro. Otherwise, # any panel that includes this widget would have to mock this # method. return finder = analyze.AslLigandSearcher() all_ligs = [] proj_rows = self._projRows() num_rows = len(proj_rows) # FIXME for "included" mode, this code will ignore the scratch entries try: maestro.project_table_synchronize() for row in proj_rows: st = row.getStructure(workspace_sync=False) for cur_lig in finder.search(st): lig = Ligand(cur_lig, row) all_ligs.append(lig) except (mm.MmException, project.ProjectException): return all_ligs.sort() self._only_one_entry = (self._source is LigSource.included and num_rows == 1) self.replaceRows(all_ligs)
def _projRows(self): """ Get all rows included or selected in the Project Table based on the current source. :return: An iterable of `schrodinger.project.ProjectRow` for all included or selected Project Table rows. :rtype: iterable """ if not maestro: # unit tests return [] try: proj = maestro.project_table_get() except project.ProjectException: # Project may have been closed during operation return [] if self._source is LigSource.included: return proj.included_rows elif self._source is LigSource.selected: return proj.selected_rows else: err = ("source must be a valid LigSource value, not %s" % self._source) raise ValueError(err) @table_helper.data_method(Qt.DisplayRole) def _displayData(self, lig): return lig.getName(self._only_one_entry)
[docs] def atomToLigNumMapping(self): """ Return a dictionary of {(entry id, atom number): ligand row number} for all atoms in all ligands in the table. """ to_lig = {} for lig_num, cur_lig in enumerate(self.rows): cur_mapping = {(cur_lig.entry_id, atom_num): lig_num for atom_num in cur_lig.atom_indexes} to_lig.update(cur_mapping) return to_lig