import enum
import itertools
import re
from collections import namedtuple
import schrodinger
from schrodinger.application.jaguar import input as jaginput
from schrodinger.application.jaguar.gui.ui import define_smarts_panel_ui
from schrodinger.application.jaguar.gui.ui import pka_smarts_page_ui
from schrodinger.infra import mm
from schrodinger.models import mappers
from schrodinger.models import parameters
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.structutils import analyze
from schrodinger.ui.qt import basewidgets
from schrodinger.ui.qt import delegates as qt_delegates
from schrodinger.ui.qt import pop_up_widgets
from schrodinger.ui.qt import table_helper
from . import input_tab_widgets
from .input_tab_widgets import SORT_ROLE
from .input_tab_widgets import WS_INCLUDE_ONLY
maestro = schrodinger.get_maestro()
JAG_NAME_STRUCTURE_ROLE = Qt.UserRole + 100
PKA_ATOM_ROLE = Qt.UserRole + 101
# We add the 100 to make sure that we don't conflict with the
# input_tab_widgets roles
TEXT_COLOR_PKA_ADD = QtGui.QColor('#00b0b3') # darker cyan
TEXT_COLOR_PKA_REMOVE = QtGui.QColor('#e69500') # darker orange
AtomType = enum.Enum('AtomType', ['Hydrogen', 'NonHydrogen', 'NonAtom'])
def _get_atom_type(atom):
"""
Return whether the given `atom` is a hydrogen, non-hydrogen, or non-atom.
:param atom: An atom represented as a string of the chemical symbol with its
atom number within the respective structure. (e.g. "H1" for hydrogen)
:type atom: str
:return: whether the given `atom` is a hydrogen, non-hydrogen, or non-atom.
:rtype: AtomType
"""
atom_match = re.match(r'([A-z]{1,2})(\d+)', atom)
if atom_match:
atomic_symbol = atom_match.group(1)
atomic_number = mm.mmelement_get_atomic_number_by_symbol(atomic_symbol)
if atomic_number == 1:
return AtomType.Hydrogen
elif atomic_number > 1:
return AtomType.NonHydrogen
return AtomType.NonAtom
[docs]def filter_hydrogen_from_list(atom_list, return_h):
"""
Filter a given atom_list to return only hydrogen atoms or only non-hydrogen atoms.
:param atom_list: a list of workspace atoms represented as strings (e.g. ["H1", "O2", "N3"])
that can contain both hydrogen and non-hydrogen atoms.
:type atom_list: list or None
:param return_h: Whether or not to return only hydrogen atoms. If True, only
hydrogen atoms are returned. If False, only non-hydrogen atoms are returned.
:type return_h: bool
:return: A filtered list of atoms represented as strings with either all hydrogen atoms removed
or only hydrogen atoms remaining. Returns None if the filtered list is empty. Also returns
None if the supplied atom_list is empty or is None.
:rtype: list(str) or NoneType
"""
if not atom_list:
return None
if return_h:
atom_list = [
atom for atom in atom_list
if _get_atom_type(atom) == AtomType.Hydrogen
]
else:
atom_list = [
atom for atom in atom_list
if _get_atom_type(atom) == AtomType.NonHydrogen
]
if not atom_list:
return None
return atom_list
ProjEntryTuplePka = namedtuple(
"ProjEntryTuplePka",
("entry_id", "struc", "charge", "spin_mult", "pka_atom"))
[docs]class ProjEntryPka(input_tab_widgets.ProjEntry):
"""
Builds upon `ProjEntry` by introducing support for storing data for the
"Add H+" and "Remove H+" pKa columns.
"""
PKA_ATOM_PROP = "s_m_pKa_atom"
PKA_VALID, PKA_INVALID, PKA_MISSING, PKA_COLUMN_INVALID = list(range(4))
COLUMN = InputEntriesColumnsPka
[docs] def __init__(self, row=None):
super(ProjEntryPka, self).__init__(row)
self.pka_atom_add = None
self.pka_atom_remove = None
[docs] def update(self, row):
"""
Builds upon ProjEntryPka.update() to update the pKa atom data
if there are no pka atoms at all.
"""
super(ProjEntryPka, self).update(row)
if self.pka_atom_add is self.pka_atom_remove is None:
pka_str = row[self.PKA_ATOM_PROP]
# This is done to ensure that self.pka_atom is a list.
# Fixes PANEL-1806.
if pka_str:
pka_list = [x.strip() for x in pka_str.split(',')]
self.pka_atom_add = filter_hydrogen_from_list(pka_list,
return_h=False)
self.pka_atom_remove = filter_hydrogen_from_list(pka_list,
return_h=True)
[docs] def getStructureWithJagNames(self):
"""
Return the entry structure with jaguar atom naming applied
:return: The structure with jaguar atom naming applied
:rtype: `schrodinger.structure.Structure`
"""
struc = self.getStructure()
jaginput.apply_jaguar_atom_naming(struc)
return struc
[docs] def reset(self):
super(ProjEntryPka, self).reset()
self.pka_atom_add = None
self.pka_atom_remove = None
[docs] def getPkaAtom(self, col_num):
"""
Get either pka_atom_add or pka_atom_remove given the supplied column. Raise
an error if supplied table column is not one of the two pKa atom columns.
:param col_num: The table column number for which to return a pKa_atom list.
:type col_num: int
:return: A list of pKa atoms or None if there are no pKa atoms in the
corresponding pka_atom attribute.
:rtype: list(str) or None
"""
if col_num == self.COLUMN.PKA_ATOM_ADD:
return self.pka_atom_add
elif col_num == self.COLUMN.PKA_ATOM_REMOVE:
return self.pka_atom_remove
else:
raise ValueError
[docs] def getPkaAtoms(self):
"""
Get a combined list of all atoms in pka_atom_add and pka_atom_remove.
:return: A list of pKa atoms or None if there are no pKa atoms in either
pka_atom attribute.
:rtype: list(str) or None
"""
pka_list = list(
itertools.chain.from_iterable(
pka_list for pka_list in (self.pka_atom_add,
self.pka_atom_remove)
if pka_list is not None))
if not pka_list:
return None
return pka_list
[docs] def checkPkaAtom(self, col_num):
"""
Make sure that a valid pKa atom(s) is specified in the given pKa atom column.
:param col_num: The table column number to display pKa data for.
:type col_num: int
:return: PKA_VALID if all valid pKa atoms are specified, PKA_INVALID if
any invalid pKa atom is specified, and PKA_MISSING if no pKa atom is
specified.
:rtype: int
:raises ValueError: if the column number passed into getPkaAtom() corresponds
to neither the "Add H+" nor the "Remove H+" column.
"""
pka_atom_names = self.getPkaAtom(col_num)
if pka_atom_names is None:
return self.PKA_MISSING
struc = self.getStructureWithJagNames()
struc_atom_names = [atom.name for atom in struc.atom]
for pka_atom_name in pka_atom_names:
if pka_atom_name not in struc_atom_names:
return self.PKA_INVALID
return self.PKA_VALID
[docs] def checkPkaAtoms(self):
"""
Aggregate the results of checkPkaAtom() on both pKa atom columns.
:return: PKA_VALID if all pKa atoms in this project entry are valid,
PKA_INVALID if any invalid pKa atom is specified anywhere, and
PKA_MISSING if no pKa atom is specified.
"""
pka_atom_add_check = self.checkPkaAtom(self.COLUMN.PKA_ATOM_ADD)
pka_atom_remove_check = self.checkPkaAtom(self.COLUMN.PKA_ATOM_REMOVE)
if pka_atom_add_check == self.PKA_INVALID or pka_atom_remove_check == self.PKA_INVALID:
return self.PKA_INVALID
elif pka_atom_add_check == pka_atom_remove_check:
if pka_atom_add_check == self.PKA_MISSING:
return self.PKA_MISSING
return self.PKA_VALID
[docs] def getPkaAtomObjs(self):
"""
Get a list of currently selected pKa atom(s) object(s) from both pKa
columns.
:return: If the currently selected pKa atom(s) is valid, returns the
list of atoms itself. Otherwise, returns None.
:rtype: list(_StructureAtom) or NoneType
"""
pka_atom_names = self.getPkaAtoms()
if pka_atom_names is None:
return None
struc = self.getStructureWithJagNames()
atom_objs = [
atom_obj for atom_obj in struc.atom
if atom_obj.name in pka_atom_names
]
if atom_objs:
return atom_objs
else:
return None
[docs]class PickingModes(enum.Enum):
MANUAL = 1
AUTO = 2
SMARTS = 3
[docs]class SmartsPageModel(parameters.CompoundParam):
"""
Model for a single page which contains the a single smarts string and
single atom position index.
"""
atom_pos = parameters.IntParam(1)
smarts = parameters.StringParam("")
[docs]class PkaPage(mappers.MapperMixin, basewidgets.BaseWidget):
model_class = SmartsPageModel
ui_module = pka_smarts_page_ui
[docs] def initSetUp(self):
super().initSetUp()
self.ui.smarts_le.textChanged.connect(self.updateSpinBoxLimits)
self.ui.from_selection_btn.clicked.connect(self.getFromSelection)
[docs] def updateSpinBoxLimits(self):
"""
Update limits of atom position spin box to reflect the number of
atoms in the SMARTS string
"""
smarts = self.ui.smarts_le.text()
if smarts and analyze.validate_smarts_canvas(smarts)[0]:
max_index = int(analyze.count_atoms_in_smarts(smarts))
if max_index < self.ui.atom_position_sb.value():
self.ui.atom_position_sb.setValue(max_index)
self.ui.atom_position_sb.setMaximum(max_index)
[docs] def defineMappings(self):
return {
self.ui.smarts_le: SmartsPageModel.smarts,
self.ui.atom_position_sb: SmartsPageModel.atom_pos
}
[docs] def getFromSelection(self):
"""
Get smarts from selected atoms in workspace and add it to the current
page
"""
try:
self.model.smarts = maestro.selected_atoms_get_smarts()
except ValueError:
# selected_atoms_get_smarts raises ValueError when too many atoms
# are selected, atoms are disconnected, no atoms are selected, etc.
self.model.smarts = ""
[docs]class SmartsSelector(pop_up_widgets.PopUp, basewidgets.BaseWidget):
"""
A popup widget that allows users to specify any number of SMARTS strings
to specify pka atoms as well as an atom position which specifies the atom
in the SMARTS
"""
closed = QtCore.pyqtSignal()
ui_module = define_smarts_panel_ui
[docs] def setup(self):
"""Needs to be implemented for PopUp widgets"""
[docs] def initSetUp(self):
super().initSetUp()
self.addPage()
self.ui.define_another_btn.clicked.connect(self.addPage)
self.ui.remove_btn.clicked.connect(self.removeCurrentPage)
self.ui.next_page_btn.clicked.connect(self.next)
self.ui.back_page_btn.clicked.connect(self.back)
[docs] def addPage(self):
"""
Add an additional page for specifying SMARTS
"""
new_widget = PkaPage(self)
self.ui.stacked_widget.addWidget(new_widget)
self.ui.stacked_widget.setCurrentIndex(self.ui.stacked_widget.count() -
1)
self.updatePagination()
[docs] def removeCurrentPage(self):
"""
Remove the current page. If there was only one page before removing,
add another after so there is always one page.
"""
current_widget = self.ui.stacked_widget.currentWidget()
self.ui.stacked_widget.removeWidget(current_widget)
if self.ui.stacked_widget.count() == 0:
self.addPage()
self.updatePagination()
[docs] def next(self):
"""
Show next page of stacked widget
"""
current_idx = self.ui.stacked_widget.currentIndex()
self.ui.stacked_widget.setCurrentIndex(current_idx + 1)
self.updatePagination()
[docs] def back(self):
"""
Show previous page of stacked widget
"""
current_idx = self.ui.stacked_widget.currentIndex()
self.ui.stacked_widget.setCurrentIndex(current_idx - 1)
self.updatePagination()
[docs] def getModels(self):
"""
Get the models for the Smarts Pages
:return: A list of the models
:rtype: list(SmartsPageModel)
"""
count = self.ui.stacked_widget.count()
return [self.ui.stacked_widget.widget(i).model for i in range(count)]
[docs] def closeEvent(self, ev):
self.closed.emit()
super().closeEvent(ev)
[docs]class AtomSelectionDelegate(input_tab_widgets.CommitMultipleDelegate,
qt_delegates.DefaultMessageDelegate):
"""
A delegate for selecting a pKa atom. The atom name can either be typed into
the line edit or selected from the workspace. A tool tip containing
instructions will appear when the editor is first open and any time the user
hovers their mouse over the editor. If the user clicks on an atom from the
wrong structure, the atom will be ignored and a tool tip warning will
appear. Clicking on an atom does not close the editor so that the user can
immediately pick a different atom if desired. Valid atoms will be immediately
added to the model upon clicking.
:ivar set_pka_marker: A signal emitted when a new pKa atom should be marked
in the workspace. Emitted with two arguments:
- The entry id of the structure to be marked (str)
- List of atoms (`schrodinger.structure._StructureAtom`) to be marked
:vartype set_pka_marker: `PyQt5.QtCore.pyqtSignal`
"""
MAESTRO_BANNER_REMOVE_H = "Pick protons to be removed"
MAESTRO_BANNER_ADD_H = "Pick atoms to add a proton to"
TOOL_TIP_INSTRUCTIONS = ("Click an atom in the workspace to\n"
"set it as the pKa atom or type the\n"
"atom name here.")
TOOL_TIP_WRONG_EID = ("The atom you selected is not\n"
"part of this structure.")
set_pka_marker = QtCore.pyqtSignal(str, object)
[docs] def __init__(self, parent):
super(AtomSelectionDelegate, self).__init__(parent)
self.closeEditor.connect(maestro.picking_stop)
self.closeEditor.connect(self._resetMarker)
[docs] def createEditor(self, parent, option, index):
editor = QtWidgets.QLineEdit(parent)
editor.setGeometry(option.rect)
editor.index = index
editor.setToolTip(self.TOOL_TIP_INSTRUCTIONS)
self._showToolTipTimer(editor, self.TOOL_TIP_INSTRUCTIONS, 300)
# The 300 ms delay is to make sure that painting the editor has
# finished. Without it, the tooltip will appear and immediately
# disappear (at least on Windows). Moving the _showToolTip() call
# to the end of the line edit's show() function doesn't avoid the
# need for this delay.
self._ensureEntryIncluded(index)
if index.column() == InputEntriesColumnsPka.PKA_ATOM_ADD:
self._startPicking(editor, index, self.MAESTRO_BANNER_ADD_H)
elif index.column() == InputEntriesColumnsPka.PKA_ATOM_REMOVE:
self._startPicking(editor, index, self.MAESTRO_BANNER_REMOVE_H)
editor.new_atom_marked = False
return editor
def _startPicking(self, editor, index, message):
pick_func = lambda atom_num: self._setEditorAtom(
atom_num, editor, index)
maestro.picking_atom_start(message, pick_func)
def _ensureEntryIncluded(self, index):
"""
Make sure that an entry is included in the workspace. If it isn't,
include only that structure.
:param index: The model index corresponding to the structure to include
:type index: `QtCore.QModelIndex`
"""
model = index.model()
row = index.row()
in_col = model.COLUMN.INCLUSION
in_index = model.index(row, in_col)
included = in_index.data()
if not included:
model.setData(in_index, WS_INCLUDE_ONLY)
[docs] def setEditorData(self, editor, index):
text = index.data()
if text is None:
text = ""
editor.setText(text)
[docs] def setModelData(self, editor, model, index):
"""
Set the model data at the appropriate index according to the atoms currently
present in the editor. Gets called every time a valid atom is clicked as well
as when the delegate is closed (i.e. can only delete atoms upon closing the
delegate).
An editor may only add/remove atoms that belong in its own column.
I.e. the "Add H+" editor can only add/remove non-hydrogen atoms
to the model. Conversely, the "Remove H+" editor may only add/remove
hydrogen atoms to the model.
:param editor: The line edit to enter the atom name into
:type editor: PyQt5.QtWidgets.QLineEdit
:param model: The table model to edit at the given index.
:type model: InputEntriesModelPka
:param index: The index of the table cell being edited
:type index: PyQt5.QtCore.QModelIndex
"""
text = editor.text()
if text == '':
pka_list = None
else:
pka_list = [s.strip() for s in text.split(',')]
model.setData(index, pka_list)
def _setEditorAtom(self, atom_num, editor, index):
"""
Respond to the user clicking an atom in the workspace. Do nothing
if the user clicks on a hydrogen atom while the the "Remove H+" delegate
is active or if the user clicks on a non-hydrogen atom while the "Add H+"
delegate is active. If the atom does not belong to this structure, display
a warning.
Otherwise, enter the Jaguar-ified atom name into the line edit, display
a marker on the atom, and update the model to reflect the selection.
If atom is already included in the list of pka atoms we remove it from
the list.
:param atom_num: The atom number (relative to the workspace structure)
that was selected.
:type atom_num: int
:param editor: The line edit to enter the atom name into
:type editor: PyQt5.QtWidgets.QLineEdit
:param index: The index of the cell being edited
:type index: PyQt5.QtCore.QModelIndex
"""
model = index.model()
eid_index = model.index(index.row(), model.COLUMN.ID)
editor_eid = eid_index.data()
struc = maestro.workspace_get()
atom = struc.atom[atom_num]
is_hydrogen_atom = atom.element == 'H'
if is_hydrogen_atom and index.column() == model.COLUMN.PKA_ATOM_ADD:
return
elif (not is_hydrogen_atom and
index.column() == model.COLUMN.PKA_ATOM_REMOVE):
return
elif atom.entry_id != editor_eid:
self._showToolTip(editor, self.TOOL_TIP_WRONG_EID)
else:
jag_name_struc = index.data(JAG_NAME_STRUCTURE_ROLE)
jag_name_atom = jag_name_struc.atom[atom.number_by_entry]
jag_name = jag_name_atom.name
self._updateEditorText(editor, jag_name)
pka_text = editor.text()
pka_atoms = self._getAtomSts(jag_name_struc, pka_text)
editor.new_atom_marked = True
self.set_pka_marker.emit(editor_eid, pka_atoms)
self.setModelData(editor, model, index)
def _getAtomSts(self, struc, text):
"""
This function returns a list of pka atoms by parsing a given text
string that contains atom names and matching them to the atom names
in the input structure. This is needed when user edits pka atom
names in the input tab table.
:param struc: current structure
:type struc: structure.Structure
:param text: text string that contains comma separated list
of pka atoms.
:type text: str
:return: list of structure atoms or None
:rtype: list or None
"""
if text == "":
return None
else:
atoms = [s.strip() for s in text.split(',')]
pka_atoms = []
for atom in struc.atom:
if atom.name in atoms:
pka_atoms.append(atom)
return pka_atoms
def _updateEditorText(self, editor, atom_name):
"""
This function checks whether a given atom_name is present in the
editor's string. If not found, add it to editor's text. If found
delete it from the editor text and reformat the text.
:param editor: The line edit to enter the atom name into
:type editor: PyQt5.QtWidgets.QLineEdit
:param atom_name: Name of picked atom
:type atom_name: string
"""
text = editor.text()
if text == "":
text = atom_name
else:
pka_atom_names = [s.strip() for s in text.split(',')]
if atom_name in pka_atom_names:
pka_atom_names.remove(atom_name)
else:
pka_atom_names.append(atom_name)
text = ', '.join(pka_atom_names)
editor.setText(text)
def _resetMarker(self, editor, hint):
"""
If a pKa atom was selected via the user clicking in the workspace, reset
the currently marked atom to the table value.
:param editor: The delegate editor
:type editor: PyQt5.QtWidgets.QLineEdit
:param hint: Not used, but present for Qt compatibility
"""
if editor.new_atom_marked:
index = editor.index
model = index.model()
eid_index = model.index(index.row(), model.COLUMN.ID)
editor_eid = eid_index.data()
pka_atom_objs = index.data(PKA_ATOM_ROLE)
self.set_pka_marker.emit(editor_eid, pka_atom_objs)
[docs] def eventFilter(self, editor, event):
"""
Make sure that the editor doesn't close when the user clicks on another
window since that will prevent the user from being able to click on an
atom.
:param editor: The pKa atom line edit
:type editor: PyQt5.QtWidgets.QWidget
:param event: A Qt event
:param event: PyQt5.QtCore.QEvent
:note: We don't need to worry about the case where the user clicks on a
different widget in the pKa panel after selecting an atom. Since the
editor was the last widget with focus in the pKa panel, it will receive
another FocusOut event when the other widget receives focus, and that
FocusOut event will cause the editor to close.
"""
if event.type() == event.FocusOut and not editor.isActiveWindow():
return True
else:
return super(AtomSelectionDelegate, self).eventFilter(editor, event)
def _showToolTip(self, widget, text):
"""
Show a tool tip centered on the specified widget
:param widget: The widget to center the tool tip on
:type widget: PyQt5.QtWidgets.QWidget
:param text: The text to display in the tool tip
:type text: str
"""
try:
tool_tip_point = widget.rect().center()
except RuntimeError:
# If this function was called via _showToolTipTimer(), then it's
# possible that the delegate was closed before the timer fired. If
# that happened, then the underlying C++ object was deleted and
# we'll get a RuntimeError here. In that case, we can give up on
# displaying a tooltip and ignore the runtime error.
return
tool_tip_point = widget.mapToGlobal(tool_tip_point)
QtWidgets.QToolTip.showText(tool_tip_point, text, widget)
def _showToolTipTimer(self, widget, text, delay=0):
"""
Show a tool tip centered on the specified widget after a delay
:param widget: The widget to center the tool tip on
:type widget: PyQt5.QtWidgets.QWidget
:param text: The text to display in the tool tip
:type text: str
:param delay: How long of a delay
:type delay: int
"""
func = lambda: self._showToolTip(widget, text)
QtCore.QTimer.singleShot(delay, func)