"""
Contains widgets that are useful in MatSci panels.
Copyright Schrodinger, LLC. All rights reserved.
"""
import enum
import json
import math
import os
import time
from collections import OrderedDict
from collections import namedtuple
from functools import partial
import inflect
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.matsci import codeutils
from schrodinger.application.matsci import gutils
from schrodinger.application.matsci import msconst
from schrodinger.application.matsci import parserutils
from schrodinger.application.matsci.msutils import get_default_forcefield
from schrodinger.application.matsci.nano import xtal
from schrodinger.graphics3d import arrow
from schrodinger.graphics3d import common as graphics_common
from schrodinger.graphics3d import polygon
from schrodinger.infra import mm
from schrodinger.infra import mmcheck
from schrodinger.math import mathutils
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 atomselector
from schrodinger.ui.qt import filedialog
from schrodinger.ui.qt import input_selector
from schrodinger.ui.qt import multi_combo_box
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt import utils as qtutils
from schrodinger.ui.qt import widgetmixins
from schrodinger.ui.qt.forcefield import ForceFieldSelector
from schrodinger.utils import preferences
from schrodinger.utils import units
from . import controlicons_rc # noqa: F401, pylint: disable=unused-import
from . import desmondutils
from . import msutils
# 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'] = parserutils.SAVE_CMS
SAVE_COMBO_OPTIONS['CMS and trajectory'] = parserutils.SAVE_TRJ
LATTICE_VECTOR_LABELS = ['a', 'b', 'c']
MINIMUM_PLANE_NORMAL_LENGTH = 2.0
INFLECT_ENGINE = inflect.engine()
maestro = schrodinger.get_maestro()
MOVED_VARIABLES = ( # module, remove_release, variables
('jagwidgets', '22-2', {
'NO_SOLVENT', 'SOLVENT_KEY', 'MODEL_KEY', 'CompactSolventSelector',
'SolventDialog', 'EmbeddedSolventWidget'
}),)
def __getattr__(name):
"""
If a variable doesn't exist in the module, check the moved variables
:param str name: The variable name
:raise AttributeError: If `name` is not a moved variable
:rtype: Any
:return: The moved variable
"""
try:
return codeutils.check_moved_variables(name, MOVED_VARIABLES)
except AttributeError:
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
[docs]class SavedASLItem(QtWidgets.QWidget):
"""
Widget that includes atomselector.ASLItem and a button for deleting the asl
"""
itemClicked = QtCore.pyqtSignal(str, str)
deleteClicked = QtCore.pyqtSignal(str)
[docs] def __init__(self, text, asl):
"""
:param str text: Display text of the asl item
:param str asl: Asl of the item
"""
super().__init__()
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 2, 5, 2)
asl_item = atomselector.ASLItem(text, asl, self)
asl_item.clicked.disconnect()
asl_item.clicked.connect(partial(self.itemClicked.emit, asl, text))
layout.addWidget(asl_item)
self.asl_item = asl_item
self.del_btn = swidgets.DeleteButton(layout=layout,
tip='Delete this asl',
command=partial(
self.deleteClicked.emit, text))
[docs]class MdAtomSelector(atomselector.AtomSelector, widgetmixins.MessageBoxMixin):
"""
Modify the parent class to support saving ASLs, make the reset button clear
asl text only, and add a status label if needed.
"""
ASL_NOT_ALLOWED = set(gutils.DISALLOWED_ASL_FLAGS) | {'set'}
# Remove non-matsci asls (MATSCI-10553)
ASL_ITEMS = {
name: asl
for name, asl in atomselector.AtomSelector.ASL_ITEMS.items()
if 'Ligand' not in name
}
PREF_KEY = 'asl_selector_saved_asls'
PLUS_TIP_WITH_SAVE = gutils.format_tooltip(
'Click to choose from saved ASLs, the Workspace selection or predefined'
' atom sets, or to open the Atom Selection dialog')
[docs] def __init__(self,
master,
title,
status_label=True,
get_name_func=None,
set_name_func=None,
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.
:param callable get_name_func: Function to call to get the suggested
name for the asl. Should take no args and return a string.
:param callable set_name_func: Function to call to set the name for the
selected saved ASL. Should take a string arg.
: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
self.get_name_func = get_name_func
self.set_name_func = set_name_func
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.setupSaveWidgets()
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()
def _addPopupMenuItems(self):
"""
Add the asl menu items to the popup menu. Overwrite parent method to
add saved ASLs submenu
"""
self.saved_asls_submenu = QtWidgets.QMenu('Saved ASLs',
self.popup_widget)
self.popup_widget.addMenu(self.saved_asls_submenu)
self.popup_widget.addSeparator()
super()._addPopupMenuItems()
[docs] def updateSavedASLs(self):
"""
Update the saved asl items in the menu
"""
self.saved_asls_submenu.clear()
asl_dict = self.getSavedASLs()
self.saved_asls_submenu.setEnabled(len(asl_dict) > 0)
for name in sorted(asl_dict, key=lambda x: x.lower()):
saved_item = SavedASLItem(name, asl_dict[name])
saved_item.itemClicked.connect(self.aslItemClicked)
saved_item.deleteClicked.connect(self.popSavedASL)
qtutils.add_widget_to_menu(saved_item, self.saved_asls_submenu)
[docs] def aslItemClicked(self, asl, text=None):
"""
Overwrite parent method to update the name in addition to the asl
:param str asl: The asl for the item that was clicked
:param str text: The name for the item that was clicked
"""
super().aslItemClicked(asl)
if self.set_name_func and text:
self.set_name_func(text)
[docs] def saveASL(self):
"""
Save the current asl using the name the user provides
"""
asl = self.getAsl()
default_name = (self.get_name_func() if self.get_name_func else asl)
asl_dict = self.getSavedASLs()
name_dlg = NewNameDialog(self,
default_name,
list(asl_dict),
title='New ASL')
if name_dlg.exec():
name = name_dlg.name_le.text()
asl_dict[name] = asl
self.dumpSavedAsls(asl_dict)
[docs] def popSavedASL(self, name):
"""
Remove the saved asl with this name
:param str name: The asl name to remove
"""
if self.question('Are you sure you want to delete this ASL?'):
asl_dict = self.getSavedASLs()
asl_dict.pop(name)
self.dumpSavedAsls(asl_dict)
[docs] def getSavedASLs(self):
"""
Get all saved asls from preferences
:return dict: Dict where keys are names and values are asls
"""
json_str = self.prefs.get(self.PREF_KEY, None)
if json_str is None:
return {}
return json.loads(json_str)
[docs] def dumpSavedAsls(self, asl_dict):
"""
Save all saved asls to preferences
:param dict asl_dict: dict where keys are names and values are asls
"""
# supports 1e6+ characters
self.prefs.set(self.PREF_KEY, json.dumps(asl_dict))
[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 = set(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()
@property
def icons(self):
"""
:rtype: dict(str=QtGui.QIcon)
:return: dictionary whose keys are the names of the stage icons and
whose values are the icon widgets
"""
if not self._icons:
icon_prefix = ":/schrodinger/application/matsci/msicons/"
icon_images = {
DOWN: icon_prefix + 'down.png',
UP: icon_prefix + 'up.png',
OPEN: icon_prefix + 'plus.png',
CLOSE: icon_prefix + 'minus.png',
DELETE: icon_prefix + 'ex.png',
COPY: icon_prefix + 'copy.png',
}
# Update the class variable so that we only make one set of icons
# for all the frame instances to share
for name, image_path in icon_images.items():
self._icons[name] = QtGui.QIcon(image_path)
return self._icons
[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 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:
self.toggle_button.setIcon(self.icons[CLOSE])
else:
self.toggle_frame.hide()
if self.toggle_button:
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,
start_staged=True):
"""
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.
:param bool start_staged: Whether or not resetting this widget should
populate the area with a stage. Defaults to `True`.
"""
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()
# Initialize stages
self.stages = []
self.stage_class = stage_class
self.start_staged = start_staged
# 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)
if self.start_staged:
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_yscale('linear')
self.plot.set_xscale('linear')
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(mathutils.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 Dev',
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(mathutils.sig_fig_round(right_slider_pos))
variation_edit.setText(
mathutils.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(mathutils.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, max_title_len=25):
"""
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
:param int max_title_len: Maximum lenght of the loaded entry label
"""
self.master = master
self.maestro = maestro
self.struct = None
self.max_title_len = max_title_len
super().__init__(layout=parent_layout)
load_options = [self.WORKSPACE, self.FILE
] if self.maestro else [self.FILE]
hlayout = swidgets.SHBoxLayout(layout=self.mylayout)
# We want the ability to hide combobox but not its label, that is why
# label and combobox is used and not labeledcombobox.
self.combo_label = swidgets.SLabel(label, layout=hlayout)
self.combo = swidgets.SComboBox(items=load_options,
layout=hlayout,
command=self.typeChanged,
nocall=True)
self.button = swidgets.SPushButton('Import',
command=self.loadStructure,
layout=hlayout)
self.label = swidgets.SLabel(self.NOT_LOADED, layout=hlayout)
self.label.setToolTip(self.NOT_LOADED_TIP)
hlayout.addStretch()
[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) > self.max_title_len:
text = text[:self.max_title_len - 3] + '...'
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
self.updateLabel()
if self.combo.currentText() == self.WORKSPACE:
ret = self.importFromWorkspace()
else:
ret = self.importFromFile()
if ret is None:
return
if ret[0] is False:
self.master.error(ret[1])
return
struct = ret[1]
valid = self.validate(struct)
if valid is not True:
self.master.error(valid[1])
return
self.struct = struct
with qtutils.wait_cursor:
self.updateLabel()
self.structure_changed.emit()
[docs] def validate(self, struct):
"""
Validate structure.
:param structure.Structure struct: Structure to be validated
:rtype: bool or bool and str
:return: True if everything is fine, or False and error message
"""
if len(struct.atom) == 0:
return False, 'The structure is empty, containing no atoms.'
return True
[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:
return True, self.maestro.get_included_entry()
except RuntimeError:
msg = ('There must be one and only one entry included in the '
'Workspace.')
return False, msg
[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:
struct = structure.Structure.read(path)
# To distinguish whenever the structure was loaded from PT or file
struct.property.pop('s_m_entry_id', None)
except (IOError, mmcheck.MmException):
return False, 'Unable to read structure information from %s' % path
return True, struct
[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 DesmondMDWEdit(swidgets.EditWithFocusOutEvent):
""" The standard edit used by DesmondMDWidgets """
LE_WIDTH = 80
BOTTOM_DATOR = 1e-10
[docs] def __init__(self, *args, **kwargs):
"""
Create a DesmondMDWEdit instance
See parent class for additional documentation
"""
kwargs.setdefault('width', self.LE_WIDTH)
kwargs.setdefault('always_valid', True)
kwargs.setdefault('validator', self.getValidator())
super().__init__(*args, **kwargs)
self.setMinimumWidth(kwargs['width'] - 20)
[docs] def getValidator(self):
"""
Get the validator for this edit
:rtype: `swidgets.SNonNegativeRealValidator`
:return: The validator to use
"""
return swidgets.SNonNegativeRealValidator(bottom=self.BOTTOM_DATOR)
[docs]class WaterTypesComboBox(swidgets.SLabeledComboBox):
"""
Combobox containing all water molecule types available in msconst.
Replaces the user-facing 'None' with 'current'.
"""
[docs] def __init__(self, ff_combo_box=None, **kwargs):
"""
Create the combobox.
:type ff_combo_box: QtWidgets.QComboBox
:param ff_combo_box: Forcefield QComboBox widget
"""
water_types = [x.strip() for x in msconst.WATER_FFTYPES.keys()]
water_types_dict = {x: x for x in water_types if x != msconst.NONE}
water_types_dict['Current'] = msconst.NONE
kwargs['itemdict'] = water_types_dict
super().__init__('Water model:', **kwargs)
if ff_combo_box:
ff_combo_box.currentTextChanged.connect(self.updateValidWaterModels)
self.updateValidWaterModels(ff_combo_box.currentText())
[docs] def updateValidWaterModels(self, name=None):
"""
Update water combobox items based on water force field.
:type name: str
:param name: name of force-field
"""
water_model_dict = self.getWaterModelDict(name == mm.OPLS_NAME_F14)
initial_model = self.currentText()
self.clear()
self.addItemsFromDict(water_model_dict)
self.selectInitialModel(initial_model)
[docs] def selectInitialModel(self, initial_model):
"""
Update combobbox items to initially selected model
:type initial_model: str
:param initial_model: name of the water force field
"""
try:
self.setCurrentText(initial_model)
except ValueError:
self.setCurrentText(msconst.SPC)
[docs] def getWaterModelDict(self, is_opls_2005=False):
"""
Get valid water models based on force field
:type is_opls_2005: bool
:param is_opls_2005: True if OPLS2005 force field used else False
:rtype: dict
:return: Dictionary of valid water force field dict
"""
water_ff = (msconst.VALID_WATER_FFTYPES_OPLS2005
if is_opls_2005 else msconst.WATER_FFTYPES.keys())
water_ff = [x.strip() for x in water_ff]
water_types_dict = {x: x for x in water_ff if x != msconst.NONE}
water_types_dict['Current'] = msconst.NONE
return water_types_dict
[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
normvec = transform.get_normalized_vector(vector)
origin = origin + self.getBuffer() * normvec
self.defineNewNormal(vector=vector, origin=origin, allow_flip=False)
[docs] def getBuffer(self):
"""
Get the buffer between the cell contents and the PBC boundary
:rtype: float
:return: The buffer space
"""
return 0.0
[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]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,
min_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(bottom=0.01)
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=60,
min_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,
max_selected_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 int max_selected_items: The maximum number of items that the
user may select before the rest disabled for selection.
: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
self.label = None
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
self._max_selected_items = max_selected_items
if self.max_selected_items is not None:
self.selectionChanged.connect(self.updateSelectability)
@property
def max_selected_items(self):
return self._max_selected_items
@max_selected_items.setter
def max_selected_items(self):
msg = (
'`max_selected_items` is not meant to be changed after '
'instantiation, because we have not yet implemented a version of '
f'`{self.__name__}` that can handle such a change stably.')
raise RuntimeError(msg)
[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 updateSelectability(self):
"""
If the number of selected items is greater than or equal to the
maximum, then make sure that the remaining, unselected items are
disabled. Otherwise, make sure that all items are enabled.
"""
num_selected = len(self.getSelectedIndexes())
if num_selected >= self.max_selected_items:
self.disableUnselectedItems()
else:
self.enableAllItems()
[docs] def enableAllItems(self):
"""
Enables all items in the combo box
"""
model = self.model()
for index in range(self.count()):
model.item(index).setEnabled(True)
[docs] def disableUnselectedItems(self):
"""
Disables all unselected items in the combo box
"""
all_indices = set(range(self.count()))
selected_indices = set(self.getSelectedIndexes())
unselected_indices = all_indices.difference(selected_indices)
model = self.model()
for index in unselected_indices:
model.item(index).setEnabled(False)
def _setIndexChecked(self, index, *args, **kwargs):
"""
Call the parent method only if the item with the corresponding index is
enabled. Otherwise, do nothing.
:param int index: The index of the item to try to check
"""
item = self.model().item(index)
if item.isEnabled():
super()._setIndexChecked(index, *args, **kwargs)
[docs] def setVisible(self, visible):
"""
Show or hide the combobox
:param bool visible: Whether the combobox should be shown
"""
super().setVisible(visible)
if self.label:
self.label.setVisible(visible)
[docs]class MSForceFieldSelector(ForceFieldSelector):
[docs] def __init__(self, *args, **kwargs):
"""
Extend the `ForceFieldSelector` by applying a
Materials-Science-specific default force field
"""
super().__init__(*args, **kwargs)
# Override the default forcefield with Matsci's forcefield
ff_disp_name = mm.opls_version_to_name(get_default_forcefield().version,
mm.OPLSName_DISPLAY)
self.force_field_menu.setCurrentText(ff_disp_name)
self.force_field_menu.default_item = self.force_field_menu.currentText()
self.force_field_menu.default_index = self.force_field_menu.currentIndex(
)
[docs]class ProcessBusyDialog(swidgets.SDialog):
"""
A dialog that indicates a subprocess is running and gives the user a
cancel button to kill it.
The class is designed to show/exec the dialog via the activate function.
The dialog is modal and will block until the subprocess finishes.
"""
KILLED = -9
SUCCESS = 0
[docs] def __init__(self, text, process, *args, **kwargs):
"""
Create a ProcessBusyDialog instance
:param str text: The text to display in the dialog
:param `subprocess.Popen` process: The running subprocess
Additional arguments are passed to the parent class
"""
self.text = text
self.process = process
cancel = QtWidgets.QDialogButtonBox.Cancel
super().__init__(*args, standard_buttons=[cancel], **kwargs)
self.setModal(True)
self.app = QtWidgets.QApplication.instance()
[docs] def layOut(self):
""" Lay out the dialog widgets """
layout = self.mylayout
self.label = swidgets.SLabel(self.text, layout=layout)
self.bar = QtWidgets.QProgressBar()
# Setting both to 0 creates an "indeterminate" busy progress bar that
# just bounces back and forth
self.bar.setMinimum(0)
self.bar.setMaximum(0)
layout.addWidget(self.bar)
[docs] def setNewData(self, text, process):
"""
Set the new text and process for this dialog - used for dialogs that
have activate called with persistent=True
:param str text: The new text of the dialog
:param `subprocess.Popen` process: The new running subprocess
"""
self.text = text
self.label.setText(text)
self.process = process
[docs] def activate(self, persistent=False):
"""
Show the dialog, wait for the process to finish (either naturally or
by the user cancelling it) and return the process return code.
:param bool persistent: If False, close the dialog before returning.
If True, do not close the dialog and caller is responsible for
calling dialog.accept() to close.
:rtype: int
:return: 0 if the process finished successfully, -9 if killed by the
user, or a non-zero integer if the process died with an error
"""
self.show()
code = None
while code is None:
time.sleep(0.2)
# Process events each iteration to catch if the user clicks Cancel
self.app.processEvents()
code = self.process.poll()
# The process completed, perhaps by the user killing it
if code == self.KILLED or not persistent:
self.accept()
return code
[docs] def reject(self):
"""
Called when the user selects the Cancel button or the window manager X
button. Kill the process but do not close the dialog. Allow the
dialog to close naturally via the activate while loop when the
process terminates.
"""
# Killing the process but not closing the dialog causes the activate
# method to return with the proper killed code.
self.process.kill()