Source code for schrodinger.application.jaguar.gui.utils

"""
Utility functions and classes for the Jaguar GUIs
"""

import enum
import math
import re
import warnings
from collections import namedtuple

import decorator

import schrodinger
from schrodinger.application.jaguar import input as jaginput
from schrodinger.infra import mm
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt import utils as qt_utils
from schrodinger.ui.qt.appframework2 import jobnames as af2_jobnames

maestro = schrodinger.get_maestro()

THEORY_DFT = "DFT"
THEORY_HF = "HF"
THEORY_LMP2 = "LMP2"
THEORY_MIXED = "Mixed"
THEORY_RIMP2 = "RI-MP2"

MOLECULAR_CHARGE_PROP = "i_m_Molecular_charge"
SPIN_MULT_PROP = "i_m_Spin_multiplicity"
ERROR_BACKGROUND_BRUSH = QtGui.QBrush(Qt.red)

JAGUAR_IN_MAE_FILETYPES = (("Jaguar Structure Input",
                            "*.in *.mae *.maegz *.mae.gz"),
                           ("Jaguar Input", "*.in"), ("Maestro",
                                                      "*.mae *.maegz *.mae.gz"))

Solvent = namedtuple(
    "Solvent",
    ("name", "keyvalue", "dielectric", "radius", "weight", "density"))
# "density" represents the density of the solvent at 20 C

# Names for the "Solvent" option menu and the values for the
# MMJAG_SKEY_SOLVENT "solvent" keyword
ALL_SOLVENTS = [
    Solvent("Water", mm.MMJAG_SOLVENT_WATER, 80.37, 1.4, 18.02, 0.99823),
    Solvent("Acetonitrile", mm.MMJAG_SOLVENT_ACETONITRILE, 37.5, 2.19, 41.05,
            0.777),
    Solvent("Benzene", mm.MMJAG_SOLVENT_BENZENE, 2.284, 2.60, 78.12, 0.87865),
    Solvent("Carbon tetrachloride", mm.MMJAG_SOLVENT_CARBON_TETRACHLORIDE,
            2.238, 2.67, 153.82, 1.5940),
    Solvent("Chlorobenzene", mm.MMJAG_SOLVENT_CHLOROBENZENE, 5.708, 2.72,
            112.56, 1.1058),
    Solvent("Chloroform", mm.MMJAG_SOLVENT_CHLOROFORM, 4.806, 2.52, 119.38,
            1.4832),
    Solvent("Cyclohexane", mm.MMJAG_SOLVENT_CYCLOHEXANE, 2.023, 2.78, 84.16,
            0.77855),
    Solvent("1,2-dichloroethane", mm.MMJAG_SOLVENT_12DICHLOROETHANE, 10.65,
            2.51, 98.96, 1.2351),
    Solvent("Dichloromethane", mm.MMJAG_SOLVENT_DICHLOROMETHANE, 8.93, 2.33,
            84.93, 1.3266),
    Solvent("Dimethylformamide", mm.MMJAG_SOLVENT_DIMETHYLFORMAMIDE, 36.7, 2.49,
            73.09, 0.944),
    Solvent("DMSO", mm.MMJAG_SOLVENT_DMSO, 36.7, 2.41, 78.13, 1.1014),
    Solvent("Ethanol", mm.MMJAG_SOLVENT_ETHANOL, 24.85, 2.27, 46.07, 0.785),
    Solvent("Methanol", mm.MMJAG_SOLVENT_METHANOL, 33.62, 2.00, 32.04, 0.7914),
    Solvent("Nitrobenzene", mm.MMJAG_SOLVENT_NITROBENZENE, 35.74, 2.73, 123.11,
            1.2037),
    Solvent("Tetrahydrofuran", mm.MMJAG_SOLVENT_TETRAHYDROFURAN, 7.6, 2.52,
            72.11, 0.8892),
    Solvent("Other", "other", None, None, None, None),
]


