Source code for schrodinger.application.matsci.kmcgui

__doc__ = """
Run initial calculations to store mobility information for all the structures in
a system.

Copyright Schrodinger, LLC. All rights reserved.
"""

import math
import os.path
import pathlib
import shutil
from collections import defaultdict
from collections import namedtuple

import numpy

import schrodinger
from schrodinger import structure
from schrodinger.application.desmond import cms
from schrodinger.application.matsci import jobutils
from schrodinger.application.matsci import kmc
from schrodinger.Qt import QtCore
from schrodinger.structutils import analyze
from schrodinger.ui.qt import input_selector
from schrodinger.ui.qt import swidgets

maestro = schrodinger.get_maestro()

NO_DB = 'None'
SQL_ENDING = '.sql'
OLD_DATABASE_PROPERTY = 's_matsci_VOTCA_Database'


[docs]def robust_log(value): """ A sqrt function that is robust to missing (0.0) values :param float value: The value to take the log of :rtype: float :return: The log10 of value or 0 if value is 0 """ if value: return math.log10(value) else: return 0.0
[docs]def get_charged_prop_value(info, selector): """ Get the full property name for a property that appends 'h' or 'e' at the end based on the charge the property is for and the state of a charge radiobutton group :param `PlotInfo` info: The PlotInfo tuple for the property :param `InputSelectorWithDatabase` selector: The database input selector :rtype: str :return: The full name of the property with the correct suffix """ prop = info.prop db_info = selector.currentDBInfo() if info.charged: if not db_info or db_info.isHole() or not db_info.getPath(): # Default to the hole ending if there is no database prop += kmc.H_ENDING else: prop += kmc.E_ENDING return prop
[docs]def get_plottable_table_data(info, selector): """ Get the data from the SQL database :param `PlotInfo` info: The PlotInfo tuple for the property :param `InputSelectorWithDatabase` selector: The database input selector :rtype: list :return: The values for prop in each row of table """ prop = get_charged_prop_value(info, selector) sqid = kmc.Table.SQID path = selector.getDatabasePath() values = [x[prop] for x in kmc.table_rows(path, info.table, orderby=sqid)] if info.function: values = [info.function(x) for x in values] return values
PlotInfo = namedtuple( 'PlotInfo', ['combo', 'title', 'function', 'charged', 'prop', 'table']) SITE_INFO = PlotInfo(combo='Site energy', title='Site Energy (eV)', function=None, charged=True, prop='UcCnN', table=kmc.SegmentsTable.TABLE_NAME) COUPLING_INFO = PlotInfo(combo='Coupling integral', title='Coupling Integral ' f'(log(J{swidgets.SUPER_SQUARED}))', function=robust_log, charged=True, prop='Jeff2', table=kmc.PairsTable.TABLE_NAME) OCCUPATION_INFO = PlotInfo(combo='Occupancy', title='Fractional Occupancy', function=None, charged=True, prop='occP', table=kmc.SegmentsTable.TABLE_NAME) # This is for the charge injection reorg energy - the difference in energy # between the *c*harged molecule at the *N*eutral geometry and the *c*harged # molecule at the *C*harged geometry (UcNcC) INJECTION_REORGANIZATION_INFO = PlotInfo(combo='Reorganization energy (hop on)', title='Reorganization Energy (eV)', function=None, charged=True, prop='UcNcC', table=kmc.SegmentsTable.TABLE_NAME) # This is for the charge removal reorg energy - the difference in energy # between the *n*eutral molecule at the *C*harged geometry and the *n*eutral # molecule at the *N*eutral geometry (UnCnN) REMOVAL_REORGANIZATION_INFO = PlotInfo(combo='Reorganization energy (hop off)', title='Reorganization Energy (eV)', function=None, charged=True, prop='UnCnN', table=kmc.SegmentsTable.TABLE_NAME)
[docs]def get_field_strength_string(components): """ Get the string indicating field direction and strength, to be shown in the UI :param list components: Field value components :rtype: str :return: A string indicating field direction and strength """ field_direction = "" for axis, value in zip(kmc.ALL_AXES, components): if value != 0: field_direction += axis if not field_direction: # All components are zero field_direction = 'XYZ' amplitude = 0 else: if any(x < 0 for x in components): field_direction = "-" + field_direction amplitude = numpy.linalg.norm(components) / 1000000 return f'Field {field_direction} = {amplitude:.1f} MV/m'
[docs]class PropertyCombo(swidgets.SComboBox): """ A combobox used to select KMC properties """ values_changed = QtCore.pyqtSignal()
[docs] def __init__(self, text, items, values, layout, selector): """ Create a PropertyCombo instance :param str text: The label for the combobox. If no text is provided, there no label will be created and self.label will be None :param list items: List of PlotInfo items to display in the combobox :param dict values: Keys are property names (such as the .prop property from PropInfo objects (modified by the charge suffix), values are a list of values for that property. Initially, this dict can be empty and will be populated as properties are selected by the user. This prevents obtaining expensive properties unless needed, and allows multiple comboboxes to share the same property/value cache :param `swidgets.SBoxLayout` layout: The layout to place this combobox into :param `InputSelectorWithDatabase` selector: The database input selector """ itemdict = {x.combo: x for x in items} self.values = values self.selector = selector if selector: selector.database_changed.connect(self.loadAndProcessData) if text: self.label = swidgets.SLabel(text, layout=layout) else: self.label = None super().__init__(itemdict=itemdict, command=self.loadAndProcessData, nocall=True, layout=layout) if self.selector.getDatabasePath(): self.loadAndProcessData()
[docs] def setEnabled(self, state): """ Set all child widgets to enabled state of state :type state: bool :param state: True if widgets should be enabled, False if not """ super().setEnabled(state) if self.label: self.label.setEnabled(state)
[docs] def getValuesFromFile(self, info): """ Get the values for info from the file :param `PlotInfo` info: The info object to get values for """ return get_plottable_table_data(info, self.selector)
[docs] def loadAndProcessData(self, *_, quiet=False, info=None, prop=None): """ Get the values for the current or passed info, loading them from a file if need be. The values_changed signal will be emitted unless quiet is specified. Args are signal arguments and are ignored. :param bool quiet: Whether to emit the values_changed signal :param `PlotInfo` info: The info object to load values for. If not supplied, the current selection will be used. :param str prop: The property to load values for. If not supplied, the current selection will be used. """ assert bool(info) == bool(prop), 'Either none or both should be passed' if info is None: info, prop = self.getCurrentInfoAndProp() if not self.values.get(prop) and self.selector.getDatabasePath(): values = self.getValuesFromFile(info) if all(x is None for x in values): values = None self.values[prop] = values if not quiet: self.values_changed.emit()
[docs] def reset(self): """ Reset this combobox. Will emit the values_changed signal. """ super().reset() self.values_changed.emit()
[docs] def getCurrentInfoAndProp(self): """ Get the current info object and property name from the combobox :rtype: (PlotInfo, str) :return: The current info object and name of the associated property """ info = self.currentData() prop = get_charged_prop_value(info, self.selector) return info, prop
[docs] def getCurrentInfoPropAndValues(self, quiet=True): """ Get the current info object, property name and associated values from the combobox :rtype: (PlotInfo, str, list) :return: The current info object, property name and associated values. The values returned are the directly cached list, not a copy. So modifying the list will modify the values returned by future calls to this function. """ info = self.currentData() prop, values = self.getPropAndValues(info, quiet=quiet) return info, prop, values
[docs] def getPropAndValues(self, info, quiet=True): """ Get property name and associated values using the info object :param `PlotInfo` info: The info object to get values for :param bool quiet: Whether to emit the values_changed signal """ prop = get_charged_prop_value(info, self.selector) values = self.values.get(prop) if values is None: self.loadAndProcessData(quiet=quiet, info=info, prop=prop) values = self.values.get(prop) return prop, values
[docs]class DatabaseInfo(QtCore.QObject): """ Holds Schrodinger info from and about the SQL database """ pathChanged = QtCore.pyqtSignal()
[docs] def __init__(self): """ Create a DatabaseInfo object """ super().__init__() self._path = None self.reset()
[docs] def setPath(self, path): """ Set the path :type path: str or pathlib.Path :param path: The path to the database file """ if path == self._path: return self._path = pathlib.Path(path) self.loadInfo() self.pathChanged.emit()
[docs] def loadInfo(self): """ Load info from the database """ if not self._path: self.reset() return stab = kmc.SchrodingerTable self.mf = kmc.get_schrodinger_db_value(self._path, stab.MOLFORM) self.volume = kmc.get_schrodinger_db_value(self._path, stab.VOLUME) self.charge = kmc.get_schrodinger_db_value(self._path, stab.CARRIERTYPE)
[docs] def getPath(self): """ Get the current path :rtype: pathlib.Path or None :return: The current path to the database file, or None if none has been specified """ return self._path
[docs] def reset(self, force_emit=False): """ Reset the data properties :param bool force_emit: Emit the pathChanged signal even if the path doesn't actually change. """ emit = force_emit or self._path is not None self._path = None self.mf = None self.volume = None self.charge = None if emit: self.pathChanged.emit()
[docs] def getPathLabelText(self): """ Get the string version of the path to the database file :rtype: str :return: The path to the database file. NO_DB is returned if the path is not set. """ path = self.getPath() if path: return path.name else: return NO_DB
[docs] def isHole(self): """ Check if the current database is for a hole calculation :rtype: bool :return: True if the database is for a hole calculation, False if for electron or there is no database """ return self.charge == kmc.HOLE
[docs]class InputSelectorWithDatabase(input_selector.InputSelector): """ Adds the ability to pick an SQL database instead of a structure file """ DATABASE = 'Database' # The database_changed signal is emitted whenever the selected database # changes - this will be the case when a new database is selected for the # existing entry, or when switching between two entries (the signal is # emitted even if both entries have no database and there is technically no # actual "change" in the database) database_changed = QtCore.pyqtSignal() USE_ENTRY_DB = (input_selector.InputSelector.INCLUDED_ENTRY, input_selector.InputSelector.FILE)
[docs] def __init__(self, parent, show_entry_cb=True, **options): """ Create a InputSelectorWithDatabase object :type parent: `af2.JobApp` :param parent: The panel this selector will be part of :param bool show_entry_cb: Show the "Use database for this structure" checkbox """ # Allow user to use the existing database when selecting included entry # Must create these first so they exist when the parent __init__ method # calls reset options['tracking'] = True # Always use turn on the file option to get the file widgets for the # database use_file = options.get('file') is not False options['file'] = True self.entry_db_frame = swidgets.SFrame(layout_type=swidgets.HORIZONTAL) edb_layout = self.entry_db_frame.mylayout self.entry_db_cb = swidgets.SCheckBox( 'Use database for this structure:', checked=True, layout=edb_layout, disabled_checkstate=False, command=self.useEntryDatabaseToggled) if not show_entry_cb: self.entry_db_cb.hide() swidgets.SLabel('Database:', layout=edb_layout) self.entry_db_combo = swidgets.SComboBox( command=self.databaseComboChanged, nocall=True, layout=edb_layout) self.entry_db_combo.setSizeAdjustPolicy( self.entry_db_combo.AdjustToContents) edb_layout.addStretch() self.entry_db_frame.setEnabled(False) self.job_db_filename = None # Database info when user chooses DATABASE input self.existing_db_info = DatabaseInfo() # initializing is used to avoid doing work that gets triggered multiple # times during InputSelector initialization that ALSO gets triggered # when the panel is shown self.initializing = True input_selector.InputSelector.__init__(self, parent, **options) self.initializing = False self.existing_db_info.pathChanged.connect(self.database_changed.emit) # Give the user the option of loading an existing database rather than # specifying a structure self.enabled_sources.append(self.DATABASE) self.input_menu.addItem(self.DATABASE, self.DATABASE) # Finish the existing database widgets now that the object is created self.input_layout.addWidget(self.entry_db_frame) self.input_changed.connect(self.checkEntryDatabase) # "filetypes" option will be overwritten when selecting a database # file. Have to save the value here and restore later if a database # is not being selected. self.nondatabase_file_types = self.options['filetypes'] # Remove the File option if we aren't using it directly if not use_file: index = self.input_menu.findText('File') # findText return -1 if no item with that text is found if index >= 0: self.input_menu.removeItem(index)
[docs] def useEntryDatabaseToggled(self): """ React to the the use existing database checkbox toggling """ self.database_changed.emit()
[docs] def showOrHideEntryDBFrame(self): """ Set the visibility of the widgets that allow the user to request the database associated with the included entry """ self.entry_db_frame.setVisible( self.inputState(true_state=True) in self.USE_ENTRY_DB)
[docs] def checkEntryDatabase(self): """ Check the current included entry to see if there is an associated database """ if self.initializing: return # Set widget states based on current input self.entry_db_frame.setEnabled(False) self.showOrHideEntryDBFrame() input_state = self.inputState(true_state=True) if input_state not in self.USE_ENTRY_DB: return self.entry_db_frame.setVisible(True) # Look for databases associated with the included entry databases = [] field_values = defaultdict(kmc.AxisData) field_free_database = None self.entry_db_combo.clear() try: struct = next(self.structures()) except StopIteration: struct = None # Find all the database properties on the structure and store the # database information if struct: if input_state == self.INCLUDED_ENTRY: directory = jobutils.get_source_path(struct) else: directory = os.path.dirname(self.getFile()) if directory: for prop, value in struct.property.items(): prop_type = kmc.is_votca_prop(prop) if (prop == OLD_DATABASE_PROPERTY or prop == kmc.PARAM_SQL_FILE or prop_type == kmc.DATABASE_TYPE): sql_name = struct.property[prop] path = os.path.join(directory, sql_name) if os.path.exists(path): if prop in (OLD_DATABASE_PROPERTY, kmc.PARAM_SQL_FILE): # Old fashioned databases use a field-free # property name from 19-3 and prior releases # Charge hopping database properties have no # field or charge information field_free_database = (sql_name, path) else: fieldnum, charge = kmc.parse_database_prop(prop) databases.append((charge, fieldnum, path)) elif prop_type == kmc.FIELD_TYPE: fieldnum, axis = kmc.parse_field_prop(prop) field_values[fieldnum].setComponent(axis, value) # Add databases to the combobox if databases: databases.sort() for charge, fieldnum, path in databases: charge = charge.capitalize() if fieldnum: field_strength_str = get_field_strength_string( field_values[fieldnum].components) usertext = f'{charge}, ' + field_strength_str else: usertext = f'{charge}, no field' self.entry_db_combo.addItem(usertext, path) # Add the field-free last so that any field-specific # databases are used first if field_free_database: self.entry_db_combo.addItem(*field_free_database) if not self.entry_db_combo.count(): # Make sure the existing info is reset if there is no database, as # clearing the combo above does not trigger a combo changed signal # We force signal emission to ensure a signal gets emitted even when # switching between two structures that don't have a database self.existing_db_info.reset(force_emit=True)
[docs] def databaseComboChanged(self): """ React to the value of the database combo changing """ path = self.entry_db_combo.currentData() if path: # Must enable this before calling setPath so the that current # database checkbox status is up to date because setPath emits a # signal that causes some slots to check the checkbox state self.entry_db_frame.setEnabled(True) self.existing_db_info.setPath(path) else: self.existing_db_info.reset()
[docs] def inputState(self, true_state=False): """ Get the current input state of the selector :type true_state: bool :param true_state: If True, DATABASE will be returned if DATABASE is the selected input. If False, INCLUDED_ENTRY will be returned if DATABASE is the selected input. INCLUDED_ENTRY is returned in this case because that is where the input structure will actually come from. :rtype: str :return: The input source. See true_state param. """ inputstate = input_selector.InputSelector.inputState(self) if inputstate == self.DATABASE and not true_state: inputstate = self.INCLUDED_ENTRY return inputstate
[docs] def isDatabaseSource(self): """ Check if DATABASE is the current input source :rtype: bool :return: Whether DATABASE is currently selected """ inputstate = self.inputState(true_state=True) return inputstate == self.DATABASE
[docs] def validate(self): """ Validate the state of the selector :rtype: str or None :return: None if there are no issues. A message describing the problem if there is an issue. """ # Standard validation error = input_selector.InputSelector.validate(self) if error: return error # Validate the state of the database and whether it matches the entry if self.usingExistingDatabase(): if self.isDatabaseSource() and not self.existing_db_info.getPath(): return 'No database selected' return self.validateDBAndStructure()
[docs] def currentDBInfo(self, only_if_used=False): """ Get the DatabaseInfo object for the current input selector settings :type only_if_used: bool :param only_if_used: If True, the included entry database info will only be returned if the user has elected to use it :rtype: `DatabaseInfo` or None :return: The info for the current settings, or None if the chosen input method does not allow for a database """ state = self.inputState(true_state=True) if (self.usingExistingDatabase() or not only_if_used): return self.existing_db_info return None
[docs] def getDatabasePath(self): """ Get the path to the currently-used existing database :rtype: pathlib.Path or None :return: The path to the existing database to use or None if no database should be used """ info = self.currentDBInfo(only_if_used=True) if info: return info.getPath() return None
[docs] def usingExistingDatabase(self): """ Check if the user has specified an existing database be used :rtype: bool :return: Whether an existing database is used """ return self.isDatabaseSource() or self.entry_db_cb.isChecked()
[docs] def existingDatabaseJobFilename(self): """ Get the filename for the existing database in the job directory. Note: !! This method is only valid during job launch - i.e. after the setup method has been called. Calling at other times may incorrectly yield None or a stale file name from the previous job launch. :rtype: str or None :return: The name of the database from the most recent job launch that used an existing database. None may be returned if no such job has been launched or the current job does not use an existing database. """ if self.usingExistingDatabase(): return self.job_db_filename return None
def _fileSelectSetEnabled(self, enable): """ Enable or diable the widgets for file selection :param bool enable: The new enabled state of the widgets """ enable = enable or self.inputState(true_state=True) == self.DATABASE super()._fileSelectSetEnabled(enable)
[docs] def reset(self): """ Reset the widget """ # Manually reset this to avoid multiple callbacks that occur before it # gets cleared, plus the base class reset implementation could clear # this without calling the input_changed signal, which would be bad self.file_text.setText("") self.entry_db_combo.clear() self.entry_db_cb.reset() if self.existing_db_info.getPath(): # The database info may have been reset by changing the above # widgets self.existing_db_info.reset() self.job_db_filename = None input_selector.InputSelector.reset(self) if not self.getDatabasePath(): # In the case where reset doesn't change the input structure, we'll # have reset the database info but not re-found the database. Do # that now. self.checkEntryDatabase()
[docs] def setup(self, jobname): """ Prepares for job start. In addition to standard behavior, will copy the chosen database to the job directory if DATABASE is the input source :type jobname: str :param jobname: The job name :rtype: bool :return: True if everything is OK, False if start should be aborted """ if self.usingExistingDatabase(): self.job_db_filename = jobname + SQL_ENDING shutil.copy(self.currentDBInfo().getPath(), self.job_db_filename) input_selector.InputSelector.setup(self, jobname)
[docs] def validateDBAndStructure(self): """ Check that everything looks OK with the chosen database and given structure :rtype: str or None :return: None if there are no issues. A message describing the problem if there is an issue. """ try: struct = maestro.get_included_entry() except RuntimeError as msg: return str(msg) info = self.currentDBInfo() if info.mf: struct_mf = analyze.generate_molecular_formula(struct) if struct_mf != info.mf: return ('Workspace structure does not have the same molecular ' f'formula ({struct_mf}) as the structure used to create' f' the database ({info.mf})') if info.volume is not None: try: struct_pbc_vol = cms.get_boxvolume(cms.get_box(struct)) except KeyError: struct_pbc_vol = 0.0 if abs(struct_pbc_vol - info.volume) > 0.001: return ('Workspace structure does not have the same PBC volume ' f'({struct_pbc_vol:.3f}) as the structure used to ' f'create the database ({info.volume:.3f})')
def _inputSourceChanged(self, *args, **kwargs): """ React to the input source changing """ # Clear the file input of incorrect file types - must be done before # calling parent method as that method emits input_changed which causes # an attempted file read filename = self.file_text.text() db_source = self.isDatabaseSource() file_source = self.inputState() == self.FILE if db_source: if filename and not filename.endswith(SQL_ENDING): self.file_text.clear() else: if file_source: if filename and filename.endswith(SQL_ENDING): self.file_text.clear() input_selector.InputSelector._inputSourceChanged(self, *args, **kwargs) self.pt_button.setVisible(not (db_source or file_source)) self.showOrHideEntryDBFrame() self.handleNewDatabaseSource() if not db_source: self.checkEntryDatabase()
[docs] def browseFiles(self): """ Allow the user to select an input file In addition to parent class behavior, allows the selection of a SQL file """ if self.isDatabaseSource(): self.options['filetypes'] = [('Database', f'*{SQL_ENDING}')] else: self.options['filetypes'] = self.nondatabase_file_types input_selector.InputSelector.browseFiles(self) self.handleNewDatabaseSource()
[docs] def handleNewDatabaseSource(self): """ Do the work required if the user picks a new database file as the source """ if self.isDatabaseSource(): if self.file_text.text(): # Extract information from the database and place into the # Workspace the structure that was used to generate the database self.existing_db_info.setPath(self.file_text.text()) self.findOrCreateDBEntry() else: self.existing_db_info.reset()
[docs] def findOrCreateDBEntry(self): """ Find the structure that was used to generate the database and put it in the Workspace. Raises a warning dialog if no structure can be found """ if maestro: ptable = maestro.project_table_get() else: return info = self.currentDBInfo() msg = ('Unable to automatically associate a project entry. The ' 'correct project entry will have to be manually placed in the ' 'Workspace. ') if info.mf and info.volume is not None: msg += ('Looking for a structure with molecular formula = ' f'{info.mf} and PBC volume = {info.volume:.3f} Angstroms.') db_jobid = kmc.get_schrodinger_db_value(info.getPath(), kmc.SchrodingerTable.JOBID) if not db_jobid: self.parent.warning( 'This database has a deprecated format or was not generated ' 'with Schrodinger software. %s' % msg) return db_row = None # First check the PT to see if any entry has the same JobID property as # this DB. if db_jobid != kmc.SQL_NOJOB: for row in ptable.all_rows: id_prop = kmc.VOTCA_JOB_ID if row[id_prop] == db_jobid: db_row = row break if not db_row: # Import the structure from the job directory source_st_path = kmc.get_db_structure_path(info.getPath()) if source_st_path: struct = structure.Structure.read(str(source_st_path)) # Set the source path so it points to the directory the # structure actually came from, which may not be the job # directory if the db and structure were moved source_path = os.path.dirname(source_st_path) # This del is a workaround for SHARED-6890 jobutils.set_source_path(struct, path=source_path) db_row = ptable.importStructure(struct) if not db_row: self.parent.warning(msg) else: db_row.includeOnly() db_row.selectOnly()