"""
Contains widgets that are useful in MatSci panels.
Copyright Schrodinger, LLC. All rights reserved.
"""
import inflect
import math
import re
import os
from collections import OrderedDict, namedtuple
from contextlib import contextmanager
import numpy
from scipy import ndimage
import schrodinger
from schrodinger import project
from schrodinger import structure
from schrodinger.application.bioluminate import sliderchart
from schrodinger.application.desmond import constants as dconst
from schrodinger.application.jaguar import input as jagin
from schrodinger.application.jaguar.gui import utils
from schrodinger.application.jaguar.gui.tabs import solvation_tab
from schrodinger.application.matsci import parserutils
from schrodinger.application.matsci.nano import xtal
from schrodinger.application.matsci import jobutils
from schrodinger.graphics3d import common as graphics_common
from schrodinger.graphics3d import arrow
from schrodinger.graphics3d import polygon
from schrodinger.infra import mm
from schrodinger.infra import mmcheck
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.structutils import measure
from schrodinger.structutils import transform
from schrodinger.ui import picking
from schrodinger.ui.qt import utils as qtutils
from schrodinger.ui.qt import appframework
from schrodinger.ui.qt import atomselector
from schrodinger.ui.qt import filedialog
from schrodinger.ui.qt import multi_combo_box
from schrodinger.ui.qt import swidgets
from schrodinger.utils import units
from . import controlicons_rc # pylint: disable=unused-import
from . import desmondutils
from . import jaguarworkflows
from . import msutils
NO_SOLVENT = 'None'
# Jaguar 'solvent' keyword
SOLVENT_KEY = mm.MMJAG_SKEY_SOLVENT
# Jaguar 'isolv' keyword
MODEL_KEY = mm.MMJAG_IKEY_ISOLV
# Names of icons used for stage control buttons
DOWN = 'down'
UP = 'up'
CLOSE = 'close'
OPEN = 'open'
DELETE = 'delete'
COPY = 'copy'
SAVE_COMBO_OPTIONS = OrderedDict()
SAVE_COMBO_OPTIONS['CMS files'] = jobutils.SAVE_CMS
SAVE_COMBO_OPTIONS['CMS and trajectory archive'] = jobutils.SAVE_TRJ
NUM_RE = re.compile(r'(\d+)')
LATTICE_VECTOR_LABELS = ['a', 'b', 'c']
MINIMUM_PLANE_NORMAL_LENGTH = 2.0
INFLECT_ENGINE = inflect.engine()
maestro = schrodinger.get_maestro()
[docs]def add_desmond_ms_logo(layout, **kwargs):
"""
Add a Desmond D. E. Shaw logo on the left and a MatSci logo on the right
:type layout: QBoxLayout
:param layout: The layout to add the logos to
"""
dlayout = appframework.make_desres_layout('Materials Science Suite',
**kwargs)
layout.addLayout(dlayout)
[docs]def get_row_from_pt(maestro, entry_id):
"""
Get row from the PT from entry_id
:param str entry_id: Entry ID
:param maestro maestro: Maestro instance
:rtype: ProjectRow or None
:return: Row or None, if not found
"""
ptable = maestro.project_table_get()
try:
row = ptable.getRow(entry_id)
except (ValueError, TypeError):
return
return row
[docs]class MdAtomSelector(atomselector.AtomSelector):
"""
Overide the AtomSelector class in atomselector.
Modify the reset button to clear asl text only,
and add a status label if needed.
"""
ASL_NOT_ALLOWED = ['entry.id', 'entry.name', 'set']
[docs] def __init__(self,
master,
title,
status_label=True,
command=None,
flat=False):
"""
See the parent class for documentation
:param QWidget master: The parent of this atomselector widget
:param str title: label over atom selection box
:param bool status_label: If True, add a status label to indicate
the number of selected atoms.
:type command: function or None
:param command: Command to execute when the asl is modified. Signal is
triggered only when editing finishes.
:param bool flat: Apply flat style to the groupbox, by default it is
false in qt.
"""
super().__init__(master, label=title, show_plus=True)
self.master = master
if status_label:
self.status_label = swidgets.SLabel("0 atoms selected")
self.main_layout.insertWidget(0, self.status_label)
else:
self.setStyleSheet(self.styleSheet() +
("QGroupBox{padding-top:1em; margin-top:-1em}"))
self.pick_toggle.setChecked(True)
self.asl_ef.getClearButton().clicked.disconnect()
self.asl_ef.getClearButton().clicked.connect(self.clearAslText)
self.setFlat(flat)
self.command = command
self.aslModified.connect(self.checkASL)
[docs] def clearAslText(self):
"""
Currently, the reset button is used to clear asl string
"""
self._setAsl('')
[docs] def pickMolecule(self):
"""
Set the pick_menu with pick molecule option.
"""
self.pick_menu.setCurrentIndex(atomselector.PICK_MOLECULES)
[docs] def checkASL(self, asl):
"""
Check if asl contains sets, entry.id or entry.name
:param str asl: asl in the line edit
"""
entry_in_asl = [x for x in self.ASL_NOT_ALLOWED if x in asl]
if entry_in_asl:
self.master.error(f'{entry_in_asl[0]} as ASL is not supported.')
self._setAsl('')
if self.command:
self.command()
[docs]class CompactSolventSelector(swidgets.SFrame):
"""
A single line of widgets that displays the currently chosen solvent and a
button that will open a dialog allowing a new solvent model/solvent choice.
Tracks the necessary Jaguar keywords to implement the user's choice.
"""
[docs] def __init__(self,
parent=None,
layout=None,
indent=False,
keywords=None,
**extra_args):
"""
Create a CompactSolventSelector object
Additional keyword arguments are passed on to the SolventDialog that is
opened by this widget.
:type layout: QBoxLayout
:param layout: The layout to place this widget into
:param bool indent: If true indent frame layout
:type keywords: dict
:param keywords: Dictionary of solvent-related Jaguar key/value pairs to
initialize/reset the widgets with
"""
super().__init__(
parent=parent,
layout=layout,
indent=indent,
layout_type=swidgets.HORIZONTAL)
self.other_solvent_options = {}
if keywords is None:
self.keywords = {}
else:
self.keywords = keywords
self.default_keywords = self.keywords.copy()
# Translates solvent keyword names to solvent user names
self.mmname_to_name = dict(
[(x.keyvalue, x.name) for x in utils.ALL_SOLVENTS])
self.label_label = swidgets.SLabel('Solvent:', layout=self.mylayout)
sname = self.getSolventName()
self.solvent_label = swidgets.SLabel(sname, layout=self.mylayout)
self.choose_btn = swidgets.SPushButton(
'Choose...', command=self.chooseSolvent, layout=self.mylayout)
self.dialog_args = extra_args
self.mylayout.addStretch()
[docs] def chooseSolvent(self):
"""
Open a dialog that lets the user choose solvent parameters (model,
solvent, solvent properties) and store the choices
"""
dialog = SolventDialog(self, keywords=self.keywords, **self.dialog_args)
dialog.keywordsChanged.connect(self.solventKeywordsChanged)
dialog.exec_()
[docs] def solventKeywordsChanged(self, keywords, options={}): # noqa: M511
"""
Called when the user clicks accept on the SolventDialog
:type keywords: dict
:param keywords: A dictionary of Jaguar solvent model keywords
"""
self.keywords = keywords
self.other_solvent_options = options
self.solvent_label.setText(self.getSolventName())
[docs] def isSolventModelUsed(self):
"""
Has a solvent model been chosen?
:rtype: bool
:return: True if yes, False if no
"""
return bool(self.keywords.get(MODEL_KEY, 0))
[docs] def getSolventName(self):
"""
Get the name of the chosen solvent
:rtype: str
:return: The user-facing name of the chosen solvent, or NO_SOLVENT if no
model has been chosen
"""
if self.isSolventModelUsed():
return self.mmname_to_name[self.keywords[SOLVENT_KEY]]
else:
return NO_SOLVENT
[docs] def getKeystring(self):
"""
Get a string containing all the keywords specified by the user's choices
:rtype: str
:return: A string containg keywords that define the user's choices. An
empty string is returned if no model has been selected
"""
skeywords = ""
if not self.isSolventModelUsed():
skeywords = ""
else:
skeywords = jaguarworkflows.keyword_dict_to_string(self.keywords)
oskeywords = ""
oskeywords = jaguarworkflows.keyword_dict_to_string(
self.other_solvent_options)
return (skeywords, oskeywords)
[docs] def reset(self):
"""
Reset all the widgets to their original values
"""
self.solventKeywordsChanged(self.default_keywords)
[docs]class SolventDialog(QtWidgets.QDialog):
"""
A Dialog that allows the user to pick a solvent model, solvent and
parameters.
Emits a keywordsChanged signal when the user clicks Accept and passes a
dictionary of the Jaguar keywords that reflect the selected settings.
"""
keywordsChanged = QtCore.pyqtSignal(dict, dict)
[docs] def __init__(self, parent, keywords=None, **extra_args):
"""
Create a SolventDialog object
Additional keyword arguments are passed to the EmbeddedSolventWidget
object
:type parent: QWidget
:param parent: The parent widget for this dialog
:type keywords: dict
:param keywords: A dictionary of jaguar key/value pairs that define the
initial solvent settings for the dialog
"""
self.master = parent
QtWidgets.QDialog.__init__(self, parent)
self.setWindowTitle('Solvent Model')
layout = swidgets.SVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
if keywords is None:
self.keywords = {}
else:
self.keywords = keywords
self.solvent_widgets = EmbeddedSolventWidget(
keywords=self.keywords, **extra_args)
layout.addWidget(self.solvent_widgets)
dbb = QtWidgets.QDialogButtonBox
dialog_buttons = dbb(dbb.Save | dbb.Cancel | dbb.Help)
dialog_buttons.accepted.connect(self.accept)
dialog_buttons.rejected.connect(self.reject)
dialog_buttons.helpRequested.connect(self.help)
layout.addWidget(dialog_buttons)
[docs] def help(self):
"""
Show the Jaguar solvent help
"""
appframework.help_dialog('JAGUAR_TOPIC_SOLVATION_FOLDER', parent=self)
[docs] def accept(self):
"""
Gather the options and emit a keyword dictionary with the
keywords/values they define, then close the dialog.
"""
keywords = self.solvent_widgets.getMmJagKeywords()
for key, value in list(keywords.items()):
if value is None:
del keywords[key]
# The solvation tab no longer allows for "other" solvents, so there are
# no other solvent options to emit
options = {}
self.keywordsChanged.emit(keywords, options)
return QtWidgets.QDialog.accept(self)
[docs]class WheelEventFilterer(QtCore.QObject):
""" An event filter that turns off wheel events for the affected widget """
[docs] def eventFilter(self, unused, event):
"""
Filter out mouse wheel events
:type unused: unused
:param unused: unused
:type event: QEvent
:param event: The event object for the current event
:rtype: bool
:return: True if the event should be ignored (a Mouse Wheel event) or
False if it should be passed to the widget
"""
return isinstance(event, QtGui.QWheelEvent)
[docs]def turn_off_unwanted_wheel_events(widget,
combobox=True,
spinbox=True,
others=None):
"""
Turns off the mouse wheel event for any of the specified widget types that
are a child of widget
Note: The mouse wheel will still scroll an open pop-up options list for a
combobox if the list opens too large for the screen. Only mouse wheel events
when the combobox is closed are ignored.
:type widget: QtWidgets.QWidget
:param widget: The widget to search for child widgets
:type combobox: bool
:param combobox: True if comboboxes should be affected
:type spinbox: bool
:param spinbox: True if spinboxes (int and double) should be affected
:type others: list
:param others: A list of other widget classes that should be affected
"""
affected = []
if others:
affected.extend(others)
if combobox:
affected.append(QtWidgets.QComboBox)
if spinbox:
affected.append(QtWidgets.QAbstractSpinBox)
for wclass in affected:
for child in widget.findChildren(wclass):
child.installEventFilter(WheelEventFilterer(widget))
[docs]def run_parent_method(obj, method, *args):
"""
Try to call a function of a parent widget. If not found, parent of the
parent will be used and so on, until there are no more parents. *args will
be passed to the found function (if any).
:param QWidget obj: QWidget to use
:param str method: Method name to be called
"""
parent = obj.parentWidget()
while parent is not None:
if hasattr(parent, method):
getattr(parent, method)(*args)
return
parent = parent.parentWidget()
[docs]class StageFrame(swidgets.SFrame):
"""
The base frame for a stage in a MultiStageArea
Contains a Toolbutton for a title and some Window-manager-like control
buttons in the upper right corner
"""
# Used to store the icons for buttons so they are only generated once.
icons = {}
[docs] def __init__(self,
master,
layout=None,
copy_stage=None,
stage_type=None,
icons=None):
"""
Create a DesmondStageFrame instance
:type master: `MultiStageArea`
:param master: The panel widget
:type layout: QLayout
:param layout: The layout the frame should be placed into
:type copy_stage: `StageFrame`
:param copy_stage: The StageFrame this StageFrame should
be a copy of. The default is None, which will create a new default
stage.
:param stage_type: The type of stage to create, should be something
meaningful to the subclass. The value is stored but not used in this
parent class.
:type icons: set
:param icons: A set of module constants indicating which icons should be
made into control buttons in the upper right corner. UP, DOWN, OPEN,
CLOSE, DELETE, COPY
"""
self.master = master
swidgets.SFrame.__init__(self, layout_type=swidgets.VERTICAL)
if layout is not None:
# Insert this stage before the stretch at the bottom of the layout
layout.insertWidget(layout.count() - 1, self)
if icons is not None:
self.icons_to_use = icons
else:
self.icons_to_use = {DOWN, UP, CLOSE, OPEN, DELETE, COPY}
# Control bar across the top
self.bar_layout = swidgets.SHBoxLayout(layout=self.mylayout)
# Label button
self.label_button = QtWidgets.QToolButton()
self.label_button.setAutoRaise(True)
self.label_button.clicked.connect(self.toggleVisibility)
self.bar_layout.addWidget(self.label_button)
self.bar_layout.addStretch()
# Top right control buttons
self.createControlButtons()
self.stage_type = stage_type
# Toggle frame - stuff in here gets shown/hidden when the stage gets
# compacted/contracted
self.toggle_frame = swidgets.SFrame(
layout=self.mylayout, layout_type=swidgets.VERTICAL)
self.layOut(copy_stage=copy_stage)
self.initialize(copy_stage=copy_stage)
# Bottom dividing line
Divider(self.mylayout)
self.updateLabel()
[docs] def layOut(self, copy_stage=None):
"""
Lay out any custom widgets
:type copy_stage: `StageFrame`
:param copy_stage: The StageFrame this StageFrame should
be a copy of. The default is None, which will create a new default
stage.
"""
layout = self.toggle_frame.mylayout
[docs] def initialize(self, copy_stage=None):
"""
Perform any custom initialization before the widget is finalized
:type copy_stage: `StageFrame`
:param copy_stage: The StageFrame this StageFrame should
be a copy of. The default is None, which will create a new default
stage.
"""
[docs] def createIconsIfNecessary(self):
"""
Factory to create button icons if they have not been created
"""
prefix = ":/schrodinger/application/matsci/msicons/"
if not self.icons:
if DOWN in self.icons_to_use:
self.icons[DOWN] = QtGui.QIcon(prefix + "down.png")
if UP in self.icons_to_use:
self.icons[UP] = QtGui.QIcon(prefix + "up.png")
if CLOSE in self.icons_to_use:
self.icons[OPEN] = QtGui.QIcon(prefix + "plus.png")
if OPEN in self.icons_to_use:
self.icons[CLOSE] = QtGui.QIcon(prefix + "minus.png")
if DELETE in self.icons_to_use:
self.icons[DELETE] = QtGui.QIcon(prefix + "ex.png")
if COPY in self.icons_to_use:
self.icons[COPY] = QtGui.QIcon(prefix + "copy.png")
[docs] def toggleVisibility(self, checked=None, show=None):
"""
Show or hide the stage
:type checked: bool
:param checked: Not used, but swallows the PyQt clicked signal argument
so that show doesn't get overwritten
:type show: bool
:param show: If True
"""
show_frame = show or (self.toggle_frame.isHidden() and show is None)
if show_frame:
self.toggle_frame.show()
if self.toggle_button and CLOSE in self.icons:
self.toggle_button.setIcon(self.icons[CLOSE])
else:
self.toggle_frame.hide()
if self.toggle_button and OPEN in self.icons:
self.toggle_button.setIcon(self.icons[OPEN])
self.updateLabel()
[docs] def updateLabel(self):
"""
Set the label of the title button that toggles the stage open and closed
"""
# Set the user-facing index to be 1-indexed rather than 0-indexed
index = self.master.getStageIndex(self) + 1
self.label_button.setText('(%d)' % index)
[docs] def moveUp(self):
"""
Move the stage up towards the top of the panel 1 stage
"""
self.master.moveStageUp(self)
[docs] def moveDown(self):
"""
Move the stage down towards the bottom of the panel 1 stage
"""
self.master.moveStageDown(self)
[docs] def delete(self):
"""
Delete this stage
"""
self.master.deleteStage(self)
[docs] def copy(self):
"""
Create a copy of this stage
"""
self.master.copyStage(self)
[docs] def reset(self):
"""
Resets the parameters to their default values.
"""
self.toggleVisibility(show=True)
[docs]class MultiStageArea(QtWidgets.QScrollArea):
"""
A scrollable frame meant to hold multiple stages. See the MatSci Desmond
Multistage Simulation Workflow as one example.
"""
[docs] def __init__(self,
layout=None,
append_button=True,
append_stretch=True,
stage_class=StageFrame,
control_all_buttons=False):
"""
Create a MultiStageArea instance
:type layout: QBoxLayout
:param layout: The layout to place this Area into
:type append_button: bool
:param append_button: Whether to add an "Append Stage" button to a
Horizontal layout below the scrolling area
:type append_stretch: bool
:param append_button: Whether to add a QSpacer to the layout containing
the append button. Use False if additional widgets will be added after
creating the area.
:type stage_class: `StageFrame`
:param stage_class: The class used to create new stages
:param bool control_all_buttons: True if buttons to control the
open/closed state of all stages should be added above the stage
area, False if not. Note that if layout is not supplied, the
top_control_layout will have to be added to a layout manually.
"""
self.top_control_layout = swidgets.SHBoxLayout(layout=layout)
if control_all_buttons:
self.top_control_layout.addStretch()
# Expand/Collapse all
for slot, text, tip in [(self.collapseAll, '--',
'Collapse all stages'),
(self.expandAll, '++',
'Expand all stages')]:
btn = swidgets.SToolButton(
text=text,
layout=self.top_control_layout,
tip=tip,
command=slot)
# Set up the scrolling area
QtWidgets.QScrollArea.__init__(self)
if layout is not None:
layout.addWidget(self)
self.setWidgetResizable(True)
self.frame = swidgets.SFrame(layout_type=swidgets.VERTICAL)
self.setWidget(self.frame)
self.stage_layout = self.frame.mylayout
# Add the append button if requested
if append_button:
self.button_layout = swidgets.SHBoxLayout(layout=layout)
self.append_btn = swidgets.SPushButton(
'Append Stage',
layout=self.button_layout,
command=self.addStage)
if append_stretch:
self.button_layout.addStretch()
# Add the first stage
self.stages = []
self.stage_class = stage_class
# Using a stretch factor of 100 allows this stretch to "overpower" the
# vertical stretches in any stages. This means that stages can use
# stretches to pack themselves tightly without expanding the stage to
# greater than its necessary size.
self.stage_layout.addStretch(100)
[docs] def addStage(self, copy_stage=None, stage_type=None, **kwargs):
"""
Add a new stage
:type copy_stage: `StageFrame`
:param copy_stage: The stage to copy. The default is None, which will
create a new default stage.
:param stage_type: What type of stage to add. Must be dealt with in the
StageFrame subclass
:rtype: `StageFrame`
:return: The newly created stage
:note: All other keyword arguments are passed to the stage class
"""
stage = self.stage_class(
self,
self.stage_layout,
copy_stage=copy_stage,
stage_type=stage_type,
**kwargs)
self.stages.append(stage)
# The duplicate processEvents lines are not a mistake. For whatever
# reason, both are required in order for the stage area to properly
# compute its maximum slider value.
QtWidgets.QApplication.instance().processEvents()
QtWidgets.QApplication.instance().processEvents()
sbar = self.verticalScrollBar()
sbar.triggerAction(sbar.SliderToMaximum)
return stage
[docs] def getStageIndex(self, stage):
"""
Return which stage number this is
:type stage: StageFrame
:param stage: Returns the index for this stage in the stage list
:rtype: int
:return: The stage number (starting at 0)
"""
try:
return self.stages.index(stage)
except ValueError:
# Stages don't exist while they are being made
return len(self.stages)
[docs] def moveStageUp(self, stage):
"""
Shift the given stage up one stage
:type stage: StageFrame
:param stage: The stage to move up
"""
current = self.getStageIndex(stage)
if not current:
return
new = current - 1
self.moveStage(current, new)
[docs] def moveStageDown(self, stage):
"""
Shift the given stage down one stage
:type stage: StageFrame
:param stage: The stage to move down
"""
current = self.getStageIndex(stage)
if current == len(self.stages) - 1:
return
new = current + 1
self.moveStage(current, new)
[docs] def moveStage(self, current, new):
"""
Move the a stage
:type current: int
:param current: The current position of the stage
:type new: int
:param new: The desired new position of the stage
"""
stage = self.stages.pop(current)
self.stages.insert(new, stage)
self.stage_layout.takeAt(current)
self.stage_layout.insertWidget(new, stage)
self.updateStageLabels(start_at=min(current, new))
[docs] def copyStage(self, stage, **kwargs):
"""
Create a copy of stage and add it directly below stage
:type stage: StageFrame
:param stage: The stage to copy
:note: All keyword arguments are passed to addStage
"""
# Create it
self.addStage(copy_stage=stage, **kwargs)
# Move it to directly below the old stage
new_index = self.getStageIndex(stage) + 1
current_index = len(self.stages) - 1
if new_index != current_index:
self.moveStage(current_index, new_index)
[docs] def deleteStage(self, stage, update=True):
"""
Delete a stage
:type stage: `StageFrame`
:param stage: The stage to be deleted
:type update: bool
:param update: True if stage labels should be updated, False if not (use
False if all stages are being deleted)
"""
index = self.getStageIndex(stage)
self.stages.remove(stage)
stage.setAttribute(Qt.WA_DeleteOnClose)
stage.close()
if update:
self.updateStageLabels(start_at=index)
[docs] def updateStageLabels(self, start_at=0):
"""
Update stage labels - usually due to a change in stage numbering
:type start_at: int
:param start_at: All stages from this stage to the end of the stage list
will be updated
"""
for stage in self.stages[start_at:]:
stage.updateLabel()
[docs] def reset(self):
"""
Reset the stage area
"""
for stage in self.stages[:]:
self.deleteStage(stage, update=False)
self.addStage()
[docs] def expandAll(self):
"""
Expand all stages
"""
for stage in self.stages:
stage.toggleVisibility(show=True)
[docs] def collapseAll(self):
"""
Collapse all stages
"""
for stage in self.stages:
stage.toggleVisibility(show=False)
[docs]class Divider(QtWidgets.QFrame):
"""
A raised divider line
"""
[docs] def __init__(self, layout):
"""
Create a Divider instance
:type layout: QLayout
:param layout: The layout the Divider should be added to
"""
QtWidgets.QFrame.__init__(self)
self.setFrameShape(self.HLine)
self.setFrameShadow(self.Raised)
self.setLineWidth(5)
self.setMinimumHeight(5)
layout.addWidget(self)
[docs]class DefineASLDialog(swidgets.SDialog):
"""
Manage defining an ASL.
"""
[docs] def __init__(self, master, help_topic='', show_markers=False, struct=None):
"""
Create an instance.
:type master: QtWidgets.QWidget
:param master: the window to which this dialog should be WindowModal
:type help_topic: str
:param help_topic: an optional help topic
:type show_markers: bool
:param show_markers: whether to show the Markers checkbox
:type struct: schrodinger.structure.Structure
:param struct: an optional structure against which
the ASL will be validated
"""
self.show_markers = show_markers
self.struct = struct
self.indices = None
dbb = QtWidgets.QDialogButtonBox
buttons = [dbb.Ok, dbb.Cancel, dbb.Reset]
if help_topic:
buttons.append(dbb.Help)
title = 'Define ASL'
swidgets.SDialog.__init__(
self,
master,
standard_buttons=buttons,
help_topic=help_topic,
title=title)
self.setWindowModality(QtCore.Qt.WindowModal)
[docs] def layOut(self):
"""
Lay out the widgets.
"""
self.atom_selector = atomselector.AtomSelector(
self, label='ASL', show_markers=self.show_markers)
self.mylayout.addWidget(self.atom_selector)
self.mylayout.addStretch()
[docs] def getAsl(self):
"""
Return the ASL.
:rtype: str
:return: the ASL
"""
return self.atom_selector.getAsl().strip()
[docs] def getIndices(self):
"""
If a structure was provided at instantiation then
return the atom indices of the provided structure that
match the specified ASL.
:rtype: list
:return: matching atom indices or None if no structure
was provided
"""
if self.struct and self.indices is None:
asl = self.getAsl()
self.indices = analyze.evaluate_asl(self.struct, asl)
return self.indices
[docs] def isValid(self):
"""
Return True if valid, (False, msg) otherwise.
:rtype: bool, pair tuple
:return: True if valid, (False, msg) otherwise
"""
asl = self.getAsl()
if not analyze.validate_asl(asl):
msg = ('The specified ASL is invalid.')
return (False, msg)
elif self.struct and not self.getIndices():
msg = ('The specified ASL does not match the given structure.')
return (False, msg)
return True
def _stopPicking(self):
"""
Stop picking.
"""
self.atom_selector.picksite_wrapper.stop()
def _hideMarkers(self):
"""
Hide markers.
"""
if self.show_markers:
self.atom_selector.marker.hide()
self.atom_selector.marker.setAsl('not all')
[docs] def accept(self):
"""
Callback for the Accept (OK) button.
"""
state = self.isValid()
if state is not True:
self.error(state[1])
return
self._stopPicking()
self._hideMarkers()
return swidgets.SDialog.accept(self)
[docs] def reject(self):
"""
Callback for the Reject (Cancel) button.
"""
self._stopPicking()
self._hideMarkers()
return swidgets.SDialog.reject(self)
[docs] def reset(self):
"""
Reset it.
"""
self.atom_selector.reset()
self._hideMarkers()
self.indices = None
[docs]class SideHistogram(object):
"""
Class to setup a side histogram plot in the passed figure.
"""
[docs] def __init__(self,
figure,
x_label,
y_label='Frequency',
x_start=0.6,
x_end=0.9,
y_start=0.2,
y_end=0.9,
color='c',
face_color='white',
fontsize='small',
title=None,
subplot_pos=None,
flip_ylabel=False):
"""
Setup an additional axes on the right half of the canvas.
:param matplotlib.figure.Figure figure: add histogram plot to this figure
:param str x_label: name for the x-axis label
:param str y_label: name for the y-axis label
:param float x_start: relative x-coordinate on the figure where the
plot should start from
:param float x_end: relative x-coordinate on the figure where the
plot should end
:param float y_start: relative y-coordinate on the figure where the
plot should start from
:param float y_end: relative x-coordinate on the figure where the
plot should end
:param str color: color of the bar of the histogram
:param str face_color: bg color of the plot
:param int fontsize: font size for the lables
:param str title: title for the plot
:param int subplot_pos: A three digit integer, where the first digit is
the number of rows, the second the number of columns, and the third
the index of the current subplot. Index goes left to right, followed
but top to bottom. Hence in a 4 grid, top-left is 1, top-right is 2
bottom left is 3 and bottom right is 4. Subplot overrides x_start,
y_start, x_end, and y_end.
:param bool flip_ylabel: If True will move tics and labels of y-axis
to right instead of left. In case of False, it won't
"""
self.hist_collections = None
self.hist_data = [[], []]
self.fontsize = fontsize
self.color = color
self.figure = figure
self.subplot_pos = subplot_pos
if self.subplot_pos:
self.plot = self.figure.add_subplot(subplot_pos)
else:
self.plot = self.figure.add_axes(
[x_start, y_start, x_end - x_start, y_end - y_start])
self.plot.set_facecolor('white')
if title is not None:
self.plot.set_title(title, size=self.fontsize)
self.plot.set_ylabel(y_label, size=self.fontsize)
self.plot.set_xlabel(x_label, size=self.fontsize)
self.plot.tick_params(labelsize=self.fontsize)
if flip_ylabel:
self.plot.yaxis.tick_right()
self.plot.yaxis.set_label_position("right")
# Save default x/y limit, tick, and label
self.d_xticks = self.plot.get_xticks()
self.d_xticks = list(map(lambda x: round(x, 2), self.d_xticks))
self.default_xlim = self.plot.get_xlim()
self.d_yticks = self.plot.get_yticks()
self.d_yticks = list(map(lambda x: round(x, 2), self.d_yticks))
self.default_ylim = self.plot.get_ylim()
self.reset()
[docs] def replot(self, data, bins=10):
"""
Remove previous histogram (if exists), plot a new one, and force
the canvas to draw.
:param list data: list of data to plot histogram
:param int bins: number of bins for the histogram
"""
if self.hist_collections:
for bar in self.hist_collections:
bar.remove()
hist_counts, bin_edges, self.hist_collections = self.plot.hist(
data, color=self.color, bins=bins)
self.hist_data = [[
(a + b) / 2 for a, b in zip(bin_edges[:-1], bin_edges[1:])
], hist_counts]
half_bin_width = (bin_edges[1] - bin_edges[0]) / 2.
self.plot.set_xlim(bin_edges[0] - half_bin_width,
bin_edges[-1] + half_bin_width)
# 5% blank each side; 5 xticks
intvl = round(len(self.hist_data[0]) / 5.) or 1
xtick_values = [x for x in self.hist_data[0][0::intvl]]
self.plot.set_xticks(xtick_values)
self.plot.set_xticklabels([round(x, 2) for x in xtick_values])
# 10% blank on top; 5 - 8 yticks
ylim_max = max(hist_counts) * 1.1
ytick_intvl = max([int(float('%.1g' % (ylim_max * 2. / 5.)) / 2.), 1])
ytick_values = [
ytick_intvl * idx
for idx in range(math.ceil(ylim_max / ytick_intvl))
]
self.plot.set_ylim(0, ylim_max)
self.plot.set_yticks(ytick_values)
self.plot.set_yticklabels(ytick_values)
# Prevent ylabel overlapping in case of multiple plots
if self.subplot_pos:
self.figure.tight_layout()
[docs] def reset(self):
"""
Reset Histogram plot.
"""
if self.hist_collections is None:
return
for bar in self.hist_collections:
bar.remove()
self.hist_collections = None
# Reset x/y limit, tick, and label
self.plot.set_xlim(self.default_xlim)
self.plot.set_xticks(self.d_xticks)
self.plot.set_xticklabels(self.d_xticks)
self.plot.set_ylim(self.default_ylim)
self.plot.set_yticks(self.d_yticks)
self.plot.set_yticklabels(self.d_yticks)
self.hist_data = [[], []]
self.figure.tight_layout()
[docs]class SliderchartVLPlot(sliderchart.SliderPlot):
"""
Overide the SliderPlot class in sliderchart to provide vertical slide bars
and significant figure round.
"""
[docs] def __init__(self, **kwargs):
"""
See the parent class for documentation
"""
super().__init__(use_hsliders=False, **kwargs)
[docs] def setVsliderPosition(self, slider_id, value):
"""
Set the position of vertical sliders.
:type slider_id: int
:param slider_id: 0 means the left slider; 1 means the right one
:type value: float
:param value: The new x value to attempt to place the slider at
:rtype: float
:return: final slider position, corrected by x range and the other slider
"""
if not (self.x_range[0] < value < self.x_range[1]):
value = self.x_range[slider_id]
self.vsliders[slider_id].setPosition(value)
value = self.vsliders[slider_id].getPosition()
return value
[docs] def updateSlider(self, slider_idx, fit_edit=None, value=None, draw=True):
"""
Change the slider to the user typed position, read this new position,
and set the widget to this new position. At least one of value and
fit_edit must be provided, and only read fit_edit when value is not
provided.
:param fit_edit: swidgets.EditWithFocusOutEvent or None
:type fit_edit: The text of this widget defines one fitting boundary,
and the text may be changed according to the newly adjusted boundary.
:param slider_idx: int (0 or 1)
:type slider_idx: 0 --> left vertical slider; 1 --> right vertical slider;
:param value: float
:type value: set slider to value position, if not None
:param draw: bool
:type draw: force the canvas to draw
:rtype: float
:return: left or right slider position
"""
if value is None and fit_edit is not None:
try:
value = float(fit_edit.text())
except ValueError:
return
value = self.setVsliderPosition(slider_idx, value)
if fit_edit:
fit_edit.setText(msutils.sig_fig_round(value))
if draw:
self.canvas.draw()
return value
[docs] def getVSliderIndexes(self):
"""
Get the data indexes of the left and right vertical sliders. Requires
that the x data is sorted ascending.
:rtype: (int, int) or (None, None)
:return: The data indexes of the left and right vertical sliders, or
None, None if the sliders are outside the data range
"""
x_min = self.getVSliderMin()
for idx, val in enumerate(self.original_xvals):
if x_min <= val:
x_min_idx = idx
break
else:
return None, None
x_max = self.getVSliderMax()
for idx, val in enumerate(reversed(self.original_xvals), start=1):
if x_max >= val:
x_max_idx = len(self.original_xvals) - idx
break
else:
return None, None
return x_min_idx, x_max_idx
[docs] def removeSliders(self, draw=True):
"""
Remove vertical and horizontal sliders from the chart
:param bool draw: Whether canvas should be redrawn
"""
for sliders in (self.vsliders, self.hsliders):
for _ in range(len(sliders)):
sliders.pop(0).remove()
if draw:
self.canvas.draw()
[docs]class SliderchartVLFitStdPlot(SliderchartVLPlot):
"""
Inherits the SliderchartVLPlot class. Provides line fitting and std plotting.
"""
[docs] def __init__(self,
legend_loc='upper right',
fit_linestyle='dashed',
fit_linecolor='red',
fit_linewidth=2.,
data_label='Data',
std_label='STD',
fit_label='Fitting',
layout=None,
**kwargs):
"""
See the parent class for documentation
:type legend_loc: str
:param legend_loc: the location of the legend
:type fit_linestyle: str
:param fit_linestyle: style of the fitted line
:type fit_linecolor: str
:param fit_linecolor: color of the fitted line
:type fit_linewidth: float
:param fit_linewidth: linewidth of the fitted line
:type data_label: str
:param data_label: legend label for data
:type std_label: str
:param std_label: legend label for standard deviation
:type fit_label: float
:param fit_label: legend label for fitting line
:type layout: QLayout
:keyword layout: layout to place the SliderchartVLFitStdPlot in
"""
self.legend_loc = legend_loc
self.fit_linestyle = fit_linestyle
self.fit_linecolor = fit_linecolor
self.fit_linewidth = fit_linewidth
self.data_label = data_label
self.std_label = std_label
self.fit_label = fit_label
self.original_ystd = None
self.variation = None
self.fitted_line = None
self.poly_collections = None
self.legend = None
super().__init__(**kwargs)
self.default_y_label = self.y_label
self.default_x_label = self.x_label
self.default_title = self.title
if layout is not None:
layout.addWidget(self)
[docs] def reset(self):
"""
Reset the labels, title, and plot.
"""
self.y_label = self.default_y_label
self.x_label = self.default_x_label
self.title = self.default_title
self.setXYYStd([], [], replot=True)
[docs] def replot(self, fit_only=False, *args, **kwargs):
"""
See the parent class for documentation
:type fit_only: bool
:param fit_only: if True, only update the fitted line.
:rtype: namedtuple
:return: fitting parameters
"""
if self.fitted_line is not None:
self.fitted_line.remove()
self.fitted_line = None
if not fit_only:
if self.poly_collections is not None:
self.poly_collections.remove()
self.poly_collections = None
super().replot()
self.plotYSTd()
data_fit = self.plotFitting(*args, **kwargs)
self.updateLegend()
sliderchart.prevent_overlapping_x_labels(self.canvas)
return data_fit
[docs] def updateLegend(self):
"""
Update legend according to the plotted lines.
"""
legend_data, legend_txt = [], []
if self.original_ystd is not None:
legend_data.append(self.poly_collections)
legend_txt.append(self.std_label)
if self.fitted_line is not None:
legend_data.append(self.fitted_line)
legend_txt.append(self.fit_label)
if len(legend_txt):
legend_data = [self.series] + legend_data
legend_txt = [self.data_label] + legend_txt
self.legend = self.plot.legend(
legend_data, legend_txt, loc=self.legend_loc)
elif self.legend:
self.legend.remove()
self.legend = None
[docs] def plotFitting(self):
"""
To be overwritten in child class.
"""
raise NotImplementedError(
"`plotFitting' method not implemented by subclass")
[docs] def plotYSTd(self, color='green', alpha=0.5):
"""
Plot standard deviation as area.
:type color: str
:param color: area color
:type alpha: float
:param alpha: set area transparent
"""
if self.original_ystd is None:
return
ystd_num = len(self.original_ystd)
x = self.original_xvals[:ystd_num]
y = self.original_yvals[:ystd_num]
y_plus_std = y + self.original_ystd
y_minus_std = y - self.original_ystd
self.poly_collections = self.plot.fill_between(
x, y_minus_std, y_plus_std, color=color, alpha=alpha)
self.plot.set_ylim((min(y_minus_std), max(y_plus_std)))
[docs] def variationChanged(self, variation_edit, upper_tau_edit, min_data_num=10):
"""
Response to the variation widget change. Move the upper slider so that
the data between the two slider bars have coefficient of variation
smaller than the variation widget value.
:param variation_edit: swidgets.EditWithFocusOutEvent
:type variation_edit: The text of this widget defines the coefficient
of variation.
:param upper_tau_edit: swidgets.EditWithFocusOutEvent
:type upper_tau_edit: The text of this widget defines upper fitting boundary
:param min_data_num: int
:type min_data_num: The minimum number of data points in the fitting range
:raise ValueError: not enough data available
"""
if self.original_ystd is None:
return
variance_input = variation_edit.float()
variation_edit.clear()
ystd_num = len(self.original_ystd)
xvals = self.original_xvals[:ystd_num]
idx_bounds = []
for vslider in self.vsliders:
idx_bounds.append(numpy.abs(xvals - vslider.getPosition()).argmin())
availabel_data_point = idx_bounds[1] - idx_bounds[0]
if availabel_data_point < min_data_num:
raise ValueError(
'Only %s data points found, but a minium of %s is required.' %
(availabel_data_point, min_data_num))
# varince_input is given in percentage, convert to decimals
std_allow = variance_input / 100. - self.variation
# Starting from the frame end, find the first frame within the variation
# or set to the closest position near the left slider bar
for idx in range(idx_bounds[1], idx_bounds[0] + min_data_num, -1):
if std_allow[idx] > 0:
break
self.setVsliderPosition(1, xvals[idx])
sliderchart.prevent_overlapping_x_labels(self.canvas)
right_slider_pos = self.vsliders[1].getPosition()
upper_tau_edit.setText(msutils.sig_fig_round(right_slider_pos))
variation_edit.setText(
msutils.sig_fig_round(self.variation[idx] * 100.))
[docs] def setXYYStd(self,
xvals,
yvals,
ystd=None,
x_range=None,
y_range=None,
replot=True):
"""
Set the X values, Y values, and Y standard deviation of the plot.
:type xvals: list
:param xvals: the x values to plot
:type yvals: list
:param yvals: y series to plot, should be the same length as xvals
:type ystd: list or None
:param ystd: the standard deviation of y series to plot
:type x_range: tuple or None
:param x_range: (min, max) values for the X-axis, default is to show all
values
:type y_range: tuple or None
:param y_range: (min, max) values for the Y-axis, default is to show all
values
:type replot: bool
:param replot: True of plot should be redrawn (default), False if not.
False can be used if a subsequent setY is required.
"""
# Set self.original_ystd before plotYSTd() is called
# setXY() calls replot(), and replot() calls plotYSTd()
self.original_ystd = ystd
super().setXY(
xvals, yvals, x_range=x_range, y_range=y_range, replot=replot)
if ystd is None:
self.variation = None
return
ystd_num = len(self.original_ystd)
smooth_mean = ndimage.filters.gaussian_filter(
self.original_yvals[:ystd_num], sigma=3)
smooth_std = ndimage.filters.gaussian_filter(ystd, sigma=10)
# x/0. = Inf
self.variation = numpy.divide(
smooth_std,
smooth_mean,
out=numpy.full_like(smooth_mean, numpy.Inf),
where=smooth_mean != 0)
[docs] def setVarianceEdit(self, variance_le):
"""
Set the variance text and state.
:param variance_le: swidgets.EditWithFocusOutEvent
:type variance_le: The text of this widget shows the coefficient
of variation
"""
variance_le.clear()
if self.variation is None:
variance_le.setEnabled(False)
return
upper_tau = self.vsliders[1].getPosition()
xval_idx = (numpy.abs(self.original_xvals - upper_tau)).argmin()
# Convert into 'xx %' format
try:
value = self.variation[xval_idx] * 100.
except IndexError:
# Standard deviation is from block average and has less data points
return
variance_le.setEnabled(True)
variance_le.setText(msutils.sig_fig_round(value))
[docs]class StructureLoader(swidgets.SFrame):
"""
A set of widgets that allow the user to load a structure.
"""
structure_changed = QtCore.pyqtSignal()
WORKSPACE = 'Included entry'
FILE = 'From file'
BUTTON_TEXT = {WORKSPACE: 'Import', FILE: 'Browse...'}
NOT_LOADED = 'Not loaded'
NOT_LOADED_TIP = 'Structure is not yet loaded'
DIALOG_ID = 'STRUCTURE_LOADER_IR'
[docs] def __init__(self, master, label, maestro, parent_layout):
"""
Create StructureLoader object.
:type master: QWidget
:param master: Master widget
:type label: str
:param label: Label for the SLabeledComboBox widget
:type maestro: `schrodinger.maestro.maestro`
:param maestro: Maestro instance
:type parent_layout: QLayout
:param parent_layout: Parent layout
"""
self.master = master
self.maestro = maestro
self.struct = None
swidgets.SFrame.__init__(
self, layout=parent_layout, layout_type=swidgets.HORIZONTAL)
layout = self.mylayout
load_options = [self.WORKSPACE,
self.FILE] if self.maestro else [self.FILE]
self.combo = swidgets.SLabeledComboBox(
label,
items=load_options,
layout=layout,
stretch=False,
command=self.typeChanged,
nocall=True)
self.button = swidgets.SPushButton(
'Import', command=self.loadStructure, layout=layout)
self.label = swidgets.SLabel(self.NOT_LOADED, layout=layout)
self.label.setToolTip(self.NOT_LOADED_TIP)
layout.addStretch()
self.reset()
[docs] def typeChanged(self):
"""
React to a change in the type of scaffold
"""
self.button.setText(self.BUTTON_TEXT[self.combo.currentText()])
[docs] def reset(self):
"""
Reset the widgets
"""
self.struct = None
self.combo.reset()
self.typeChanged()
self.updateLabel()
[docs] def updateLabel(self):
"""
Update the status label.
"""
if self.struct:
text = self.struct.title
tip = text
if len(text) > 25:
text = text[:22] + '...'
else:
tip = self.NOT_LOADED_TIP
text = self.NOT_LOADED
self.label.setText(text)
self.label.setToolTip(tip)
[docs] def loadStructure(self):
"""
Load a structure from the selected source.
"""
self.struct = None
if self.combo.currentText() == self.WORKSPACE:
loaded = self.importFromWorkspace()
else:
loaded = self.importFromFile()
if loaded and self.struct and len(self.struct.atom) == 0:
self.struct = None
self.master.warning('The structure is empty, containing no atoms.')
elif loaded:
with qtutils.wait_cursor:
self.updateLabel()
self.structure_changed.emit()
[docs] def importFromWorkspace(self):
"""
Import a structure from the workspace.
:rtype: bool or None
:return: True if a structure was loaded successfully, None if not
"""
try:
self.struct = self.maestro.get_included_entry()
return True
except RuntimeError:
self.master.warning('There must be one and only one entry included'
' in the Workspace.')
[docs] def importFromFile(self):
"""
Import a structure from a file, including opening the dialog to allow
the user to select the file.
:rtype: bool or None
:return: True if the structure was loaded successfully, None if not
"""
ffilter = 'Maestro files (*.mae *.maegz *.mae.gz *cms *cms.gz)'
path = filedialog.get_open_file_name(
parent=self.master,
caption='Load Structure',
filter=ffilter,
id=self.DIALOG_ID)
if not path:
return
try:
self.struct = structure.Structure.read(path)
# To distinguish whenever the structure was loaded from PT or file
self.struct.property.pop('s_m_entry_id', None)
return True
except (IOError, mmcheck.MmException):
self.master.warning(
'Unable to read structure information from %s' % path)
[docs] def getMolecularWeight(self):
"""
Get the molecular weight of the structure.
:return: the molecular weight of the structure
:rtype: float
"""
if self.struct is None:
return 0.
return self.struct.total_weight
[docs]class WaterTypesComboBox(swidgets.SLabeledComboBox):
"""
Combobox containing all water molecule types available in desmondutils.
Replaces the user-facing 'None' with 'current'.
"""
[docs] def __init__(self, **kwargs):
"""
Create the combobox.
"""
water_types = [x.strip() for x in desmondutils.WATER_FFTYPES.keys()]
water_types_dict = {x: x for x in water_types if x != desmondutils.NONE}
water_types_dict['Current'] = desmondutils.NONE
kwargs['itemdict'] = water_types_dict
super().__init__('Water model:', **kwargs)
[docs]class PlaneSelectorMixin(object):
"""
Set of widgets to create plane and directional arrow using various methods
like best fit to selected atoms, crystal vector, and plane using 3 atoms.
"""
NPX = numpy.array(transform.X_AXIS)
NPY = numpy.array(transform.Y_AXIS)
NPZ = numpy.array(transform.Z_AXIS)
NPO = numpy.zeros(3, float)
FIT_SELECTED_ATOMS = 'Best fit to selected atoms'
CRYSTAL_VECTOR = 'Crystal vector:'
CHOOSE_ATOMS = 'Choose at least 3 atoms to define the plane'
PLANE_SCALE = 2.0
[docs] def methodToggled(self):
"""
React to the plane determination method being changed
"""
if self.struct is None:
return
method = self.method_rbg.checkedText()
self.cvec_frame.setEnabled(method == self.CRYSTAL_VECTOR)
self.pick_frame.setEnabled(method == self.CHOOSE_ATOMS)
if method != self.CHOOSE_ATOMS:
self.clearPicked()
self.pick3_cb.reset()
self.computePlaneFromEntry()
[docs] def flipDirection(self):
"""
Flip the direction of the interface normal
"""
vector = -self.full_vector
if not self.vector_set_by_atoms:
origin = xtal.find_origin_on_structure_exterior(self.struct, vector)
else:
origin = self.vector_origin
self.defineNewNormal(vector=vector, origin=origin, allow_flip=False)
[docs] def pickToggled(self):
"""
React to a change in state of the pick atom checkbox
"""
if self.pick3_cb.isChecked():
self.picked_atoms = set()
[docs] def atomPicked(self, asl):
"""
React to the user picking another atom while defining the plane
:type asl: str
:param asl: The asl defining the picked atom
"""
struct = maestro.workspace_get()
self.picked_atoms.update(analyze.evaluate_asl(struct, asl))
self.marker.setAsl(
'atom.n ' + ','.join([str(x) for x in self.picked_atoms]))
self.marker.show()
if len(self.picked_atoms) > 2:
self.computePlaneFromAtoms(struct=struct, atoms=self.picked_atoms)
[docs] def clearPicked(self):
"""
Clear all the picked atom information, including the WS markers
"""
if self.marker:
self.marker.setAsl('not all')
self.picked_atoms = set()
[docs] def computePlaneFromAtoms(self, struct=None, atoms=None):
"""
Compute the interface plane as the best fit plane to a set of atoms
:type struct: `schrodinger.structure.Structure`
:param struct: The structure containing the atoms. If not given, the
previously loaded structure will be used.
:type atoms: list
:param atoms: List of atom indexes of the atoms to fit. If not given,
all atoms will be fit.
"""
if not struct:
struct = self.struct
if not atoms:
atoms = maestro.selected_atoms_get()
if not atoms:
maestro.command('workspaceselectionreplace all')
atoms = list(range(1, struct.atom_total + 1))
self.vector_set_by_atoms = False
else:
self.vector_set_by_atoms = True
coords = numpy.array([struct.atom[a].xyz for a in atoms])
try:
normal = measure.fit_plane_to_points(coords)
except ValueError:
self.warning('There must be at least 3 atoms for a planar '
'interface')
return
except measure.LinearError:
self.warning('At least one of the 3 points must not be colinear')
return
except numpy.linalg.LinAlgError:
self.warning('Unable to find a best fit plane to these atoms')
return
origin = numpy.array(transform.get_centroid(struct, list(atoms))[:3])
self.defineNewNormal(vector=normal, origin=origin)
[docs] def updateArrowAndPlane(self):
"""
Update the workspace arrow and plane to the new coordinates.
"""
head = self.full_vector + self.vector_origin
if not self.arrow:
try:
self.createArrow(head, self.vector_origin)
except schrodinger.MaestroNotAvailableError:
return
else:
self.arrow.xhead = head[0]
self.arrow.yhead = head[1]
self.arrow.zhead = head[2]
self.arrow.xtail = self.vector_origin[0]
self.arrow.ytail = self.vector_origin[1]
self.arrow.ztail = self.vector_origin[2]
self.createPlane()
[docs] def setStructure(self, struct):
"""
Set the scaffold structure that will define the interface plane
:type struct: `schrodinger.structure.Structure`
:param struct: The scaffold structure that will define the interface
"""
self.struct = struct
if struct:
has_props = desmondutils.has_chorus_box_props(self.struct)
# Enable the crystal lattice widgets if possible
self.cframe.setEnabled(has_props)
if has_props:
button = self.CRYSTAL_VECTOR
else:
button = self.FIT_SELECTED_ATOMS
self.method_rbg.setTextChecked(button)
self.computePlaneFromEntry()
else:
self.cleanUp()
[docs] def loadStructureIntoWorkspace(self):
"""
Put the loaded structure into the workspace so the user can view it
"""
# Clear out the workspace but remember what was in it
ptable = maestro.project_table_get()
self.previous_inclusion = []
for row in ptable.included_rows:
# Saving the in_workspace value allows us to preserve fixed entries
self.previous_inclusion.append((row.entry_id, row.in_workspace))
row.in_workspace = project.NOT_IN_WORKSPACE
self.modified_project = ptable.fullname
# Put the structure in the workspace
temprow = ptable.importStructure(self.struct, wsreplace=True)
self.temprow_id = temprow.entry_id
[docs] def crystalVectorPicked(self):
"""
React to the user choosing one of the crystal lattice vectors to define
the plane
"""
if not self.method_rbg.checkedText() == self.CRYSTAL_VECTOR:
# The group was just reset, don't react as this option isn't chosen
return
self.computePlaneFromEntry(use_selected_xtal_vector=True)
[docs] def computePlaneFromEntry(self, use_selected_xtal_vector=False):
"""
Compute the interface plane based on an entire entry. In order of
preference, this would be the crystal lattice vector most parallel with
the moment of inertia. If the lattice vectors are not known, then we fit
a plane to the entire structure. In either case, we then move the plane
so that the entry lies entirely on one side of the plane and slide the
vector to be right over the centroid of the entry.
:type use_selected_xtal_vector: bool
:param use_selected_xtal_vector: Instead of picking the best plane based
on a heirarchy of options, use the one defined by the currently selected
crystal lattice vector.
"""
self.vector_set_by_atoms = False
# Find our best guess for plane
if self.method_rbg.checkedText() == self.CRYSTAL_VECTOR:
if use_selected_xtal_vector:
btext = self.crystal_rbg.checkedText()
else:
if not self.best_btext:
self.setBestXtalVectorProperty()
self.crystal_rbg.setTextChecked(self.best_btext)
btext = self.best_btext
vec = xtal.extract_chorus_lattice_vector(self.struct, btext)
norm_vec = transform.get_normalized_vector(vec)
vorigin = xtal.find_origin_on_structure_exterior(
self.struct, norm_vec)
self.defineNewNormal(origin=vorigin, vector=norm_vec)
return
if self.struct.atom_total < 3:
self.warning('Cannot define plane for a structure with fewer '
'than two atoms.')
return
try:
self.computePlaneFromAtoms()
except numpy.linalg.LinAlgError:
# Unable to guess a plane, go with the default Z-Axis
self.full_vector = self.NPZ.copy()
except measure.LinearError:
self.warning('Unable to define a plane for a structure with 3 '
'co-linear atoms.')
return
# Pick a vector origin that is on the exterior of the structure
vorigin = xtal.find_origin_on_structure_exterior(
self.struct, self.full_vector)
# Display the normal vector
self.defineNewNormal(origin=vorigin)
[docs] def pointVectorAwayFromStructure(self, struct, vector, origin):
"""
Pick the 180 degree direction of the vector that points it away from the
centroid of the given structure
:type struct: `schrodinger.structure.Structure`
:param struct: The structure to point the vector away from
:type vector: `numpy.array`
:param vector: The vector to potentially flip 180
:type origin: `numpy.array`
:param origin: The point the vector will originate from
:rtype: `numpy.array`
:return: The given vector, possibly flipped 180 degrees so that it
points away from the given structure
"""
centroid = transform.get_centroid(struct)[:3]
oc_vec = centroid - origin
if vector.dot(oc_vec) > 0:
return -vector
return vector
[docs] def createPlane(self):
"""
Create or update the square in the workspace that represents the
interface plane
"""
if self.plane:
self.group.remove(self.plane)
self.plane = None
# Find a vector perpendicular to the normal vector
xaxis = self.NPX.copy()
raw_perp = numpy.cross(self.full_vector, xaxis)
if not transform.get_vector_magnitude(raw_perp):
# Vector is parallel with the X-axis
yaxis = self.NPY.copy()
raw_perp = numpy.cross(self.full_vector, yaxis)
# First vector in the plane
four_vectors = [transform.get_normalized_vector(raw_perp)]
# Second vector is perpendicular to the normal and the first vector
raw_perp2 = numpy.cross(self.full_vector, four_vectors[0])
four_vectors.append(transform.get_normalized_vector(raw_perp2))
# Third vector is just 180 degrees from the first
four_vectors.append(-four_vectors[0])
# Fourth vector is just 180 degrees from the second
four_vectors.append(-four_vectors[1])
vertices = []
for vec in four_vectors:
scaled_vec = self.PLANE_SCALE * self.arrow_length * vec + self.vector_origin
# Convert to list as MaestroPolygon needs a list rather than numpy
# array for each vertex
vertices.append(list(scaled_vec))
# Complete the full circuit by adding the first point to the end
vertices.append(vertices[0][:])
self.plane = polygon.MaestroPolygon(
vertices, color='yellow', opacity=0.75)
self.group.add(self.plane)
self.group.show()
[docs] def createArrow(self, head_coords, tail_coords):
"""
Create the arrow that represents the interface plane normal in the
workspace
:type head_coords: `numpy.array`
:param head_coords: The coordinates of the tip of the arrow
:type tail_coords: `numpy.array`
:param tail_coords: The coordinates of the base of the arrow
"""
if self.arrow:
self.group.remove(self.arrow)
self.arrow = None
hx, hy, hz = head_coords
tx, ty, tz = tail_coords
radius = MINIMUM_PLANE_NORMAL_LENGTH / 10.0
self.arrow = arrow.MaestroArrow(
xhead=hx,
yhead=hy,
zhead=hz,
xtail=tx,
ytail=ty,
ztail=tz,
color='orange',
radius=radius)
self.group.add(self.arrow)
self.group.show()
[docs] def defineNewNormal(self, vector=None, origin=None, allow_flip=True):
"""
Store the new normal vector and origin for the interface plane,
optionally updating the workspace graphics to show the new vector/plane
:type vector: `numpy.array`
:param vector: The new xyz values of the plane normal vector. If not
given, the previous vector will be used.
:type origin: `numpy.array`
:param origin: The new origin of the vector. If not given, the previous
origin will be used.
:type allow_flip: bool
:param allow_flip: Whether to potentially flip the vector 180 degrees so
that it points away from the structure
"""
# This just makes a bigger arrow for bigger scaffolds - helps it be more
# visible
norm_len = self.struct.atom_total / 100.0 if self.struct else 0
self.arrow_length = max(MINIMUM_PLANE_NORMAL_LENGTH, norm_len)
if not self.struct:
return
if vector is None:
vector = self.full_vector
if origin is None:
origin = self.unbuffered_origin
vector = numpy.array(vector)
normvec = transform.get_normalized_vector(vector)
self.unbuffered_origin = origin
origin = origin + self.getBuffer() * normvec
if allow_flip:
normvec = self.pointVectorAwayFromStructure(self.struct, normvec,
origin)
self.full_vector = self.arrow_length * normvec
self.vector_origin = origin
self.updateArrowAndPlane()
[docs] def setBestXtalVectorProperty(self):
"""
Set the crystal lattice vector that is most parallel with the largest
moment of inertia of the loaded structure in best_btext. This vector
most likely aligns with the desired interface plane normal vector.
"""
inertial_vec = analyze.get_largest_moment_normalized_vector(
struct=self.struct, massless=True)
# Now find the crystal lattice vector that is most parallel (or
# antiparallel) with the largest moment of inertia
largest_dotp = -1.
for btext in LATTICE_VECTOR_LABELS:
vec = xtal.extract_chorus_lattice_vector(self.struct, btext)
norm = transform.get_normalized_vector(vec)
# Larger dot product == more parallel
dotp = abs(inertial_vec.dot(norm))
if dotp > largest_dotp:
largest_dotp = dotp
best_btext = btext
self.best_btext = best_btext
[docs] def cleanUp(self):
"""
Clean up the everything in the workspace from this dialog, including
restoring the molecules that were in the workspace prior to it opening.
Also resets some properties to their default values
"""
def _final_cleanup():
self.temprow_id = None
self.modified_project = None
self.previous_inclusion = []
self.cleanArrowAndPlanes()
self.picker.stop()
try:
ptable = maestro.project_table_get()
except (schrodinger.MaestroNotAvailableError, project.ProjectException):
_final_cleanup()
return
if ptable.fullname == self.modified_project and self.temprow_id:
# Delete our temporary project entry and reload the workspace state
row = ptable.getRow(self.temprow_id)
# It's possible the row might no longer exist if the user has closed
# a temporary project (closing a temporary project doesn't change
# the current project name, so the above project.fullname check
# will still pass) or if the user has manually deleted our
# temporary entry.
if row:
row.in_workspace = project.NOT_IN_WORKSPACE
ptable.deleteRow(self.temprow_id)
# This update prevents a stale row from sticking around in the PT
ptable.update()
for eid, state in self.previous_inclusion:
row = ptable.getRow(entry_id=eid)
if row:
row.in_workspace = state
_final_cleanup()
[docs] def cleanArrowAndPlanes(self):
"""
Remove the arrow and markers from the workspace
"""
self.group.hide()
self.group.clear()
self.arrow = None
self.plane = None
self.best_btext = None
self.marker.hide()
self.marker.setAsl('not all')
[docs] def resetFrame(self):
"""
Reset the dialog widgets and clean up the workspace
"""
self.cleanUp()
# Reset default values
self.arrow_length = MINIMUM_PLANE_NORMAL_LENGTH
self.full_vector = self.NPZ.copy()
self.vector_origin = self.NPO.copy()
self.pick3_cb.reset()
if self.struct:
self.loadStructureIntoWorkspace()
if self.cframe.isEnabled():
button = self.CRYSTAL_VECTOR
else:
button = self.FIT_SELECTED_ATOMS
self.crystal_rbg.reset()
self.method_rbg.setTextChecked(button)
self.methodToggled()
[docs]class LipidImporter(swidgets.SFrame):
"""
Manage importing a forcefield supported lipid into the Maestro workspace.
"""
lipidImported = QtCore.pyqtSignal()
[docs] def __init__(self, layout=None, label=None, command=None):
"""
Create an instance.
:type layout: QLayout or None
:param layout: the layout to which this widget
will be added or None if there isn't one
:type label: str or None
:param label: the label of the button or None if the default
is to be used
:type command: function or None
:param command: a function to call on lipid import or None
if none to call
"""
super().__init__(layout_type=swidgets.HORIZONTAL, layout=layout)
self.st_dict = desmondutils._get_lipid_ff_st_dict()
label = label or 'Import Lipid'
swidgets.SPushButton(
label, layout=self.mylayout, command=self.importLipid)
items = sorted(self.st_dict.keys())
self.lipid_combo = swidgets.SComboBox(
items=items, nocall=True, layout=self.mylayout)
self.mylayout.addStretch()
if command:
self.lipidImported.connect(command)
[docs] def getStructure(self):
"""
Return the structure for the chosen lipid.
:rtype: schrodinger.structure.Structure
:return: the structure
"""
title = self.lipid_combo.currentText()
return self.st_dict[title]
[docs] def importLipid(self):
"""
Import the lipid into the Maestro workspace.
"""
if not maestro:
return
struct = self.getStructure()
p_table = maestro.project_table_get()
p_table.importStructure(struct, wsreplace=True)
self.lipidImported.emit()
[docs] def reset(self):
"""
Reset.
"""
self.lipid_combo.reset()
[docs]def locate_file_from_link(row,
linkprop=jaguarworkflows.JAGUAR_OUTPATH_PROP,
fix_link=True,
parent=None,
ext='.out',
idtag='locate_file',
caption='Locate File',
source_subdir=None):
"""
Locate the the requested file from the given project row property. If it
isn't at the linked location, check the source directory for this row. If it
still can't be found, a file dialog will be posted for the user to locate
it.
:type row: `schrodinger.structure.Structure` or
`schrodinger.project.ProjectRow`
:param row: The project row or structure with the info
:param str linkprop: The property that gives the file link
:param bool fix_link: If True, the linkprop will be fixed with any new file
location found
:param `QWidget` parent: Any file dialog will be centered over this widget.
If not given, no file dialog will be posted.
:param str ext: The extension (including '.') of the file. Used only by the
file dialog.
:param str idtag: The id for the file dialog
:param str caption: The caption for the file dialog
:type source_subdir: str or True
:param str source_subdir: If given and the source directory property is
used to locate the file, look in this subdirectory of the source
directory. If source_subdir is simply True, then if file doesn't exists
in the current directory, the subdir name is taken from the final
directory in the path obtained from linkprop. For instance, if
source_subdir=True and the path obtained from linkprop is /a/b/c/d,
d will be the file name and c will be the source_subdir. If the source
path is used and found to be /e/f/g, the file looked for will be
/e/f/g/c/d.
:rtype: str or None
:return: The path to the file if found, otherwise None
"""
# Get path directly from link property
path = row.property.get(linkprop, "")
if os.path.exists(path):
return path
# Build path from row source path
if path:
# Grab the file name from the linkprop path
link_directory, file_name = os.path.split(path)
# Build the source path
source_path = jobutils.get_source_path(row)
if source_subdir and source_subdir is not True:
# source_subdir is a path
source_path = os.path.join(source_path, source_subdir)
path = os.path.join(source_path, file_name)
if not os.path.exists(path):
path = None
# Try the last subdir in the linkprop path
if source_subdir is True:
source_subdir = os.path.basename(link_directory)
path = os.path.join(source_path, source_subdir, file_name)
if not os.path.exists(path):
path = None
# Ask the user to locate the file
if parent and not path:
path = filedialog.get_open_file_name(
parent=parent, caption=caption, filter=f'File (*{ext})', id=idtag)
# Update the link property so we can find the file next time
if path and fix_link and linkprop:
row.property[linkprop] = path
return path
[docs]class Stepper(swidgets.SFrame):
"""
A set of widgets that allow inputting a start, stepsize and number of points
"""
StepperData = namedtuple('StepperData', ['start', 'num', 'stepsize'])
ADD_TIP = ('This increment will be added to the previous\n'
'step value to get the new step value.')
MULT_TIP = ('This multiplier will be multiplied times the previous\n'
'step value to get the new step value.')
[docs] def __init__(self,
label,
units,
start,
points,
step,
parent_layout,
multiple=False):
"""
Create a Stepper instance
:param str label: The label for the line of widgets
:param str units: The label to put after the starting and stepsize value
widgets
:param float start: The initial starting value
:param int points: The initial number of points
:param float step: The initial stepsize
:param `swidgets.SBoxLayout` parent_layout: The layout to place the
Stepper into
:param bool multiple: Whether the step is a multiplier (True) or
additive (False)
"""
super().__init__(layout_type=swidgets.HORIZONTAL, layout=parent_layout)
layout = self.mylayout
swidgets.SLabel(label, layout=layout)
self.multiple = multiple
# Start
st_dator = swidgets.SNonNegativeRealValidator(bottom=0.01, decimals=2)
self.start_edit = swidgets.SLabeledEdit(
'Start:',
edit_text=str(start),
after_label=units,
always_valid=True,
validator=st_dator,
stretch=False,
width=60,
layout=layout)
# Number of points
self.num_sb = swidgets.SLabeledSpinBox(
'Number of steps:',
minimum=1,
maximum=999,
value=points,
stretch=False,
nocall=True,
command=self.numPointsChanged,
layout=layout)
# Stepsize
step_dator = swidgets.SNonNegativeRealValidator()
if self.multiple:
step_what = 'multiplier'
step_unit = None
tip = self.MULT_TIP
else:
step_what = 'increment'
step_unit = units
tip = self.ADD_TIP
self.step_edit = swidgets.SLabeledEdit(
f'Step {step_what}:',
edit_text=str(step),
after_label=step_unit,
always_valid=True,
validator=step_dator,
stretch=False,
width=40,
layout=layout,
tip=tip)
self.numPointsChanged(self.num_sb.value())
layout.addStretch(1000)
[docs] def numPointsChanged(self, value):
"""
React to the number of points changing
:param int value: The current number of points
"""
self.step_edit.setEnabled(value != 1)
[docs] def getData(self):
"""
Get the current settings
:rtype: `StepperData`
:return: The current settings
"""
return self.StepperData(
start=self.start_edit.text(),
num=self.num_sb.text(),
stepsize=self.step_edit.text())
[docs] def reset(self):
""" Reset the widgets """
self.start_edit.reset()
self.num_sb.reset()
self.step_edit.reset()
[docs] def values(self):
"""
A generator for the values produced by the current settings
:rtype: generator of float
:return: Each item generated is a value defined by the current settings
"""
data = self.getData()
start = float(data.start)
step = float(data.stepsize)
num = int(data.num)
for stepnum in range(num):
if self.multiple:
yield start * step**stepnum
else:
yield start + step * stepnum
[docs]class MultiComboWithCount(multi_combo_box.MultiComboBox):
"""
A MultiComboBox that shows the number of selected items as the combobox
text instead of item names
"""
[docs] def __init__(self,
text=None,
item_name='item',
layout=None,
items=None,
command=None,
stretch=True,
width=80,
**kwargs):
"""
Create a MultiComboWithCount instance
:param `QBoxLayout` layout: The layout for this widget
:param list items: The items to add to the combobox
:param callable command: The command to call when the selection changes
"""
super().__init__(**kwargs)
self.item_name = item_name
if items:
self.addItems(items)
self.frame = swidgets.SFrame(
layout_type=swidgets.HORIZONTAL, layout=layout)
rlayout = self.frame.mylayout
if text:
self.label = swidgets.SLabel(text, layout=rlayout)
rlayout.addWidget(self)
if stretch:
rlayout.addStretch()
self.setMinimumWidth(width)
self.popupClosed.connect(self.changeToolTip)
self.selectionChanged.connect(self.recordChange)
self.has_new_selection = False
self.command = command
[docs] def recordChange(self, *args):
""" Record that the selection has changed """
self.has_new_selection = True
[docs] def currentText(self):
"""
Override the parent class to only show the number of selected items
rather than all the item names
:rtype: str
:return: The text to display in the combobox
"""
num_selected = len(self.getSelectedItems())
if not num_selected:
return 'None'
else:
noun = INFLECT_ENGINE.plural(self.item_name, num_selected)
return f'{num_selected} {noun}'
[docs]def fill_table_with_data_frame(table, data_frame):
"""
Fill the passed QTableWidget with data from the passed pandas data frame
:param QTableWidget table: The table to fill
:param `pandas.DataFrame` data_frame: The data frame to read values from
"""
num_rows = data_frame.shape[0]
columns = data_frame.columns
table.setColumnCount(len(columns))
table.setHorizontalHeaderLabels(columns)
table.setRowCount(0)
table.setSortingEnabled(False)
for row in range(num_rows):
table.insertRow(row)
for col in range(len(columns)):
item = swidgets.STableWidgetItem(
editable=False, text=str(data_frame.iat[row, col]))
table.setItem(row, col, item)
table.setSortingEnabled(True)
[docs]@contextmanager
def disable_sort(table):
"""
CM to disable sorting on a table.
:type table: QTableWidget
:param table: the table
"""
# see MATSCI-9505 - sorting should be disabled while rows are changing
table.setSortingEnabled(False)
try:
yield
finally:
table.setSortingEnabled(True)