Source code for schrodinger.trajectory.trajectory_gui_dir.interaction_plots
"""
Plot Manager for interaction plots (e.g. H-Bonds, Pi-Pi Stacking)
"""
from collections import defaultdict
from schrodinger.structutils import analyze
from schrodinger.ui import maestro_ui
from schrodinger.tasks import queue
from . import traj_plot_models
from . import plots
from .traj_plot_models import AnalysisMode
from schrodinger.Qt import QtCore
from schrodinger.Qt.QtCore import Qt
from schrodinger import get_maestro
maestro = get_maestro()
# This message is shown below every interactions error dialog text:
ATOM_DEFINITION_MESSAGE = ("\nCheck the Interactions pane in the\n"
"Workspace Configuration toolbar for\n"
"the current atom set definitions.")
INTERACTIONS_DEF_EMPTY_ALIST_ERROR = (
"The atoms specified for this interaction\n"
"type do not appear in the trajectory.\n") + ATOM_DEFINITION_MESSAGE
MISSING_INTERACTION_TYPES_ERROR = (
"No instances of these interaction types were found in any frame."
) + ATOM_DEFINITION_MESSAGE
NO_CUSTOM_ASLS_DEFINED_ERR = (
"The 'Other' type interaction definition is selected "
"but ASLs are not defined. Please define them or select 'Ligand-Complex' "
"or 'Intra-Ligand' and try again.")
# Interaction Mode constants
LIGAND_RECEPTOR_ASL = 'ligand OR protein'
INTRA_LIGAND_ASL = 'ligand'
ALL_ASL = 'all'
INTERACTION_ASL_TO_TITLE = {
LIGAND_RECEPTOR_ASL: 'Ligand-Receptor',
INTRA_LIGAND_ASL: 'Intra-Ligand',
ALL_ASL: 'All'
}
LIGAND_RECEPTOR_DISPLAY_TO_TITLE = {
maestro_ui.LigandReceptorInteractions.ALL_AVAILABLE: 'All Available',
maestro_ui.LigandReceptorInteractions.SAME_ENTRY_ONLY: 'Same Entry Only',
maestro_ui.LigandReceptorInteractions.SAME_GROUP_ONLY: 'Same Group Only',
maestro_ui.LigandReceptorInteractions.NONE: 'None'
}
[docs]class InteractionsPlotManager(plots.TrajectoryAnalysisPlotManager):
"""
Class responsible for setting up and running an interactions plot task.
"""
[docs] def __init__(self, panel, mode, use_visible_atoms):
"""
:param panel: Parent panel
:type panel: QtWidget.QWidget
:param mode: Analysis mode
:type mode: AnalysisMode
:param use_visible_atoms: Whether to only show interactions between
visible atoms.
:type use_visible_atoms: bool
"""
super().__init__(panel)
self.use_visible_atoms = use_visible_atoms
self.task = traj_plot_models.TrajectoryAnalysisSubprocTask()
ws_st = maestro.workspace_get()
self.configureTask(self.task, mode, ws_st)
self.setupView()
[docs] def configureTask(self, task, analysis_mode, ws_st):
"""
Configure the analysis task analyze Workspace interactions of given type.
:param task: Task to configure
:type task: tasks.AbstractTask
:param analysis_mode: Interactions analysis mode for this task
:type analysis_mode: traj_plot_models.AnalysisMode
:param ws_st: Workspace structure containing the system.
:type ws_st: structure.Structure
:return: Configured analysis task
:rtype: traj_plot_model.TrajectoryAnalysisSubprocTask
"""
super().configureTask(task)
task.input.analysis_mode = analysis_mode
maestro_hub = maestro_ui.MaestroHub.instance()
interactions_state = maestro_hub.getInteractionViewState()
if analysis_mode in [
AnalysisMode.HydrogenBondFinder, AnalysisMode.HalogenBondFinder,
AnalysisMode.SaltBridgeFinder
]:
anums_as_kwargs = False
interactions_mode = interactions_state.non_covalent_mode
elif analysis_mode in [
AnalysisMode.PiPiFinder, AnalysisMode.CatPiFinder
]:
anums_as_kwargs = True
interactions_mode = interactions_state.pi_mode
else:
raise ValueError(
f"{analysis_mode} is not a valid interactions analyzer.")
if interactions_mode == maestro_ui.InteractionMode.LIGAND_RECEPTOR:
fit_asl = LIGAND_RECEPTOR_ASL
anums1 = analyze.evaluate_asl(ws_st, 'ligand')
anums2 = analyze.evaluate_asl(ws_st, 'NOT ligand')
elif interactions_mode == maestro_ui.InteractionMode.INTRA_LIGAND:
fit_asl = INTRA_LIGAND_ASL
anums1 = analyze.evaluate_asl(ws_st, INTRA_LIGAND_ASL)
anums2 = anums1[:]
elif interactions_mode == maestro_ui.InteractionMode.ALL:
fit_asl = ALL_ASL
anums1 = analyze.evaluate_asl(ws_st, ALL_ASL)
anums2 = anums1[:]
elif interactions_mode == maestro_ui.InteractionMode.OTHER:
asl1 = interactions_state.non_covalent_bonds_other_asl_set1
asl2 = interactions_state.non_covalent_bonds_other_asl_set2
if not asl1 or not asl2:
# NOTE: Typically both ASLs will be defined or neither defined.
raise RuntimeError(NO_CUSTOM_ASLS_DEFINED_ERR)
fit_asl = f'({asl1}) OR ({asl2})'
anums1 = analyze.evaluate_asl(ws_st, asl1)
anums2 = analyze.evaluate_asl(ws_st, asl2)
else:
raise AssertionError("Unexpected interaction mode: %s" %
interactions_mode)
if self.use_visible_atoms:
# Only consider visible atoms in the Workspace for interactions
anums1 = [a for a in anums1 if ws_st.atom[a].visible]
anums2 = [a for a in anums2 if ws_st.atom[a].visible]
if len(anums1) == 0 or len(anums2) == 0:
mode_name = traj_plot_models.ANALYSIS_MODE_MAP[analysis_mode].name
msg = f"Cannot display {mode_name} plot.\n{INTERACTIONS_DEF_EMPTY_ALIST_ERROR}"
raise RuntimeError(msg)
anums1.sort()
anums2.sort()
# Used for settings hash:
self.anums1 = anums1
self.anums2 = anums2
if anums_as_kwargs:
task.input.additional_kwargs = {"aids1": anums1, "aids2": anums2}
else:
task.input.additional_args = [anums1, anums2]
task.input.fit_asl = fit_asl
[docs] def getInitialPlotTitleAndTooltip(self):
"""
Derive the interaction plot title based on the settings stored in task.
See base method for documentation of return value.
"""
task = self.task
interactions_mode = INTERACTION_ASL_TO_TITLE[task.input.fit_asl]
mode = task.input.analysis_mode
if mode == AnalysisMode.HydrogenBondFinder:
prefix = 'Hydrogen Bonds'
elif mode == AnalysisMode.HalogenBondFinder:
prefix = 'Halogen Bonds'
elif mode == AnalysisMode.SaltBridgeFinder:
prefix = 'Salt Bridges'
elif mode == AnalysisMode.PiPiFinder:
prefix = 'Pi-Pi Stacking'
elif mode == AnalysisMode.CatPiFinder:
prefix = 'Pi-Cation'
title = f'{prefix} ({interactions_mode})'
# TODO: consider showing more information in a tooltip instead of
# returning None
return title, None
[docs] def formatPlotAxes(self):
"""
Formats axes tick numbers and spacing depending on result data.
"""
results = self.task.output.result
self._formatPlotAxes(results)
def _formatPlotAxes(self, result_data):
"""
Shared between both classes.
"""
chart = self.chart()
chart.createDefaultAxes()
axes = chart.axes()
for axis in axes:
if axis.orientation() == Qt.Orientation.Horizontal:
axis.setTitleText('Time (ns)')
axis.applyNiceNumbers()
axis.setLabelFormat('%i')
interstitial_values = max(result_data) - min(result_data) + 1
num_ticks = min(plots.MAX_AXIS_TICKS, interstitial_values)
axis.setTickCount(num_ticks)
else:
axis.setTitleText('Number of Interactions')
axis.setLabelFormat('%.1f')
plots._generateAxisSpecifications(result_data, axis)
def _validateTask(self, task):
if not super()._validateTask(task):
return False
if all(v == 0 for v in task.output.result):
return False
return True
[docs]class MultiSeriesPlotManager(InteractionsPlotManager):
"""
Plot manager that contains multiple interaction series, on a single plot.
Raises RuntimeError if plot could not be created.
"""
newDataAvailable = QtCore.pyqtSignal()
showWarning = QtCore.pyqtSignal(str)
[docs] def __init__(self, panel, use_visible_atoms):
plots.TrajectoryAnalysisPlotManager.__init__(self, panel)
self.use_visible_atoms = use_visible_atoms
self.task_queue = self.createBatchQueue()
self.task_queue.mainDone.connect(self._onTaskQueueFinished)
# Make the view a little taller, to allow room for the legend:
self.setupView(fixed_height=350, multi_series=True)
[docs] def createBatchQueue(self):
"""
Get a configured task queue for running a batch of tasks.
:return: Configured task queue
:rtype: queue.TaskQueue
"""
task_queue = queue.TaskQueue()
task_queue.max_running_tasks = 1
hub = maestro_ui.MaestroHub.instance()
ws_st = maestro.workspace_get()
intview = hub.getInteractionViewState()
for attr, mode in [('halogen_bonds', AnalysisMode.HalogenBondFinder),
('hydrogen_bonds', AnalysisMode.HydrogenBondFinder),
('salt_bridges', AnalysisMode.SaltBridgeFinder),
('pi_stacking', AnalysisMode.PiPiFinder),
('pi_cation', AnalysisMode.CatPiFinder)]:
if getattr(intview, attr) is True:
task = traj_plot_models.TrajectoryAnalysisSubprocTask()
try:
self.configureTask(task, mode, ws_st)
except RuntimeError:
continue
task.input.for_multiseries_plot = True
task_queue.addTask(task)
if len(task_queue.getTasks()) == 0:
msg = f"Cannot display interaction plots.\n{MISSING_INTERACTION_TYPES_ERROR}"
raise RuntimeError(msg)
return task_queue
[docs] def getSettingsHash(self):
"""
Generates a key for use in the interactions map
:return: key for interaction map
:rtype: str, tuple(AnalysisMode), tuple(str)
"""
# Tasks in a TaskQueue should be exposed (PANEL-18939)
tasks = self.task_queue.getTasks()
all_tasks_settings = []
for task in tasks:
all_tasks_settings.append([
task.input.analysis_mode.name, task.input.additional_args,
task.input.additional_kwargs
])
return self.generateSettingsHash(all_tasks_settings)
[docs] def createCollapsiblePlotWidget(self):
plot_widget = super().createCollapsiblePlotWidget()
self.newDataAvailable.connect(plot_widget.onPlotTitleChanged)
return plot_widget
def _onTaskQueueFinished(self):
"""
Warns the user that some interactions in a multiseries analysis did not generate
plot data. Updates Interaction Plot cache with plot
"""
msg = self.validateTaskQueue(self.task_queue)
if msg:
self.showWarning.emit(msg)
[docs] def validateTaskQueue(self, task_queue):
"""
Validate task queue for multi-interaction plot. On error, return error
message text, otherwise return None.
"""
tasks = task_queue.getTasks()
no_result_interactions = []
for task in tasks:
if all(v == 0 for v in task.output.result):
no_result_interactions.append(task.output.legend_name)
# If none of the tasks had output, do not save plot
if len(tasks) == len(no_result_interactions):
return (
f"Cannot display plot series for any interaction plots. "
f"Plot will not be created.\n{MISSING_INTERACTION_TYPES_ERROR}")
elif no_result_interactions:
plural = 's' if len(no_result_interactions) > 1 else ''
return (f"Cannot display plot series for interaction{plural} "
f"{', '.join(no_result_interactions)}.\n"
f"{MISSING_INTERACTION_TYPES_ERROR}")
return None
[docs] def getInitialPlotTitleAndTooltip(self):
"""
For multi-series plots, title will be updated when all tasks complete
via if multiple ASL sets are used.
"""
task_mapping = defaultdict(list)
tasks = self.task_queue.getTasks()
for task in tasks:
# Verify that output contains non-0 values and was plotted
if not all(v == 0 for v in task.output.result):
fit_asl = task.input.fit_asl
analysis_mode = task.input.analysis_mode
task_mapping[fit_asl].append(analysis_mode)
if len(task_mapping) > 1:
return self._getPlotTitleByMapping(task_mapping)
else:
task = self.getTasks()[0]
interactions_mode = INTERACTION_ASL_TO_TITLE[task.input.fit_asl]
# TODO: consider showing more information in a tooltip instead of
# returning None
return f'Interactions ({interactions_mode})', None
def _getPlotTitleByMapping(self, task_mapping):
"""
Return the title and tooltip for the plot based on the given mapping
of ASLs to plot analysis modes.
:param task_mapping: Dictionary where keys are fit ASLs and values are
lists of analysis modes. Each ASL must be one of the keys
for the INTERACTION_ASL_TO_TITLE constant.
:type task_mapping: dict
:return: Title and tooltip
:rtype: str, str
"""
title = 'Interactions (Varied Atom Sets)'
# Add span to force rich text, to get the line breaks
tooltip = '<span>'
for asl, modes in task_mapping.items():
recep_disp = None
if asl == LIGAND_RECEPTOR_ASL:
hub = maestro_ui.MaestroHub.instance()
intview = hub.getInteractionViewState()
if modes[0] in traj_plot_models.NON_COVALENT_MODES:
recep_disp = LIGAND_RECEPTOR_DISPLAY_TO_TITLE[
intview.non_covalent_display]
elif modes[0] in traj_plot_models.PI_INTERACTION_MODES:
recep_disp = LIGAND_RECEPTOR_DISPLAY_TO_TITLE[
intview.pi_display]
title_asl = INTERACTION_ASL_TO_TITLE[asl]
mode_names = [
traj_plot_models.ANALYSIS_MODE_MAP[mode].name for mode in modes
]
tooltip += f'{title_asl}: {", ".join(mode_names)}\n'
if recep_disp:
tooltip += f' ({recep_disp})'
tooltip += '\n'
tooltip += '</span>'
return title, tooltip
[docs] def getTasks(self):
"""
Return all tasks that are managed by this plot manager.
"""
return self.task_queue.getTasks()
[docs] def isRunning(self):
"""
Return True if the plot is still generating data.
"""
return bool(self.task_queue._running_tasks)
[docs] def formatPlotAxes(self):
"""
Formats axes tick numbers and spacing depending on result data.
"""
results = []
for task in self.getTasks():
results += task.output.result
self._formatPlotAxes(results)
[docs] def onPlotClicked(self, value):
"""
There is no click handler for multi-series interactions plots.
"""
if self.time_to_frame_map:
time = value.x()
frame_idx = self._getNearestFrameForTime(time)
if frame_idx is not None:
eid = self.entry_traj.eid
self.displayFrameAndAsl.emit(traj_plot_models.EMPTY_ASL, eid,
frame_idx)