[docs]def count_num_strucs(input_selector): """ Count the number of structures currently specified in the input selector widget. Since Jaguar can't accept more than three structures, this function will return 4 for all values >= 4. :param input_selector: The input selector widget :type input_selector: `schrodinger.ui.qt.input_selector.InputSelector` """ try: strucs = input_selector.structures(False) for num_strucs in range(0, 5): next(strucs) except StopIteration: pass except IOError: return 0 return num_strucs
[docs]class JaguarSettingError(Exception): """ An exception indicating that there was an error with the user-specified Jaguar settings """
# This class intentionally left blank
[docs]class JaguarSettingWarning(UserWarning): """ A warning indicating that a user-specified Jaguar settings could not be loaded into the GUI """
# This class intentionally left blank
[docs]def find_key_for_value(mydict, value): """ This function finds key corresponding to a given value in a dictionary. We assume here that values in a given dictionary are unique. :param mydict: dictionary :type mydict: dict :param value: value in dictionary. It can be any hashable type. :return: key, which can be any type. """ return next((k for k, v in mydict.items() if v == value), None)
[docs]def validate_le_float_input(widget, msg=None): """ This function checks whether a given line edit widget has a valid input. If it does it will return a float value. Otherwise an exception will be raised. This function should only be used for line edits used to enter float numbers. :param widget: line edit widget :type widget: `QLineEdit` :param msg: text of exception error message :type msg: str :return: valid float number :rtype: float """ if widget.hasAcceptableInput(): return float(widget.text()) else: if msg is None: msg = "Invalid float input value." raise JaguarSettingError(msg)
[docs]@decorator.decorator def catch_jag_errors(func, *args, **kwargs): """ A decorator that will display any `JaguarSettingError` instances in an error dialog. :return: If the decorated function raised a `JaguarSettingError`, False will be returned. Otherwise, the return value of the decorated function will be returned. """ self = args[0] try: return func(*args, **kwargs) except JaguarSettingError as err: if str(err): self.error(str(err)) return False
[docs]def warn_about_mmjag_unknowns(jag_input, parent=None): """ If the JaguarInput object contains any unknown keywords, warn the user about the unknowns and ask them if they want to continue. :param jag_input: The JaguarInput object to check for unknown keywords :type jag_input: schrodinger.application.jaguar.input.JaguarInput :param parent: The Qt parent widget for the warning dialog :type parent: QtWidgets.QWidget :return: True if we should continue (either there were no unknown keywords, or there were but the user wants to continue). False if we should cancel. :rtype: bool """ unknowns = jag_input.getUnknownKeywords() if unknowns: plurals = ("is an", "") if len(unknowns) == 1 else ("are", "s") err = ("There %s unrecognized keyword%s in the input file:" % plurals) for (key, val) in sorted(unknowns.items()): err += "\n\t%s=%s" % (key, val) plurals = ("this", "") if len(unknowns) == 1 else ("these", "s") err += ("\nDo you still wish to continue with %s unrecognized " "keyword%s?" % plurals) QMB = QtWidgets.QMessageBox msg_box = QMB(QMB.Warning, "Warning", err, QMB.Ignore | QMB.Cancel, parent) msg_box.button(QMB.Ignore).setText("Ignore and Continue") retval = msg_box.exec() return retval == QMB.Ignore else: return True
[docs]def calculate_num_protons(struc): """ Calculate the number of protons in the specified structure :param struc: The structure to calculate protons in :type struc: schrodinger.structure.Structure :return: The number of protons :rtype: int """ num_protons = 0 for cur_atom in struc.atom: counterpoise = cur_atom.property.get(mm.M2IO_DATA_ATOM_COUNTERPOISE, False) if not counterpoise and cur_atom.atomic_number > 0: num_protons += cur_atom.atomic_number return num_protons
[docs]def get_atom_info(ws_atom_num): """ Get information about the specified workspace atom :param ws_atom_num: The workspace atom number :type ws_atom_num: int :return: A tuple of: - The atom name (after Jaguar atom naming has been applied) - The atom number relative to the entry (rather than relative to the workspace structure) - The entry id - The entry title :rtype: tuple """ ws_struc = maestro.workspace_get() ws_atom = ws_struc.atom[ws_atom_num] eid = ws_atom.entry_id atom_num = ws_atom.number_by_entry proj = maestro.project_table_get() row = proj[eid] struc = row.getStructure() jaginput.apply_jaguar_atom_naming(struc) atom = struc.atom[atom_num] return atom.name, atom_num, eid, row.title
atom_name_regex = re.compile(r"^([^\W\d]+)(\d+)$") """ A regular expression that matches Jaguar atom names. Group 1 of the match is the element and group 2 is the number. """
[docs]def atom_name_sort_key(atom_name): """ Convert a Jaguar atom name into a key for use in sorting by number :param atom_name: The atom name :type atom_name: str :return: A tuple of (atom number, element) :rtype: tuple """ groups = atom_name_regex.match(atom_name).groups() num_name = (int(groups[1]), groups[0]) return num_name
[docs]def generate_job_name(struc_name, task_name, theory, basis): """ Generate an appropriate job name for a job with the specified settings. Any settings that are specified as None will be omitted from the job name. If a directory or file with the specified name exists in the current directory, an integer will be appended to the job name. :note: If generating multiple job names, the input for each job must be saved before the next job name is generated. Otherwise, this function will not be able to append the appropriate integer. :param struc_name: The structure title :type struc_name: str or NoneType :param task_name: The task name (i.e. a shortened version of the panel name :type task_name: str or NoneType :param theory: The theory method. Should be "HF", "LMP2", or the DFT functional. :type theory: str or NoneType :param basis: The basis name :type basis: str or NoneType :return: The job name :rtype: str """ if basis: # Replace all characters in the basis that aren't allowed in job names basis = basis.replace("*", "s").replace("+", "p") basis = basis.replace("(", "").replace(")", "") if theory: theory = theory.replace("(", "").replace(")", "") if struc_name: # The structure name may contain characters that aren't allowed in job # names, so remove all characters other than alphanumerics, underscores, # dashes, and periods. (Allowed characters taken from # L{schrodinger.utils.fileutils.is_valid_jobname}.) struc_name = re.sub(r"[^\w_\-\.]", "", struc_name) job_data = ("jag", struc_name, task_name, theory, basis) job_data = [name for name in job_data if name] jobname = "_".join(job_data) jobname = af2_jobnames.get_next_jobname(jobname, True) return jobname
[docs]class EnhancedComboBox(swidgets.SComboBox): """ A combo box for use in the Jaguar GUI with several Pythonic enhancements """
[docs] def setCurrentMmJagData(self, jag_input, keyword, setting_name): """ Set the combo box selection based on the specified mmjag setting The combo box user data must match the mmjag keyword values. If no matching setting is found, a warning will be issued. :param jag_input: A JaguarInput object containing the settings to load :type jag_input: `schrodinger.application.jaguar.input.JaguarInput` :param keyword: The mmjag keyword to load :type keyword: str :param setting_name: The name of the setting that is being set. This name will only be used when issuing warnings. :type setting_name: str """ value = jag_input[keyword] index = self.findData(value) if index != -1: self.setCurrentIndex(index) else: msg = "Unknown value for %s: %s=%s" % (setting_name, keyword, str(value)) warnings.warn(JaguarSettingWarning(msg))
[docs]class PropertiesTableWidget(QtWidgets.QTableWidget): """ A QTableWidget for the Properties table on the Properties tab """
[docs] def __init__(self, *args, **kwargs): super(PropertiesTableWidget, self).__init__(*args, **kwargs) self.shrinkRowHeight()
[docs] def shrinkRowHeight(self): """ Shrink the row height to eliminate unnecessary vertical padding """ options = qt_utils.get_view_item_options(self) font = options.font font_metrics = QtGui.QFontMetrics(font) font_height = font_metrics.height() desired_row_height = math.floor(font_height * 1.5) # The scaling of 1.5 gives roughly the same row heights as the C++ # GUI vert_header = self.verticalHeader() current_row_height = vert_header.defaultSectionSize() if desired_row_height < current_row_height: # Just in case the user has some strange settings, make sure we're # really shrinking the row height and not increasing it vert_header.setDefaultSectionSize(desired_row_height)
[docs] def sizeHint(self): """ Set the preferred size of the table so that all rows are visible. """ width = super(PropertiesTableWidget, self).minimumSizeHint().width() height = self.verticalHeader().length() height += self.horizontalHeader().height() height += self.frameWidth() * 2 return QtCore.QSize(width, height)
[docs]class WorkspaceInclusionCheckBox(QtWidgets.QCheckBox): """ A checkbox that is skinned to look like the Project Table workspace inclusion checkbox. This checkbox is used in the Transition State and IRC tabs. :note: This skinning could be done using a style sheet, but that requires separate images for checked + disabled and unchecked + disabled. By using a QIcon, these disabled images are generated automatically. """
[docs] def __init__(self, parent=None): super(WorkspaceInclusionCheckBox, self).__init__(parent) self._icon = QtGui.QIcon() self._icon.addFile(":/icons/include_checked.png", state=QtGui.QIcon.On) self._icon.addFile(":/icons/include_unchecked.png", state=QtGui.QIcon.Off)
[docs] def paintEvent(self, event): painter = QtGui.QPainter(self) opt = QtWidgets.QStyleOptionButton() self.initStyleOption(opt) if self.isEnabled(): if opt.state & QtWidgets.QStyle.State_MouseOver: mode = QtGui.QIcon.Active elif opt.state & QtWidgets.QStyle.State_Selected: mode = QtGui.QIcon.Selected else: mode = QtGui.QIcon.Normal else: mode = QtGui.QIcon.Disabled if self.isChecked(): state = QtGui.QIcon.On else: state = QtGui.QIcon.Off rect = opt.rect align = Qt.AlignLeft | Qt.AlignVCenter self._icon.paint(painter, rect, align, mode, state)
[docs]class ProjTableLikeView(QtWidgets.QTableView): """ A table view that mimics the selecting and editing behaviors of the Project Table """
[docs] def __init__(self, parent=None): super(ProjTableLikeView, self).__init__(parent) self.setSortingEnabled(True) self.setSelectionBehavior(self.SelectRows) self.setSelectionMode(self.ExtendedSelection) edit_triggers = self.editTriggers() edit_triggers |= self.SelectedClicked self.setEditTriggers(edit_triggers)
[docs] def selectionCommand(self, index, event=None): """ Don't update the current selection when using the keyboard to navigate or when clicking on a selected editable item. :param index: The newly selected index :type index: `PyQt5.QtCore.QModelIndex` :param event: The event that triggered the index change :type event: `PyQt5.QtCore.QEvent` :return: A flag describing how the selection should be updated :rtype: int """ smodel = self.selectionModel() keypress = event is not None and event.type() == event.KeyPress selected = index.isValid() and smodel.isSelected(index) editable = index.flags() & Qt.ItemIsEditable if keypress or (selected and editable): return smodel.NoUpdate else: return super(ProjTableLikeView, self).selectionCommand(index, event)
[docs] def commitDataToSelected(self, editor, index, delegate): """ Commit data to all selected cells in the column that is currently being edited. :param editor: The editor being used to enter data :type editor: `PyQt5.QtWidgets.QWidget` :param index: The index being edited :type index: `PyQt5.QtCore.QModelIndex` :param delegate: The delegate used to create the editor :type delegate: `PyQt5.QtWidgets.QAbstractItemDelegate` """ all_selected = self.selectedIndexes() indices = [ sel for sel in all_selected if sel.column() == index.column() ] if index not in indices: # This is possible using keyboard navigation indices.append(index) model = self.model() for cur_index in indices: delegate.setModelData(editor, model, cur_index)
[docs] def setItemDelegateForColumn(self, column, delegate, connect_selected=False): """ Set the delegate for the specified column. Note that this function adds the optional `connect_selected` argument not present in the QTableView function. :param column: The column to set the delegate for :type column: int :param delegate: The delegate to set :type delegate: `PyQt5.QtWidgets.QAbstractItemDelegate` :param connect_selected: If True, the delegate's commitDataToSelected signal will be connected :type connect_selected: bool """ if connect_selected: delegate.commitDataToSelected.connect(self.commitDataToSelected) super(ProjTableLikeView, self).setItemDelegateForColumn(column, delegate)
[docs]@enum.unique class SpinTreatment(enum.Enum): """ An enumeration of the possible spin treatment settings. Enum values correspond to mmjag settings. """ NA = None # Spin treatment is not applicable to Hartree-Fock and lMP2 # levels of theory Restricted = mm.MMJAG_IUHF_OFF Unrestricted = mm.MMJAG_IUHF_ON Automatic = mm.MMJAG_IUHF_AUTOMATIC
[docs] def unrestrictedAvailable(self): """ Does the current spin treatment setting allow for an unrestricted waveform? :return: True for unrestricted or automatic spin treatments. False otherwise. :rtype: bool """ return self in (SpinTreatment.Unrestricted, SpinTreatment.Automatic)
def __bool__(self): err = ("SpinTreatment enums should not be treated as a Boolean. Use " "Spintreatment.unrestrictedAvailable() instead.") raise RuntimeError(err)