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()