Source code for schrodinger.application.matsci.mswidgets

"""
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 NewNameDialog(swidgets.SDialog, widgetmixins.MessageBoxMixin): """ Dialog for getting a new name from the user """
[docs] def __init__(self, parent, default_name, existing_names, **kwargs): """ Create a NewNameDialog instance :param QtWidgets.QWidget parent: the parent widget :param str default_name: The default name :param iterable existing_names: The existing names """ self.default_name = default_name self.existing_names = existing_names super().__init__(parent, **kwargs)
[docs] def layOut(self): """ Layout the widgets """ self.name_le = swidgets.SLabeledEdit('Name:', layout=self.mylayout, edit_text=self.default_name)
[docs] def accept(self): """ Check that the name is valid before accepting """ name = self.name_le.text() if not name: self.error('The name cannot be empty.') return if name in self.existing_names: msg = ('An item with the specified name already exists. ' 'Overwrite?') if not self.question(msg): return super().accept()
[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 setupSaveWidgets(self): """ Set up the widgets required for saving and loading ASLs """ self.plus_button.setToolTip(self.PLUS_TIP_WITH_SAVE) save_btn = QtWidgets.QToolButton() save_btn.clicked.connect(self.saveASL) path = os.path.join(swidgets.UI_QT_DIR, 'icons_dir', 'save_file.png') save_btn.setIcon(QtGui.QIcon(path)) save_btn.setToolTip('Save this ASL') # Set style to be similar to the other buttons save_btn.setObjectName('all_button') self.asl_layout.insertWidget(self.asl_layout.indexOf(self.all_button), save_btn) self.prefs = preferences.Preferences(preferences.SCRIPTS) self.popup_widget.aboutToShow.connect(self.updateSavedASLs)
[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 createControlButtons(self): """ Create upper-right corner control buttons as requested by the user """ # We have explicit, ordered checks so that we maintain consistent # ordering of the buttons in the GUI (e.g., open/close is always first, # delete is always last, etc.) # We have one button that toggles the opened/closed state the stage. We # make that toggle button if either the OPEN or CLOSE icons are # requested. if OPEN in self.icons_to_use or CLOSE in self.icons_to_use: self.toggle_button = StageControlButton(self.icons[CLOSE], self.bar_layout, self.toggleVisibility) else: self.toggle_button = None if UP in self.icons_to_use: StageControlButton(self.icons[UP], self.bar_layout, self.moveUp) if DOWN in self.icons_to_use: StageControlButton(self.icons[DOWN], self.bar_layout, self.moveDown) if COPY in self.icons_to_use: StageControlButton(self.icons[COPY], self.bar_layout, self.copy) if DELETE in self.icons_to_use: StageControlButton(self.icons[DELETE], self.bar_layout, self.delete)
[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 StageControlButton(QtWidgets.QToolButton): """ The QToolButtons on the right of each `StageFrame` """
[docs] def __init__(self, icon, layout, command): """ Create a StageControlButton instance :type icon: QIcon :param icon: The icon for the button :type layout: QLayout :param layout: The layout the button should be placed in :type command: callable :param command: The slot for the clicked() signal """ QtWidgets.QToolButton.__init__(self) self.setIcon(icon) layout.addWidget(self) self.clicked.connect(command) self.setIconSize(QtCore.QSize(11, 11)) self.setAutoRaise(True)
[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 SaveDesmondFilesWidget(swidgets.SFrame): """ Widget that provides options for saving intermediate Desmond job files. """
[docs] def __init__(self, combined_trj=False, layout=None, **kwargs): """ Create an instance. :param bool combined_trj: Whether the checkbox for combining trajectory should be added. :param: layout: Layout to add this widget to :type layout: QtWidgets.QLayout """ super().__init__(layout=layout) self.combined_trj = combined_trj save_label = 'Save intermediate data: ' self.save_combo = swidgets.SComboBox(itemdict=SAVE_COMBO_OPTIONS) self.save_cbw = swidgets.SCheckBoxWithSubWidget(save_label, self.save_combo, layout=self.mylayout, **kwargs) if combined_trj: self.combine_cb = swidgets.SCheckBoxToggle( 'Combined trajectory', layout=self.mylayout, disabled_checkstate=False, checked=True)
[docs] def getCommandLineArgs(self): """ Return the command line flags to be used based on the widget state. Note that this expects driver classes to the `parserutils.SAVE_FLAG` with the allowed options found in `parserutils.SAVE_FLAG_OPTS`. :return: List of command line args to use :rtype: list """ args = [parserutils.SAVE_FLAG] if not self.save_cbw.isChecked(): args.append(parserutils.SAVE_NONE) else: args.append(self.save_combo.currentData()) if self.combined_trj and self.combine_cb.isChecked(): args.append(parserutils.FLAG_COMBINE_TRJ) return args
[docs] def setFromCommandLineFlags(self, flags): """ Set the state of these widgets from command line flag values :param dict flags: Keys are command line flags, values are flag values. For flags that take no value, the value is ignored - the presence of the key indicates the flag is present. """ value = flags.get(parserutils.SAVE_FLAG) self.save_cbw.setChecked(value is not None) if value is not None: self.save_combo.setCurrentData(value) if self.combined_trj: self.combine_cb.setChecked(parserutils.FLAG_COMBINE_TRJ in flags)
[docs] def reset(self): """ Reset the frame """ self.save_cbw.reset() if self.combined_trj: self.combine_cb.reset()
[docs]class RandomSeedWidget(swidgets.SCheckBoxWithSubWidget): """ Standardized checkbox with spinbox to provide option to specify random seed. The spinbox is hidden when the checkbox is not checked. """
[docs] def __init__(self, layout=None, minimum=parserutils.RANDOM_SEED_MIN, maximum=parserutils.RANDOM_SEED_MAX, default=parserutils.RANDOM_SEED_DEFAULT, **kwargs): """ :param `QtWidgets.QLayout` layout: Layout to add this widget to :param int minimum: The minimum acceptable random seed value :param int maximum: The maximum acceptable random seed value :param int default: The default custom random seed value """ self.seed_sb = swidgets.SSpinBox(minimum=minimum, maximum=maximum, value=default, stepsize=1) tip = kwargs.get('tip') if tip: self.seed_sb.setToolTip(tip) if kwargs.get('checked') is None: kwargs['checked'] = True super(RandomSeedWidget, self).__init__('Set random number seed', self.seed_sb, layout=layout, **kwargs)
[docs] def getSeed(self): """ Return the value specified in the spinbox. :return: seed for random number generator :rtype: int """ return self.seed_sb.value()
[docs] def getCommandLineFlag(self): """ Return a list containing the proper random seed flag and argument specified by the state of this widget. Meant to be added onto a command list, e.g.:: cmd = [EXEC, infile_path] cmd += rs_widget.getCommandLineFlag() :return: A list containing the random seed flag followed by either "random", or the string representation of the seed specified in this widget's spinbox. :rtype: `list` of two `str` """ if not self.isChecked(): seed = parserutils.RANDOM_SEED_RANDOM else: seed = str(self.getSeed()) return [parserutils.FLAG_RANDOM_SEED, seed]
[docs] def setFromCommandLineFlags(self, flags): """ Set the state of these widgets from command line flag values :param dict flags: Keys are command line flags, values are flag values. For flags that take no value, the value is ignored - the presence of the key indicates the flag is present. """ seed = flags.get(parserutils.FLAG_RANDOM_SEED) if seed is None or seed == parserutils.RANDOM_SEED_RANDOM: self.setChecked(False) else: self.setChecked(True) self.seed_sb.setValue(int(seed)) self._toggleStateChanged(self.isChecked())
def _toggleStateChanged(self, state): """ React to the checkbox changing checked state Overwrite the parent class to hide the subwidget instead of disabling it :type state: bool :param state: The new checked state of the checkbox """ if self.reverse_state: self.subwidget.setVisible(not state) else: self.subwidget.setVisible(state)
[docs] def setVisible(self, state): """ Overwrite the parent method to allow the checkbox state to control the visibility of the subwidget """ super().setVisible(state) if state: self._toggleStateChanged(self.isChecked()) else: self.subwidget.setVisible(False)
[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 DesmondMDWidgets(swidgets.SFrame): """ Frame that holds core MD related fields, to be reused in the panels that submit desmond jobs. """ TRJ_NFRM_LABEL = 'yields %d frames' ENEGRP_NFRM_LABEL = 'yields %d records' PTENSOR_NFRM_LABEL = 'yields %d records' ENESEQ_NFRM_LABEL = 'yields %d records' DEFAULTS = { parserutils.FLAG_MD_TIME: 1.0, parserutils.FLAG_MD_TIMESTEP: 2.0, parserutils.FLAG_MD_TRJ_INT: 4.8, parserutils.FLAG_MD_ENEGRP_INT: 4.8, parserutils.FLAG_MD_PTENSOR_INT: 4.8, parserutils.FLAG_MD_ENESEQ_INT: 1.2, parserutils.FLAG_MD_TEMP: 300.0, parserutils.FLAG_MD_PRESS: 1.01325 } NUM_FRMS_TYPE = enum.Enum('NUM_FRMS_TYPE', 'enegrp eneseq ptensor trj')
[docs] def __init__(self, time_changed_command=None, timestep_changed_command=None, show_temp=True, temp_changed_command=None, show_press=True, show_save=True, show_enegrp=False, show_ptensor=False, show_eneseq=False, show_seed=True, show_trj_interval=True, enegrp_changed_command=None, ensembles=None, isotropy=None, defaults=None, time_use_ps=False, combined_trj=False, **kwargs): """ Initialize object and place widgets on the layout. See swidgets.SFrame for more documentation. :type time_changed_command: Method or None :param time_changed_command: Called on focus out event of MD time field :type timestep_changed_command: Method or None :param timestep_changed_command: Called on focus out event of MD time step field :type show_temp: bool :param show_temp: Show or not MD temperature field :type temp_changed_command: Method or None :param temp_changed_command: Called on focus out event of MD temp step field :type show_press: bool :param show_press: Show or not MD pressure field :type show_save: bool :param show_save: Show or not Save MD related data widget :type show_enegrp: bool :param show_enegrp: Show or not energy group recording interval widget :param bool show_ptensor: Whether to show pressure tensor recording interval :type show_eneseq: bool :param show_eneseq: Show or not energy recording interval widget :type show_seed: bool :param show_seed: Show or not random seed widget :type show_trj_interval: bool :param show_trj_interval: Show or not the widgets for saving trajectory intervals :type enegrp_changed_command: Method or None :param enegrp_changed_command: Called on focus out event of enegrp interval field :type ensembles: None or list :param ensembles: Show choice of desmond ensembles :type isotropy: None or dict :param isotropy: Show choice of desmond barostat isotropy policies. Keys are user-facing text for each isotropy policy, values are the value from schrodinger.application.desmond.constants.IsotropyPolicy. :type defaults: dict or None :param defaults: Dict with the default values of MD fields :type time_use_ps: bool :param time_use_ps: If True, use ps for the time field, otherwise ns (which is default in Desmond GUI) """ self.time_use_ps = time_use_ps if self.time_use_ps: time_after_label = 'ps' else: time_after_label = 'ns' self.show_temp = show_temp self.temp_changed_command = temp_changed_command self.show_press = show_press self.show_save = show_save self.show_enegrp = show_enegrp self.show_ptensor = show_ptensor self.show_eneseq = show_eneseq self.show_seed = show_seed self.show_trj_interval = show_trj_interval self.enegrp_changed_command = enegrp_changed_command self.ensembles = ensembles self.time_changed_command = time_changed_command self.timestep_changed_command = timestep_changed_command self.isotropy = isotropy self.defaults = dict(self.DEFAULTS) if defaults: self.defaults.update(defaults) swidgets.SFrame.__init__(self, **kwargs) if self.ensembles: # Ensure that all ensembles provided are known by desmond assert len(set(self.ensembles).difference(set( msconst.ENSEMBLES))) == 0 self.ensemble_cb = swidgets.SLabeledComboBox( 'Ensemble class:', items=self.ensembles, layout=self.mylayout, command=self.onEnsembleChanged, nocall=True) hlayout = swidgets.SHBoxLayout(layout=self.mylayout) self.time_le = DesmondMDWEdit( 'Simulation time:', edit_text=str(self.defaults[parserutils.FLAG_MD_TIME]), after_label=time_after_label, focus_out_command=self.onTimeChanged, layout=hlayout, tip='Time for the Molecular Dynamics simulations', stretch=False) self.timestep_le = DesmondMDWEdit( 'Time step:', edit_text=str(self.defaults[parserutils.FLAG_MD_TIMESTEP]), after_label='fs', focus_out_command=self.onTimestepChanged, layout=hlayout, tip='Time step for the Molecular Dynamics simulations') hlayout.addStretch(1000) if self.show_trj_interval: hlayout_trj = swidgets.SHBoxLayout(layout=self.mylayout) self.trj_int_le = DesmondMDWEdit( 'Trajectory recording interval:', edit_text=str(self.defaults[parserutils.FLAG_MD_TRJ_INT]), after_label='ps', focus_out_command=self.onTrjIntervalChanged, layout=hlayout_trj, tip='Trajectory recording interval', stretch=False) self.trj_nfrm_le = swidgets.SLabel(self.TRJ_NFRM_LABEL % 10, layout=hlayout_trj) hlayout_trj.addStretch(1000) if self.show_enegrp: hlayout_enegrp = swidgets.SHBoxLayout(layout=self.mylayout) self.enegrp_int_le = DesmondMDWEdit( 'Energy group recording interval:', edit_text=str(self.defaults[parserutils.FLAG_MD_ENEGRP_INT]), after_label='ps', focus_out_command=self.onEneGrpIntervalChanged, layout=hlayout_enegrp, tip='Energy group recording interval', stretch=False) self.enegrp_nfrm_le = swidgets.SLabel(self.ENEGRP_NFRM_LABEL % 10, layout=hlayout_enegrp) hlayout_enegrp.addStretch(1000) if self.show_ptensor: hlayout_ptensor = swidgets.SHBoxLayout(layout=self.mylayout) self.ptensor_int_le = DesmondMDWEdit( 'Pressure tensor recording interval:', edit_text=str(self.defaults[parserutils.FLAG_MD_PTENSOR_INT]), after_label='ps', focus_out_command=self.onPtensorIntervalChanged, layout=hlayout_ptensor, tip='Pressure tensor recording interval', stretch=False) self.ptensor_nfrm_le = swidgets.SLabel(self.PTENSOR_NFRM_LABEL % 10, layout=hlayout_ptensor) hlayout_ptensor.addStretch(1000) if self.show_eneseq: hlayout_eneseq = swidgets.SHBoxLayout(layout=self.mylayout) self.eneseq_int_le = DesmondMDWEdit( 'Energy recording interval:', edit_text=str(self.defaults[parserutils.FLAG_MD_ENESEQ_INT]), after_label='ps', focus_out_command=self.onEneSeqIntervalChanged, layout=hlayout_eneseq, tip='Energy recording interval', stretch=False) self.eneseq_nfrm_le = swidgets.SLabel(self.ENESEQ_NFRM_LABEL % 10, layout=hlayout_eneseq) hlayout_eneseq.addStretch(1000) hlayout_tp = swidgets.SHBoxLayout(layout=self.mylayout) self.temp_le = DesmondMDWEdit( 'Temperature:', edit_text=str(self.defaults[parserutils.FLAG_MD_TEMP]), after_label='K', focus_out_command=self.temp_changed_command, layout=hlayout_tp, tip='Temperature for the Molecular Dynamics simulations', stretch=False) self.temp_le.setVisible(self.show_temp) self.press_le = DesmondMDWEdit( 'Pressure:', edit_text=str(self.defaults[parserutils.FLAG_MD_PRESS]), after_label='bar', layout=hlayout_tp, tip='Pressure for the Molecular Dynamics simulations') if self.isotropy: # Ensure that all barostat isotropy type provided are known by desmond assert set(self.isotropy.values()).issubset( set(dconst.IsotropyPolicy)) self.isotropy_cb = swidgets.SLabeledComboBox( 'Barostat isotropy:', itemdict=self.isotropy, layout=hlayout_tp, tip='Barostat isotropy type') hlayout_tp.addStretch(1000) self.press_le.setVisible(self.show_press) if self.show_seed: self.random_seed = RandomSeedWidget(layout=self.mylayout) self.save_widget = SaveDesmondFilesWidget(combined_trj=combined_trj, layout=self.mylayout) self.save_widget.setVisible(self.show_save) self.reset()
[docs] def updateAllNumFrames(self): """ Update all known frames. """ for num_frms_type in self.NUM_FRMS_TYPE: self.updateNumFrames(num_frms_type)
[docs] def updateNumFrames(self, num_frms_type): """ Update approximate the number of recordings and interval (if needed). :param NUM_FRMS_TYPE num_frms_type: Type of the recordings to update """ if num_frms_type == num_frms_type.enegrp: # Update energy group widgets show = self.show_enegrp if not show: return int_le = self.enegrp_int_le nfrm_le = self.enegrp_nfrm_le label = self.ENEGRP_NFRM_LABEL elif num_frms_type == num_frms_type.ptensor: # Update pressure tensor widgets show = self.show_ptensor if not show: return int_le = self.ptensor_int_le nfrm_le = self.ptensor_nfrm_le label = self.PTENSOR_NFRM_LABEL elif num_frms_type == num_frms_type.eneseq: # Update pressure tensor widgets show = self.show_eneseq if not show: return int_le = self.eneseq_int_le nfrm_le = self.eneseq_nfrm_le label = self.ENESEQ_NFRM_LABEL elif num_frms_type == num_frms_type.trj: # Update trajectory widgets show = self.show_trj_interval if not show: return int_le = self.trj_int_le nfrm_le = self.trj_nfrm_le label = self.TRJ_NFRM_LABEL else: raise AssertionError('Unknown frame type: %s.' % num_frms_type) time_ps = self.getTimePS() if int_le.float() > time_ps: int_le.setText(str(time_ps)) nfrm_le.setText(label % math.floor(time_ps / int_le.float()))
[docs] def getTimestepPS(self): """ Returns the simulation timestep in picoseconds. :rtype: float :return: Time in picoseconds """ timestep_ps = self.timestep_le.float() * units.FEMTO2PICO return timestep_ps
[docs] def getTimePS(self): """ Returns the simulation time in picoseconds. :rtype: float :return: Time in picoseconds """ time_ps = self.time_le.float() if not self.time_use_ps: # Default is time in ns time_ps *= units.NANO2PICO return time_ps
[docs] def onTimeChanged(self): """ Called when simulation time changes. """ self.updateAllNumFrames() if self.time_changed_command: self.time_changed_command()
[docs] def onTimestepChanged(self): """ Called when time step changes. """ if self.timestep_changed_command: self.timestep_changed_command()
[docs] def onTrjIntervalChanged(self): """ Called when trajectory interval changes. """ self.updateNumFrames(num_frms_type=self.NUM_FRMS_TYPE.trj)
[docs] def onEneGrpIntervalChanged(self): """ Called when energy group interval changes. """ self.updateNumFrames(num_frms_type=self.NUM_FRMS_TYPE.enegrp) if self.enegrp_changed_command: self.enegrp_changed_command()
[docs] def onPtensorIntervalChanged(self): """ Called when energy group interval changes. """ self.updateNumFrames(num_frms_type=self.NUM_FRMS_TYPE.ptensor)
[docs] def onEneSeqIntervalChanged(self): """ Called when energy interval changes. """ self.updateNumFrames(num_frms_type=self.NUM_FRMS_TYPE.eneseq)
[docs] def onEnsembleChanged(self): """ Called when ensemble changes. """ if not self.show_press or not self.ensembles: return # Disable pressure field if ensemble doesn't have pressure in it is_p_ensemble = 'P' in self.ensemble_cb.currentText() self.press_le.setEnabled(is_p_ensemble) if self.isotropy: self.isotropy_cb.setEnabled(is_p_ensemble)
[docs] def getCommandLineFlags(self): """ Return a list containing the proper command-line flags and their values, e.g.:: cmd = [EXEC, infile_path] cmd += rs_widget.getCommandLineFlag() :rtype: list :return: command-line flags and their values """ ret = [ parserutils.FLAG_MD_TIME, self.time_le.text(), parserutils.FLAG_MD_TIMESTEP, self.timestep_le.text() ] if self.show_trj_interval: ret += [parserutils.FLAG_MD_TRJ_INT, self.trj_int_le.text()] if self.show_temp: ret += [parserutils.FLAG_MD_TEMP, self.temp_le.text()] if self.show_press: ret += [parserutils.FLAG_MD_PRESS, self.press_le.text()] if self.show_enegrp: ret += [parserutils.FLAG_MD_ENEGRP_INT, self.enegrp_int_le.text()] if self.show_ptensor: ret += [parserutils.FLAG_MD_PTENSOR_INT, self.ptensor_int_le.text()] if self.show_eneseq: ret += [parserutils.FLAG_MD_ENESEQ_INT, self.eneseq_int_le.text()] if self.ensembles: ret += [ parserutils.FLAG_MD_ENSEMBLE, self.ensemble_cb.currentText() ] if self.isotropy: ret += [ parserutils.FLAG_MD_ISOTROPY, self.isotropy_cb.currentData() ] if self.show_seed: ret += self.random_seed.getCommandLineFlag() if self.show_save: ret += self.save_widget.getCommandLineArgs() return ret
[docs] def setFromCommandLineFlags(self, flags): """ Set the state of these widgets from command line flag values :param dict flags: Keys are command line flags, values are flag values. For flags that take no value, the value is ignored - the presence of the key indicates the flag is present. """ flag_to_edit = { parserutils.FLAG_MD_TIME: self.time_le, parserutils.FLAG_MD_TIMESTEP: self.timestep_le, parserutils.FLAG_MD_TEMP: self.temp_le, parserutils.FLAG_MD_PRESS: self.press_le } if self.show_trj_interval: flag_to_edit[parserutils.FLAG_MD_TRJ_INT] = self.trj_int_le if self.show_enegrp: flag_to_edit[parserutils.FLAG_MD_ENEGRP_INT] = self.enegrp_int_le if self.show_ptensor: flag_to_edit[parserutils.FLAG_MD_PTENSOR_INT] = self.ptensor_int_le if self.show_eneseq: flag_to_edit[parserutils.FLAG_MD_ENESEQ_INT] = self.eneseq_int_le for aflag, edit in flag_to_edit.items(): value = flags.get(aflag) if value is not None: edit.setText(str(value)) ensemble = flags.get(parserutils.FLAG_MD_ENSEMBLE) if ensemble: self.ensemble_cb.setCurrentText(ensemble) if self.isotropy: isotropy = flags.get(parserutils.FLAG_MD_ISOTROPY) if isotropy: self.isotropy_cb.setCurrentData(isotropy) if self.show_seed: self.random_seed.setFromCommandLineFlags(flags) self.save_widget.setFromCommandLineFlags(flags)
[docs] def reset(self): """ Reset widgets. """ self.time_le.reset() self.timestep_le.reset() if self.show_trj_interval: self.trj_int_le.reset() if self.show_enegrp: self.enegrp_int_le.reset() if self.show_ptensor: self.ptensor_int_le.reset() if self.show_eneseq: self.eneseq_int_le.reset() if self.ensembles: self.ensemble_cb.reset() self.temp_le.reset() self.press_le.reset() if self.show_seed: self.random_seed.reset() self.save_widget.reset() self.updateAllNumFrames() self.onEnsembleChanged()
[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 addWidgetsToLayout(self, layout): """ Add PlaneSelectorMixin widgets to the passed layout :type layout: QBoxLayout :param layout: The layout to place this panel into """ self.struct = None self.modified_project = None self.previous_inclusion = [] self.modified_project = None self.temprow_id = None self.arrow_length = MINIMUM_PLANE_NORMAL_LENGTH self.arrow = None self.plane = None self.best_btext = None self.group = graphics_common.Group() self.picked_atoms = set() self.full_vector = self.NPZ.copy() self.unbuffered_origin = self.NPO.copy() self.vector_origin = self.NPO.copy() self.vector_set_by_picked_atoms = False self.method_rbg = swidgets.SRadioButtonGroup(nocall=True, command=self.methodToggled, layout=layout) self.fit_sel_atom_rb = swidgets.SRadioButton( self.FIT_SELECTED_ATOMS, tip=f'A plane {self.FIT_SELECTED_ATOMS.lower()} will be used', layout=layout) self.method_rbg.addExistingButton(self.fit_sel_atom_rb) # Crystal vectors self.cframe = swidgets.SFrame(layout=layout, layout_type=swidgets.HORIZONTAL) clayout = self.cframe.mylayout crystal_rb = swidgets.SRadioButton(self.CRYSTAL_VECTOR, layout=clayout) tip = ('The selected vector from the existing periodic boundary ' 'condition will be used.') crystal_rb.setToolTip(tip) self.cvec_frame = swidgets.SFrame(layout=clayout, layout_type=swidgets.HORIZONTAL) cvlayout = self.cvec_frame.mylayout labels = LATTICE_VECTOR_LABELS tips = [ 'Use crystal vector a', 'Use crystal vector b', 'Use crystal vector c' ] self.crystal_rbg = swidgets.SRadioButtonGroup( labels=labels, tips=tips, command=self.crystalVectorPicked, nocall=True, layout=cvlayout) clayout.addStretch() self.method_rbg.addExistingButton(crystal_rb) # Atom picking playout = swidgets.SHBoxLayout(layout=layout) self.choose_atom_rb = swidgets.SRadioButton(self.CHOOSE_ATOMS, layout=playout) tip = ('A best-fit plane for the picked atoms will be used') self.choose_atom_rb.setToolTip(tip) self.method_rbg.addExistingButton(self.choose_atom_rb) self.pick_frame = swidgets.SFrame(layout=playout, layout_type=swidgets.HORIZONTAL) pflayout = self.pick_frame.mylayout self.pick3_cb = swidgets.SCheckBox('Pick', layout=pflayout, checked=False) self.pick3_cb.toggled.connect(self.pickToggled) tip = ('Check this box to pick the atoms in the workspace that\n' 'define the plane. Uncheck this box when finished picking\n' 'atoms.') self.pick3_cb.setToolTip(tip) self.picker = picking.PickAtomToggle(self.pick3_cb, self.atomPicked, enable_lasso=True) btn = swidgets.SPushButton('Clear Selection', command=self.clearPicked, layout=pflayout) tip = ('Clear the currently picked set of atoms') btn.setToolTip(tip) playout.addStretch() self.flip_btn = swidgets.SPushButton('Flip Direction', command=self.flipDirection, layout=layout) tip = ('Flip the direction of the interface and the side of the\n' 'structure the interface extends from.') self.flip_btn.setToolTip(tip)
[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 AtomNameWidgetItem(QtWidgets.QTableWidgetItem): """ QTableWidgetItem validated as type provided. """
[docs] def __init__(self, value, type_value=str, default=''): """ Initialize item object. :param value: Input value :param type_value: type to which value must be converted. Default type of value is str :param default: Value default, must be of type type_value """ assert isinstance(default, type_value) self.type_value = type_value self.old_value = default self.old_value = self.getValue(value) super().__init__(str(self.old_value), QtWidgets.QTableWidgetItem.UserType)
[docs] def getValue(self, value): """ Get value of the correct type and save it. :param value: Input value to be converted to the typed value :return: typed value :rtype: type """ try: self.old_value = self.type_value(value) except ValueError: pass return self.old_value
[docs] def setData(self, role, value): """ Validates table cell data value by converting it to float. :type role: int :param role: Item role. :param value: Value of the item's data. """ if role != QtCore.Qt.UserRole: # Update the value only if it is not user data value = self.getValue(value) super().setData(role, value)
[docs] def userData(self): """ Get user data. :rtype: any or None :return: User data or None """ return super().data(QtCore.Qt.UserRole)
def __lt__(self, other): """ Sort based on the atom index (stored in self.old_value). """ # if value does not exist if not self.old_value or not other.old_value: return False # get the atom index for values self_idx = msutils.get_index_from_default_name(self.old_value) other_idx = msutils.get_index_from_default_name(other.old_value) if self_idx is None or other_idx is None: return False return self_idx < other_idx
[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 changeToolTip(self): """ Change the tooltip to show all selected items """ selected = self.getSelectedItems() tip = self._delimiter.join(selected) self.setToolTip(tip) if self.has_new_selection: if self.command: self.command() self.has_new_selection = False
[docs] def hideWidgets(self, hidden): """ Hide (or show) the combobox and clear the selection :param bool hidden: Whether to hide the combobox or show it """ self.frame.setVisible(not hidden) if hidden: self.clearSelection() self.changeToolTip()
[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()
[docs]class OptionalInputSelector(input_selector.InputSelector): """ An InputSelector object that does not act when disabled """
[docs] def setEnabled(self, enable, *args, **kwargs): """ Override the parent method to block signals when disabled """ super().setEnabled(enable) self.blockSignals(not enable)
[docs] def validate(self, *args, **kwargs): """ Override the parent method to not validate when disabled """ if not self.isEnabled(): return None return super().validate()
[docs] def writePTEntries(self, *args, **kwargs): """ Override the parent method to not write when disabled """ if not self.isEnabled(): return True return super().writePTEntries(*args, **kwargs)
[docs] def setup(self, jobname, *args, **kwargs): """ Override the parent method to store the structure file name but do nothing else when disabled """ if not self.isEnabled(): self.params.inputstrucfile = jobname + '.maegz' return True return super().setup(jobname, *args, **kwargs)