Source code for polo.widgets.optimize_widget

import math

from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QBitmap, QBrush, QColor, QIcon, QPainter, QPixmap
from PyQt5.QtWidgets import QGraphicsColorizeEffect, QGraphicsScene

from polo import ICON_DICT, IMAGE_CLASSIFICATIONS, make_default_logger
from polo.crystallography.cocktail import UnitValue
from polo.crystallography.image import Image
from polo.crystallography.run import HWIRun, Run
from polo.designer.UI_optimizeWidget import Ui_Form
from polo.utils.io_utils import write_screen_html
from polo.widgets.unit_combo import UnitComboBox

logger = make_default_logger(__name__)



[docs]class OptimizeWidget(QtWidgets.QWidget): HTML_ICON = str(ICON_DICT['html']) GRID_ICON = str(ICON_DICT['grid']) '''The :class:`OptimizeWidget` class is a primary run interface widget that allows users to create optimization screens around the crystallization conditions that yielded xtal hits. The concept is very similar to the program MakeTray available from Hampton Research. Currently, users cannot specify their own conditions and are limited to the predetermined conditions of the :class:`~polo.utils.io_utils.Menu` that was selected when the run was originally imported into Polo. Additionally, the :class:`OptimizeWidget` is only available to :class:`HWIRun` instances as the cocktail to well mapping cannot be inferred for other more general :class:`Run` types. :param parent: Parent Widget :type parent: QWidget :param run: Run to screen for hits from, defaults to None :type run: HWIRun, optional ''' def __init__(self, parent, run=None): super(OptimizeWidget, self).__init__(parent) self.ui = Ui_Form() self.ui.setupUi(self) self._run = run self._current_reagents = None self.ui.comboBox_12.currentTextChanged.connect( self._update_current_reagents ) self._set_up_unit_comboboxes() # set up combos before connecting to signals self.ui.comboBox_6.currentTextChanged.connect( self._set_reagent_stock_con ) self.ui.unitComboBox.ui.doubleSpinBox.valueChanged.connect( lambda: self._set_reagent_stock_con_values(x=True) ) self.ui.unitComboBox.ui.comboBox.currentTextChanged.connect( lambda: self._set_reagent_stock_con_values(x=True) ) self.ui.unitComboBox_3.ui.doubleSpinBox.valueChanged.connect( lambda: self._set_reagent_stock_con_values(y=True) ) self.ui.unitComboBox_3.ui.comboBox.currentTextChanged.connect( lambda: self._set_reagent_stock_con_values(y=True) ) self.ui.unitComboBox_4.ui.doubleSpinBox.valueChanged.connect( lambda: self._set_reagent_stock_con_values(const=True) ) self.ui.unitComboBox_4.ui.comboBox.currentTextChanged.connect( lambda: self._set_reagent_stock_con_values(const=True) ) self.ui.pushButton_27.clicked.connect(self._write_optimization_screen) self.ui.pushButton_26.clicked.connect(self._export_screen) self._header = self.ui.tableWidget.horizontalHeader() self._sider = self.ui.tableWidget.verticalHeader() self._header.setSectionResizeMode( QtWidgets.QHeaderView.ResizeToContents) self._sider.setSectionResizeMode( QtWidgets.QHeaderView.ResizeToContents) # update constant reagents when x or y reagents change self.ui.comboBox_6.currentTextChanged.connect( lambda: self._handle_reagent_change(x=True) ) self.ui.comboBox_13.currentTextChanged.connect( lambda: self._handle_reagent_change(y=True) ) self.ui.pushButton_26.setIcon(QIcon(self.HTML_ICON)) self.ui.pushButton_27.setIcon(QIcon(self.GRID_ICON)) logger.debug('Created {}'.format(self)) @property def x_wells(self): '''Returns spinBox value that representing the number wells on the x axis of the screen.''' return self.ui.spinBox_2.value() @property def y_wells(self): '''Returns spinBox value representing the number of wells on the y axis of the screen.''' return self.ui.spinBox_3.value() @property def x_reagent(self): '''Used to retrieve the :class:`Reagent` object that is to be varied along the x axis of the screen. ''' reagent_text = self.ui.comboBox_6.currentText() if reagent_text and reagent_text in self._current_reagents: return self._current_reagents[reagent_text] else: return None @property def y_reagent(self): '''Used to retreive the :class:`Reagent` object that is to be varied along the y axis of the optimization plate ''' reagent_text = self.ui.comboBox_13.currentText() if reagent_text and reagent_text in self._current_reagents: return self._current_reagents[reagent_text] @property def constant_reagents(self): '''Retrieve a set of :class:`Reagents` that are not included as either the attr:`x_reagent` or the attr:`y_reagent` but are still part of the crystallization cocktail and therefore need to be included in the screen. Unlike either the x or y reagents, constant reagents do not change their concentration across the screening plate. ''' x, y = self.x_reagent, self.y_reagent if x and y: v = set([x, y]) a = set(list(self._current_reagents.values())) return a - v @property def selected_constant(self): '''Return the constant :class:`Reagent` that is currently selected by the user. :return: Currently selected constant :class:`~polo.crystallography.cocktail.Reagent` if exists and selected, None otherwise :rtype: Reagent or None ''' if self.ui.listWidget_4.currentItem(): sel_cont = self.ui.listWidget_4.currentItem().text() if sel_cont and sel_cont in self._current_reagents: return self._current_reagents[sel_cont] else: return None @property def well_volume(self): '''Returns the well volume set by the user modifed by whatever well volume unit is currently selected. ''' return self.ui.unitComboBox_2.get_value() @property def hit_images(self): '''Retrieves a list of :class:`~polo.crystallography.image.Image` object instances that have human classification == 'Crystals'. Used to determine what wells to allow the user to optimize. Currently, only allow the user to optimize wells they have marked as crystal. ''' hits = [] if isinstance(self.run, (Run, HWIRun)): for image in self.run.images: if (image.human_class == IMAGE_CLASSIFICATIONS[0] and not image.is_placeholder): hits.append(image) return hits @property def x_step(self): '''The percent variance between :attr:`x_reagent` wells. ''' return self.ui.doubleSpinBox_4.value() / 100 @property def y_step(self): '''The percent variance between :attr:`y_reagent` wells. ''' return self.ui.doubleSpinBox_5.value() / 100 @property def run(self): return self._run @run.setter def run(self, new_run): self._run = new_run if isinstance(new_run, (Run, HWIRun)): self._set_hit_well_choices() # give use options of crystal hits self._update_current_reagents() logger.debug('Opened new run {}'.format(self._run))
[docs] def _set_up_unit_comboboxes(self): '''Private method that sets the base unit and the scalers of all :class:`unitComboBox` instances that are apart of the UI. ''' self.ui.unitComboBox.base_unit = 'M' # x reagent stock con setter self.ui.unitComboBox.scalers = UnitComboBox.saved_scalers self.ui.unitComboBox_3.base_unit = self.ui.unitComboBox.base_unit self.ui.unitComboBox_3.scalers = self.ui.unitComboBox.scalers self.ui.unitComboBox_4.base_unit = self.ui.unitComboBox.base_unit self.ui.unitComboBox_4.scalers = self.ui.unitComboBox.scalers self.ui.unitComboBox_2.base_unit = 'L' # well volume selector self.ui.unitComboBox_2.scalers = UnitComboBox.saved_scalers
[docs] def _handle_reagent_change(self, x=False, y=False, const=False): '''Private method that handles when a :class:`~polo.crystallography.cocktail.Reagent` is changed. The arguments indicate which :class:`~polo.crystallography.cocktail.Reagent` has been changed. :param x: If True update the x reagent, defaults to False :type x: bool, optional :param y: If True update the y reagent, defaults to False :type y: bool, optional :param const: If True update the constant reagents, defaults to False :type const: bool, optional ''' if x or y: if x and self.x_reagent: self.ui.unitComboBox.set_value(self.x_reagent.stock_con) elif y and self.y_reagent: self.ui.unitComboBox_3.set_value(self.y_reagent.stock_con) self._set_constant_reagents() elif const: self.ui.listWidget_4.setCurrentIndex(0) if self.selected_constant: self.ui.unitComboBox_4.set_value(self.selected_constant.stock_con)
[docs] def update_interface(self): '''Method to update reagents and selectable wells to the user after they have made additional classifications that would increase or decrease the pool of crystal classified images. ''' current_well = self.ui.comboBox_12.currentText() if self._set_hit_well_choices(): # set wells to pick from current_well_index = self.ui.comboBox_12.findText(current_well) if current_well_index > 0: self.ui.comboBox_12.setCurrentIndex(current_well_index) else: self.ui.comboBox_12.setCurrentIndex(0) self.ui.tableWidget.clear() self._set_constant_reagents()
# set the current index in order to update the reagent choices
[docs] def _set_hit_well_choices(self): '''Private method that sets the hit well comboBox widget choices based on the images in the :attr:`~OptimizeWidget.run` attribute that are human classified as crystal. Wells are identified in the comboBox by their well number. ''' if isinstance(self.run, (Run, HWIRun)): hits = self.hit_images self.ui.comboBox_12.clear() if hits: self.ui.comboBox_12.addItems( [str(image.well_number) for image in hits] ) return True else: return False
# sets options to well numbers of hits
[docs] def _update_current_reagents(self, image_index=None): '''Private method that updates x and y :class:`~polo.crystallography.cocktail.Reagent` comboBox widgets to reflect what :class:`Reagent` instances are contained in the currently selected well. :param image_index: Index of the :class:`Image` to set :class:`Reagent` choices from, defaults to None. :type image_index: int, optional ''' if self.run and image_index: image_index = int(image_index) - 1 self._current_reagents = { str(r): r for r in self.run.images[image_index].cocktail.reagents} self._set_reagent_choices() # change whats listed in combo boxes
[docs] def _set_constant_reagents(self): '''Private method that populates the listWidget with constant reagents to display to the user. ''' constants = self.constant_reagents self.ui.listWidget_4.clear() if constants: items = [str(r) for r in constants] self.ui.listWidget_4.addItems(items) self.ui.listWidget_4.setCurrentRow(0)
[docs] def _set_reagent_choices(self): '''Private method that sets :class:`Reagent` choices for the x and y reagents based on the currently selected well. :class:`Reagents` must come from the class:`Cocktail` instance associated with the selected well. TODO: Add the option to vary pH instead of a :class:`~polo.crystallography.cocktail.Reagent` along either axis. This would also mean that the constant reagents would need to be updated. ''' # assumes current reagents have already been set if self._current_reagents: self.ui.comboBox_6.clear() self.ui.comboBox_13.clear() for reagent in self._current_reagents: self.ui.comboBox_6.addItem(str(reagent)) self.ui.comboBox_13.addItem(str(reagent)) # self.ui.comboBox_6.addItem('pH') TODO allow user to varry pH instead of reagents # self.ui.comboBox_6.addItem('pH') self.ui.comboBox_6.setCurrentIndex(0) self.ui.comboBox_13.setCurrentIndex(len(self._current_reagents)-1) self._set_constant_reagents()
[docs] def _set_reagent_stock_con(self): '''Private method. If a :class:`Reagent` has already been assigned a stock concentration this method displays that concentration to the user through the appropriate :class:`UnitCombobBox` instance. Only displays concentrations for the x and y reagents. ''' if self.x_reagent and self.x_reagent.stock_con: self.ui.unitComboBox.set_value(self.x_reagent.stock_con) else: self.ui.unitComboBox.set_zero() if self.y_reagent and self.y_reagent.stock_con: self.ui.unitComboBox_3.set_value(self.y_reagent.stock_con) else: self.ui.unitComboBox_3.set_zero()
# self._set_constant_reagents()
[docs] def _set_reagent_stock_con_values(self, x=False, y=False, const=False): '''Private method to update the stock concentations of current reagents through their :attr:`~polo.crystallography.cocktail.Reagent.stock_con` attribute. The :class:`Reagent` to update is indicated by the flag set to True at the time the method is called. The stock concentration value is pulled from each reagent's respective `unitComboBox` instance. :param x: If True, set :attr:`x_reagent` stock con, defaults to False :type x: bool, optional :param y: If True, set the :attr:`y_reagent` stock con, defaults to False :type y: bool, optional :param const: If True, sets the constant reagents stock con, defaults to False :type const: bool, optional ''' if x and self.x_reagent: new_con = self.ui.unitComboBox.get_value() self.x_reagent.stock_con = new_con elif y and self.y_reagent: new_con = self.ui.unitComboBox_3.get_value() self.y_reagent.stock_con = new_con elif const and self.selected_constant: new_con = self.ui.unitComboBox_4.get_value() self.selected_constant.stock_con = new_con
[docs] def _gradient(self, reagent, num_wells, step, stock=False): '''Private method for calculating a concentration gradient for a given :class:`Reagent` using a given step size as a proportion of the :class:`Reagent` instance's :attr:`~polo.crystallography.cocktail.Reagent.concentration` attribute. :param reagent: Reagent to vary concentration :type reagent: Reagent :param num_wells: Number of wells to vary concentration across :type num_wells: int :param step: Proportion of hit concentration to vary each well by :type step: float < 1 :param stock: If True, vary the stock volume not the hit concentration unit, defaults to False :type stock: bool, optional :return: List of UnitValues that make up the _gradient :rtype: list ''' if stock and reagent.stock_volume(self.well_volume): c = reagent.stock_volume(self.well_volume.to_base()) else: c = reagent.concentration m = math.floor(num_wells / 2) s = float(c) * step return [UnitValue((c.value + (s * (n-m))), c.units) for n in range( 1, num_wells+1)] # return a list of signed values
[docs] def _write_optimization_screen(self): '''Private method to write the current optimization screen to the :class:`tableWidget` UI for display to the user. ''' if self._error_checker(): x_grad_stock, x_grad_con = ( self._gradient(self.x_reagent, self.x_wells, self.x_step, stock=True), self._gradient(self.x_reagent, self.x_wells, self.x_step)) y_grad_stock, y_grad_con = ( self._gradient(self.y_reagent, self.y_wells, self.y_step, stock=True), self._gradient(self.y_reagent, self.y_wells, self.y_step) ) constants = [] for c in self.constant_reagents: stock_vol = c.stock_volume(self.well_volume.to_base()) if not stock_vol: stock_vol = c.concentration.to_base() constants.append((c.chemical_additive, c.concentration, stock_vol)) self.ui.tableWidget.setRowCount(len(y_grad_con)) self.ui.tableWidget.setColumnCount(len(x_grad_con)) # x is column y is row breaker = False for i in range(0, len(y_grad_con)): for j in range(0, len(x_grad_con)): volume_list = [x_grad_stock[j], y_grad_stock[i]] + [c[-1] for c in constants] # pull out the concentrations from constant tuples water_volume = self._check_for_overflow(volume_list) if water_volume: # well does not overflow well_string = self._make_well_html( x_grad_con[j], x_grad_stock[j], y_grad_con[i], y_grad_stock[i], constants, water_volume) widget = QtWidgets.QTextBrowser(self) widget.setText(well_string) self.ui.tableWidget.setCellWidget(i, j, widget) else: # well overflows msg = QtWidgets.QMessageBox() msg.setIcon(QtWidgets.QMessageBox.Warning) msg.setText('Well Overflow Error! Try increasing \ your stock concentrations or using a higher well volume.') msg.setStandardButtons(QtWidgets.QMessageBox.Ok) msg.exec_() self.ui.tableWidget.clear() breaker = True break if breaker: break self.repaint() # repaint for Mac catalina OS
[docs] def adjust_unit(self, signed_value, new_unit, ndigits=4): adjusted = None if signed_value.units == 'L': # only convert volume for now if new_unit == 'ul': adjusted = signed_value.scale('u') elif new_unit == 'ml': adjusted = signed_value.scale('m') elif new_unit == 'cl': adjusted = signed_value.scale('c') else: adjusted = signed_value else: adjusted = signed_value if adjusted and ndigits: adjusted = round(adjusted, ndigits) return adjusted
[docs] def _make_well_html(self, x_con, x_stock, y_con, y_stock, constants, water): '''Private method to format the information that describes the contents of an individual well into prettier html that can be displayed to the user in a :class:`textBrowser` widget. :param x_con: Concentration of x reagent in this well :type x_con: UnitValue :param x_stock: Volume of x reagent stock in this well :type x_stock: UnitValue :param y_con: Concentration of y reagent in this well :type y_con: UnitValue :param y_stock: Volume of y reagent stock in this well :type y_stock: UnitValue :param constants: Tuples of constant reagents to be included in each well :type constants: list of tuples :param water: Volume of water to be added to this well :type water: Signed Value :return: Html string to be rendered to the user :rtype: str ''' write_unit = self.ui.comboBox_16.currentText() template, s = '<h4>{} {}</h4>\n{} of stock\n', '' s += template.format( self.x_reagent.chemical_additive, x_con, self.adjust_unit(x_stock, write_unit) ) s += template.format( self.y_reagent.chemical_additive, y_con, self.adjust_unit(y_stock, write_unit) ) for c in constants: a, b, d = c s += template.format(a, b, self.adjust_unit(d, write_unit)) # rename this so it makes sense s += '<h4>Volume of H20</h4>\n{}'.format(self.adjust_unit(water, write_unit)) return s
[docs] def _error_checker(self): '''Private method to check if all widgets and attributes have allowed values before calculating the actual grid screen. Show error message if there is a conflict. ''' error, message = False, '' if self.well_volume.value <= 0: error, message = True, 'Please set well volume > 0.' elif self.x_wells == 0 or self.y_wells == 0: error, message = True, 'Please set well dimensions > 0' elif not self.x_reagent or not self.y_reagent: error, message = True, 'Please select x and y reagents' elif not self.x_reagent.stock_con or not self.y_reagent.stock_con: error, message = True, 'Please set stock concentrations for x and y reagents' elif self.selected_constant and not self.selected_constant.stock_con: error, message = True, 'Please set constant reagent stock con' if error: msg = QtWidgets.QMessageBox() msg.setIcon(QtWidgets.QMessageBox.Information) msg.setText(message) msg.setStandardButtons(QtWidgets.QMessageBox.Ok) msg.exec_() logger.warning( 'Recorded error when making optimization screen: {}'.format( message )) return False else: return True
[docs] def _make_plate_list(self): '''Private method that converts the concents of the :class:`tableWidget` UI (assuming that a optimization screen has been already rendered to the user) to a list of lists that is easier to write to html using the jinja2 template. :return: tableWidget contents converted to list :rtype: list ''' plate_list = [] for i in range(0, self.ui.tableWidget.rowCount()): plate_list.append([]) for j in range(0, self.ui.tableWidget.columnCount()): plate_list[i].append( self.ui.tableWidget.cellWidget(i, j).toHtml()) return plate_list
[docs] def _export_screen(self): '''Private method to write the current optimization screen to a html file. ''' if self._run and self._error_checker(): export_path = QtWidgets.QFileDialog.getSaveFileName(self, 'Save Screen')[ 0] if export_path: well_number = self.ui.comboBox_12.currentText() run_name = self._run.run_name plate_list = self._make_plate_list() write_screen_html(plate_list, well_number, run_name, self.x_reagent, self.y_reagent, self.well_volume, export_path, )
[docs] def _check_for_overflow(self, volume_list): '''Private method to check if the volume of :class:`Reagent` instancess in a given well exceeds the total well volume. If an overflow is detected, return False otherwise return the volume of H20 that should be added to the well as a :class:`~polo.crystallography.cocktail.UnitValue` instance. :param volume_list: List of `UnitValues` that consitute the contents of a well in the optimization plate :type volume_list: list :return: `UnitValue` describing the volume of water that should be added to the well if it does not overflow in liters, False otherwise :rtype: UnitValue or False ''' # args should be volumes as signed value of all stuff max_volume, total_volume = self.well_volume, 0 max_volume = max_volume.to_base() for value in volume_list: if isinstance(value, UnitValue): if value.units == 'L': total_volume += value.to_base().value elif value.units == 'w/v': pass # some kind of warning here about could not convert # weight volume elif value.units == 'v/v': total_volume += max_volume.value * (value.value/100) if total_volume > max_volume.value: logger.debug( 'Recorded well overflow. Total volume: {} Max Volume: {}'.format( total_volume, max_volume )) return False else: # does fit in the well return UnitValue(max_volume.value - total_volume, 'L')
# return the value of water that should be included in the well in # liters # def change_reagent_stock_con(self, value, reagent): # '''Change the stock concentration of a give reagent to a new value. # TODO: Support more units besides molarity. # :param value: The new concentration in mols / liter # :type value: float # :param reagent: The reagent who's stock con is being changed # :type reagent: Reagent # ''' # # look more into this now that chaning up now units are working # if value and reagent: # reagent.stock_con = UnitValue(value, 'M') # def change_constant_reagent_stock_con(self, value): # '''Changes the stock concentration of the currently selected # constant reagent to the concentration of the doublespinbox widget # associated with the constant reagents tab. # :param value: new concentration in mols / liter # :type value: float # ''' # if self.selected_constant: # selected_reagent = self.selected_constant # selected_reagent.stock_con = self.ui.unitComboBox_4.get_value() # def set_constant_reagent_stock_con(self): # '''Display the currently selected constant reagent's stock # concentration in the constant reagent double spin box widget. # ''' # current_reagent = self.selected_constant # if current_reagent: # if current_reagent.stock_con: # self.ui.unitComboBox_4.set_value( # current_reagent.stock_con) # else: # self.ui.unitComboBox_4.set_zero() # def changed_tab_update(self): # '''Method used for when user leaves the optimize widget tab and then # returns. The `OptimizeWidget` needs to maintain the current screen selection but update # the available hit wells because they may have classified additional # wells as crystal hits. # ''' # current_hit = self.ui.comboBox_12.currentText() # self._set_hit_well_choices() # index = self.ui.comboBox_12.findText(current_hit) # self.ui.comboBox_12.setCurrentIndex(index)