Demo entry 6356634

Periodic Program

   

Submitted by anonymous on Apr 19, 2017 at 17:39
Language: Python 3. Code size: 47.4 kB.

#Periodic Program by Sasha Duke
#This is the source code for developer use, if you would like to simply run the program please use the executable provided
#Look at the included LICENSE.txt file for license information

version = "0.5.3"
date = "2017-4-19"

#Only necessary functions from modules are imported as we do not want to bulk up the compiled executable
#General imports
import sys
from re import finditer, search, match
from threading import Thread
from queue import Queue
#from traceback import print_exc #GUI does not print errors when it crashes so they have to be printed with this function
#Uncomment above line when debugging

#Imports specifically for downloading and saving element data
from urllib.request import urlopen
from bs4 import BeautifulSoup
from pickle import dump, load

#GUI imports
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QStackedWidget, QVBoxLayout, QGridLayout,
                             QLabel, QPushButton, QLineEdit, QComboBox, QTextEdit, QMessageBox, QProgressBar)
from PyQt5.QtCore import QTimer, Qt
from PyQt5.QtGui import QIcon

#Each element is an object with all of these attributes
class Element:
    def __init__(self, name, z, symbol, ar, ec, group, period, block, mp, bp):
        self.name = name
        self.z = z              #Z in chemistry means proton number
        self.symbol = symbol
        self.ar = ar            #Ar in chemistry means relative atomic mass
        self.ec = ec            #Electron configuration
        self.group = group
        self.period = period
        self.block = block
        self.mp = mp            #Melting point
        self.bp = bp            #Boiling point

#Function returns the element object corresponding to its name, number, or symbol
def lookUpElement(search, elements):
    if search.isalnum(): #Checks the search term does not contain any invalid characters
        for element in elements:
            if search.lower() == element.name.lower() or search == element.symbol:
                return(element)

            #A try statement is used here as if the user is not searching by atomic number, casting it to an int will throw an error
            try:
                if int(search) == int(element.z):
                    return(element)
            except:
                pass

#This function breaks a formula into its constituent elements by number
def multiplyElements(formula):
    indices = []
    formulaList = []
    bracketStart = []

    #Checks the item does not contain any invalid characters
    if not match("^[a-zA-Z0-9(){}[\].*]*$", formula):
        return("")

    #Finds the positions of capital letters or separation symbols in the string. A capital letter signifies the start of a new element
    for cap in finditer("[A-Z(){}[\].*]", formula):
        indices.append(cap.start())

    #Splits the string into list items starting with each of these positions
    for pos, index in enumerate(indices):
        if pos + 1 < len(indices):
            formulaList.append(formula[index:indices[pos + 1]])
        else:
            formulaList.append(formula[index:])

    #Finds positions of brackets in the list
    for pos, item in enumerate(formulaList):
        if item == "(" or item == "{" or item == "[":
            bracketStart.append(pos)

    #This block of code handles the multiplication of items within brackets, including nested brackets
    for pos in range(len(bracketStart)):
        pointer1 = bracketStart[pos] + 1
        count = 0

        #To deal with nested brackets we have to keep track of how many brackets have been opened and closed
        #Every time a bracket is opened, the counter is incremented, and if a bracket is closed the counter is decremented
        #If the amount of open and closed brackets is equal, the items between those brackets can all be multiplied
        while pointer1 < len(formulaList):
            if formulaList[pointer1] == "(" or formulaList[pointer1] == "{" or formulaList[pointer1] == "[":
                count += 1
            if ")" in formulaList[pointer1] or "}" in formulaList[pointer1] or "]" in formulaList[pointer1]:
                count -= 1
            if count < 0:
                pointer2 = bracketStart[pos] + 1

                #This multiplies the number of each element (1 if there is no number) in between the brackets by the multiplier outside the brackets
                while pointer2 < pointer1:
                    if not search("[0-9]+", formulaList[pointer2]):
                        multiplier = 1
                    else:
                        multiplier = search("[0-9]+", formulaList[pointer2]).group() #Searches for the numerical component
                    try: #Adds the numerical component multiplied by the bracket multiplier back to the element
                        formulaList[pointer2] = search("[a-zA-Z]+", formulaList[pointer2]).group() + str(int(multiplier) * int(formulaList[pointer1][1:]))
                    except:
                        pass
                    pointer2 += 1
                break
            else:
                pointer1 += 1

    try:
        first = formula[:indices[0]] #Get any number value before the first element (start of first section)
    except:
        return("")

    #Multiplies the number of each element in a section the formula (up to a dot or a star) by the multiplier at the start of that section
    for pos, item in enumerate(formulaList):
        if "." in item or "*" in item:
            if search("[0-9]+", item):
                first = search("[0-9]+", item).group()

        if first:
            if not search("[0-9]+", item):
                multiplier = 1
            else:
                multiplier = int(search("[0-9]+", item).group())
            try:
                formulaList[pos] = search("[a-zA-Z]+", item).group() + str(multiplier * int(first))
            except:
                pass

    #Adds up duplicates of elements, leaving just the individual element constituents and the number of atoms of each
    compList = []
    for item in formulaList:
        if not search("[a-zA-Z]+", item):
            continue
        element = search("[a-zA-Z]+", item).group()

        if not search("[0-9]+", item):
            multiplier = 1
        else:
            multiplier = int(search("[0-9]+", item).group())

        #Checks if the element is already in the list, adds to its number if it is, if not, adds to the end of the list
        found = False
        if compList:
            for item in compList:
                if element == item[0]:
                    item[1] += multiplier
                    found = True

        if not found:
            compList.append([element, multiplier])

    compList = sorted(compList, key = lambda e:e[1], reverse = True) #Sort the list alphabetically
    return(compList)

#This function looks up the molar mass of each element and multiplies it by the number of that element, adding them up to find the total molar mass of the compound
def addMr(compList, elements):
    mr = 0 #Mr in chemistry means relative formula mass

    for item in compList:
        try:
            ar = lookUpElement(item[0], elements).ar
        except:
            return("")
        mr += float(ar) * item[1]

    if mr == 0:
        return("")
    return(("%.3f" % mr).rstrip("0").rstrip("."))
    #Each relative atomic mass is only to 3 decimal places of precision, therefore showing the final molar mass as more than 3 d.p. would be inaccurate
    #Therefore we round to 3 d.p. and remove the superfluous trailing zeroes. This practise is used multiple times in the program

#2 functions in one just to make life easier, multiplyElements is used by itself to find composition of a compound
def calculateMr(formula, elements):
    return(addMr(multiplyElements(formula), elements))

#This function is used to sort elements in a number of ways, and filter the results
def sortElements(master, elements):
    elementList = []
    index = 0

    #Get the drop down choices from the object it's being run from, defaults to show all results in ascending order if no choice was selected
    sortBy = master.sortBy.currentText()
    direction = master.direction.currentText()
    if not direction:
        direction = "Ascending"
    try:
        number = int(master.number.text())
    except:
        number = 118
    group = master.group.currentText()
    period = master.period.currentText()
    block = master.block.currentText()

    #Which value in the list to sort by
    if sortBy == "Atomic Number":
        index = 0
    elif sortBy == "Alphabetical":
        index = 1
    elif sortBy == "Molar Mass (g/mol)":
        index = 2
    elif sortBy == "Melting Point (K)":
        index = 6
    elif sortBy == "Boiling Point (K)":
        index = 7
    else:
        return("")

    #List of elements with all values that could be sorted
    for element in elements:
        elementList.append([int(element.z), element.name, float(element.ar),
                                element.group, element.period, element.block])
        #Melting and boiling points are not always known, if there a certain element object does not have one of those values "None" will be put in its place
        if element.mp and element.bp:
            elementList[-1].extend([float(element.mp), float(element.bp)]) #Extend is used instead of append because we want to add to the list of the last item, not add a new item
        elif element.mp:
            elementList[-1].extend([float(element.mp), None])
        elif element.bp:
            elementList[-1].extend([None, float(element.bp)])
        else:
            elementList[-1].extend([None, None])

    if index == 6 or index == 7:
        elementList[:] = [e for e in elementList if e[index] is not None] #Remove results with no melting/boiling point values if sorting by melting/boiling point
    #Remove results of a different group, period of block if filtering by one of those
    if group:
        elementList[:] = [e for e in elementList if e[3] == group]
    if period:
        elementList[:] = [e for e in elementList if e[4] == period]
    if block:
        elementList[:] = [e for e in elementList if e[5] == block]

    #Sort final results by value in list, in reverse order if descending. Shorten list to requested number of results
    if direction == "Ascending":
        sortedList = sorted(elementList, key = lambda e:e[index])[:number]
    elif direction == "Descending":
        sortedList = sorted(elementList, key = lambda e:e[index], reverse = True)[:number]

    if not sortedList:
        return("")

    #Format values to be displayed as a block of text, this is just for the sorter widget
    output = ""
    count = 1
    for item in sortedList:
        if index == 0: #Format for atomic number sort results
            output += item[1] + " - " + str(item[index])
        elif index == 1: #Format for alphabetical sort results
            output += item[1]
        else: #Format for rest of sort results
            output += "#" + str(count) + ": " + item[1] + " - " + str(item[index])
        output += "\n"
        count += 1

    return(output)

#Return to menu function used by all menu buttons
def menu(master):
    master.widgets.setCurrentWidget(master.menuWidget)

#Close function used by all quit buttons and windows close button in corner
def close(master):
    choice = QMessageBox.question(master, "Confirm Quit", "Exit application?",
                                  QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel)
    if choice == QMessageBox.Yes:
        #Checks if the download is running and stops it if it is
        if master.menuWidget.loadingWidget:
            master.menuWidget.loadingWidget.cancelDownload = True
        elif master.window.menuWidget.loadingWidget.loadingWidget:
            master.window.menuWidget.loadingWidget.cancelDownload = True
        sys.exit(0)

#Parent class for all the widgets in the program
class Window(QMainWindow):
    def __init__(self):     #These __init__ functions only run when the object is created
        super().__init__()  #This initialises the parent class (QMainWindow for this or QWidget for the Widgets)

        self.resize(400, 600)
        self.setWindowTitle("Periodic Program")
        self.setWindowIcon(QIcon("icon.png"))

        #Handles switching between widgets
        self.widgets = QStackedWidget()
        self.setCentralWidget(self.widgets)
        self.show()

        self.elements = [] #Where all the element objects are stored, passed to all functions that use them to avoid using a global

        #Creating the menu widget and switching to it
        self.menuWidget = MainMenu(self)
        self.widgets.addWidget(self.menuWidget)
        self.widgets.setCurrentWidget(self.menuWidget)

    #Overriding windows close event to use my own in order to stop the download correctly if it is happening
    #As the download happens in its own thread, it would continue usually even if the main thread was closed
    def closeEvent(self, event):
        close(self)
        event.ignore()  #The close event is only ignored if the cancel button is pressed in the popup dialogue from the close button, otherwise sys.exit is called inside the function
                        #We have to put this as the windows close event would happen otherwise even if the cancel button was pressed

#Main menu for switching to other widgets
class MainMenu(QWidget):
    def __init__(self, master):
        super().__init__()
        self.window = master #We pass this through to every widget, so we can tell the window to switch to another widget or use the "global" elements list

        #Looks for saved element data, if it is not there then downloads element data
        try:
            self.window.elements = load(open("elements.pkl", "rb"))
        except:
            QTimer.singleShot(0, self.fetchElements) #The reason this has been done like this is explained in the fetchElements function

        self.inputButton = QPushButton("Element and Formula Lookup")
        self.calcButton = QPushButton("Molar Calculator")
        self.yieldButton = QPushButton("Theoretical Yield Calculator")
        self.sortButton = QPushButton("Sort and Filter Elements")
        self.fetchButton = QPushButton("Update Element Data")
        self.helpButton = QPushButton("Help and Info")
        self.exitButton = QPushButton("Quit")

        #This connects the GUI buttons to functions when clicked
        self.inputButton.clicked.connect(self.search)
        self.calcButton.clicked.connect(self.calculate)
        self.yieldButton.clicked.connect(self.theoreticalYield)
        self.sortButton.clicked.connect(self.sort)
        self.fetchButton.clicked.connect(self.confirmOverwrite)
        self.helpButton.clicked.connect(self.info)
        self.exitButton.clicked.connect(self.closeWrapper)

        self.layout = QVBoxLayout() #Vertical arrangement of items
        self.layout.addWidget(self.inputButton)
        self.layout.addWidget(self.calcButton)
        self.layout.addWidget(self.yieldButton)
        self.layout.addWidget(self.sortButton)
        self.layout.addWidget(self.fetchButton)

        self.layout.addStretch() #Adds space in between the top and bottom areas so the buttons are not stretched out
        self.layout.addWidget(self.helpButton)
        self.layout.addWidget(self.exitButton)
        self.setLayout(self.layout)

    #Dialogue to check if the user wants to overwrite the element data
    def confirmOverwrite(self):
        choice = QMessageBox.question(self, "Confirm Overwrite", "Are you sure you want to update the element data?\n" \
                                      "This can be a long download and will overwrite the existing data.",
                                      QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel)
        if choice == QMessageBox.Yes:
            self.fetchElements()

    #These functions will create the appropriate widget object if it does not exist already, and switch to it
    def search(self):
        if not hasattr(self, "searchWidget"): #Avoiding creating a new widget object if it exists means we can persist data between tabs
            self.searchWidget = Search(self.window)
            self.window.widgets.addWidget(self.searchWidget)
        self.window.widgets.setCurrentWidget(self.searchWidget) #What the user has typed in and the output will stay as we are switching between widgets, not recreating them

    def calculate(self):
        if not hasattr(self, "calcWidget"):
            self.calcWidget = MolesCalculator(self.window)
            self.window.widgets.addWidget(self.calcWidget)
        self.window.widgets.setCurrentWidget(self.calcWidget)

    def theoreticalYield(self):
        if not hasattr(self, "yieldWidget"):
            self.yieldWidget = YieldCalculator(self.window)
            self.window.widgets.addWidget(self.yieldWidget)
        self.window.widgets.setCurrentWidget(self.yieldWidget)

    def sort(self):
        if not hasattr(self, "sortWidget"):
            self.sortWidget = SortElements(self.window)
            self.window.widgets.addWidget(self.sortWidget)
        self.window.widgets.setCurrentWidget(self.sortWidget)

    #This is the only widget that we create a new object for every time, as it is necessary for it to be initialised so all the download code is run each time
    #There is also no data we need to persist here, not to mention the user is not going to be switching in and out of this tab
    def fetchElements(self):
        self.loadingWidget = ElementFetcher(self.window)
        self.window.widgets.addWidget(self.loadingWidget)
        self.window.widgets.setCurrentWidget(self.loadingWidget)

    def info(self):
        if not hasattr(self, "helpWidget"):
            self.helpWidget = HelpInfo(self.window)
            self.window.widgets.addWidget(self.helpWidget)
        self.window.widgets.setCurrentWidget(self.helpWidget)

    #The connect function on buttons does not let you pass a parameter, so I cannot get it to do close(self) directly, only self.closeWrapper()
    def closeWrapper(self):
        close(self)

#This downloads the elements and has the GUI for the loading screen while downloading
class ElementFetcher(QWidget):
    def __init__(self, master):
        super().__init__()
        self.window = master
        self.cancelDownload = False #Explained later

        self.status1 = QLabel("Fetching element data from internet:")
        self.status2 = QLabel("Starting download")
        self.pbar = QProgressBar()
        self.pbar.setFormat("  0/118")

        self.cancelButton = QPushButton("Cancel download")
        self.exitButton = QPushButton("Quit")
        self.cancelButton.clicked.connect(self.cancel)
        self.exitButton.clicked.connect(self.closeWrapper)

        self.layout = QVBoxLayout()
        self.layout.addWidget(self.status1)
        self.layout.addWidget(self.pbar)
        self.layout.addWidget(self.status2)

        self.layout.addStretch()
        if self.window.elements: #If there is no elements list, the download should not be able to be cancelled as the program depends on this
            self.layout.addWidget(self.cancelButton)
        self.layout.addWidget(self.exitButton)
        self.setLayout(self.layout)

        self.q = Queue() #Explained later
        self.updateUI()
        self.download()

    def download(self):
        def downloadInner(): #An inner function is needed because a thread has to target a function when it is created
            elements = []

            for e in range(0,118):      #We want this loop of code to be run in a separate thread to the main program so we surrounded it with a function to be targeted
                if self.cancelDownload: #A thread cannot be stopped so we must have a check in the loop to see if it should keep carrying on
                    break               #Sometimes we want to end the download early, for example if the user closes the program or cancels the download it should end
                                        #Otherwise the download will keep going once the main thread (the program) has been closed

                #The urls on the royal society of chemistry website for elements can be iterated through
                #.../element/1 will take you to the element 1 page and .../element/2 to the element 2 page and so on, we can increment this for every loop
                url = "http://www.rsc.org/periodic-table/element/" + str(e + 1)
                try:
                    page = BeautifulSoup(urlopen(url), "html.parser")
                except:
                    #self.status2.setText("Could not connect to element " + str(e + 1) + " page") #Not sure how to fix this
                    self.cancelDownload = True
                    break

                #The following pieces of information are retrieved from parts of the webpage, I made a function called "get" to automate finding values in the same table
                table = page.find("table", class_ = "element_hover_table_ca").find_all("td") #Finds the table by its class, this contains most of the other data
                name = str(page.find("title")).split()[1] #The title of the page always has the element name in it
                symbol = str(page.find(id = "murrayM")).split()[-2] #Finding it by its unique ID
                z = e + 1
                ar = self.get(table, 15).strip("[]") #Unstable elements have square brackets around their mass number
                group = self.get(table, 1)
                period = self.get(table, 5)
                block = self.get(table, 9).capitalize()
                ec = (self.get(table, 21, -3) + " " + self.get(table, 21).replace("</sup><sup>","").replace("<sup>", "").replace("</sup>", " ")).replace('text_bold"> ', "")[:-1]
                #Electron configuration is written with superscript HTML tags which need to be stripped from the string, as well as other tags

                mp = self.get(table, 3, -3)
                if mp == "width=\"230\">": #If the melting point is unknown this is what will be taken from the HTML
                    mp = None
                bp = self.get(table, 7, -3)
                if bp == "border_right_none\">": #If the boiling point is unknown this is what will be taken from the HTML
                    bp = None

                elements.append(Element(name, z, symbol, ar, ec, group, period, block, mp, bp)) #Makes the element into an object and adds it to the list
                self.q.put((name, e)) #This queue keeps track of the last element fetched and is necessary for keeping the GUI updated
                #Because the GUI is running in a separate thread to this loop, queues and timers need to be used to keep the GUI updated or it will not keep up

            if not self.cancelDownload: #If the download is cancelled halfway through partially complete element data may overwrite the saved data if we do not prevent it
                dump(elements,  open("elements.pkl", "wb")) #Only happens if download fully completes
                self.window.elements = elements
            self.q.put(("end", 0)) #Signals the end of the GUI update loop
            self.window.widgets.setCurrentWidget(self.window.menuWidget)

        downloadThread = Thread(target = downloadInner) #Makes the loop code into its own thread
        downloadThread.daemon = True #This may not be needed, I'm not 100% sure on how this works in this context as multithreading is complicated
        downloadThread.start()

    def updateUI(self):
        try:
            name, e = self.q.get(timeout = 0.01) #Gets the last item from the queue, to update the GUI
            if name != "end": #Updates it unless the end signal has been received
                self.status2.setText('Fetched data for "' + name + '"')
                self.pbar.setValue((e + 1) / 118 * 100)
                self.pbar.setFormat("  " + str(e + 1) + "/118")
                QApplication.processEvents()
                QTimer.singleShot(0.01, self.updateUI) #Checks for an update again
        except: #If the queue is empty
            QTimer.singleShot(0.01, self.updateUI)

    #As things retrieved from the webpage are mostly structured in the same way, this function can be reused for each item in the table to get the relevant data
    def get(self, obj, num, part = -2):
        return(str(obj[num]).split()[part])

    #Cancel the download when the button is pressed
    def cancel(self):
        choice = QMessageBox.question(self, "Confirm Cancel", "Are you sure you want to cancel the download?",
                                      QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
        if choice == QMessageBox.Yes:
            self.cancelDownload = True

    def closeWrapper(self):
        close(self)

#This widget is for looking up elements and formulae to get information about them / their compositions
class Search(QWidget):
    def __init__(self, master):
        super().__init__()
        self.window = master
        self.currentElement = 0 #Keeps track of the element currently listed

        self.formulaLabel = QLabel("Enter molecular formula or element name / atomic number (case-sensitive):")
        self.lookup = QLineEdit()

        self.dropDownLabel = QLabel("Or choose element from drop down list:")
        self.dropDown = QComboBox()
        self.dropDown.addItem("")
        for element in self.window.elements: #Drop down with a choice for each element
            self.dropDown.addItem(str(element.z) + " (" + element.symbol + ") " + element.name)

        self.info = QTextEdit()     #The text display is a TextEdit rather than a Label as I want people to be able to copy and paste between different widgets
        self.info.setReadOnly(True) #But not actually change the text inside hence it is read only, but can be highlighted and copied

        self.menuButton = QPushButton("Return to menu")
        self.exitButton = QPushButton("Quit")
        self.lookup.textChanged[str].connect(self.updateText) #These types of connects are very fancy, call the function when the input text is changed
        self.dropDown.activated[str].connect(self.updateText)
        self.menuButton.clicked.connect(self.menuWrapper)
        self.exitButton.clicked.connect(self.closeWrapper)

        self.layout = QVBoxLayout()
        self.layout.addWidget(self.formulaLabel)
        self.layout.addWidget(self.lookup)
        self.layout.addWidget(self.dropDownLabel)
        self.layout.addWidget(self.dropDown)
        self.layout.addWidget(self.info)

        self.layout.addWidget(self.menuButton)
        self.layout.addWidget(self.exitButton)
        self.setLayout(self.layout)

    #Called when the user types into the search bar or makes a choice in the drop down
    def updateText(self, text = None):
        text = ""
        element = lookUpElement(self.lookup.text(), self.window.elements) #Tries to find element typed in search bar
        if self.dropDown.currentIndex() != self.currentElement: #Checks if the current element being displayed is different to the one chosen in the drop down
            element = lookUpElement(str(self.dropDown.currentIndex()), self.window.elements) #Gets element chosen in drop down
            self.lookup.setText("") #Clears the text in the search bar as it is not representative of what is being displayed

        #This block happens if an element was found, otherwise it is a compound or an invalid search
        if element:
            self.currentElement = element.z
            self.dropDown.setCurrentIndex(self.currentElement) #Keeping track of the new element being displayed
            text += "Element\n\nName: " + element.name
            text += "\nAtomic Number: " + str(element.z)
            text += "\nSymbol: " + element.symbol
            if "." in element.ar: #If the molar mass is a whole number, it is the mass number of the most stable isotope of that unstable element
                text += "\nMolar Mass: " + element.ar + " g/mol"
            else:
                text += "\nMolar Mass: [" + element.ar + "] g/mol" #If this is the case it should have square brackets around it
            text += "\nElectron Configuration: " + element.ec
            text += "\nGroup: " + element.group
            text += "\nPeriod: " + element.period
            text += "\nBlock: " + element.block

            if element.mp == element.bp: #If there is a melting and boiling point and they are equal, it is a sublimation point
                if element.mp is not None:
                    text += "\nSublimation Point: " + element.mp + " K"
            #Sometimes there isn't a known melting or boiling point however
            else:
                if element.mp is not None:
                    text += "\nMelting Point: " + element.mp + " K"
                if element.bp is not None:
                    text += "\nBoiling Point: " + element.bp + " K"

        else:
            self.currentElement = 0 #Not an element
            self.dropDown.setCurrentIndex(self.currentElement) #Clears the dropdown choice
            mr = calculateMr(self.lookup.text(), self.window.elements) #This function will not return a molar mass if the search is invalid
            if mr: #If it is a valid search
                text += "Compound\nMolar Mass: " + mr + " g/mol\n\nComposition:"
                compList = multiplyElements(self.lookup.text())

                #Breaking down a compound into its constituents, see explanation of the multiplyElements function
                for item in compList:
                    e = lookUpElement(item[0], self.window.elements)
                    text += "\n"
                    text += "\n" + e.name + " (" + e.symbol + "): " + str(item[1]) + " atoms"

                    #Calculates the percentages of mass per atom and total, using same rounding technique as described earlier
                    if item[1] == 1:
                        text += "\n" + e.ar + " g/mol, " + str(("%.2f" % (float(e.ar) / float(mr) * 100)).rstrip("0").rstrip(".")) + "% of compound mass"
                    else:
                        text += "\nEach atom: " + e.ar + " g/mol," \
                                " " + str(("%.2f" % (float(e.ar) / float(mr) * 100)).rstrip("0").rstrip(".")) + "% of compound mass"
                        text += "\nTotal: " + str(("%.3f" % (float(e.ar) * item[1])).rstrip("0").rstrip(".")) + " g/mol," \
                                " " + str(("%.2f" % (float(e.ar) * item[1] / float(mr) * 100)).rstrip("0").rstrip(".")) + "% of compound mass"

        self.info.setText(text)

    #The connect function on buttons does not let you pass a parameter, so I cannot get it to do close(self) directly, only self.closeWrapper()
    def menuWrapper(self):
        menu(self.window)

    def closeWrapper(self):
        close(self)

#The main calculator and unit converter for moles-related calculations
class MolesCalculator(QWidget):
    def __init__(self, master):
        super().__init__()
        self.window = master
        self.clear1 = True #Will explain these later
        self.clear2 = True

        #I use chemistry symbols in these variable names - n for moles, mr for relative formula mass, etc.
        self.info = QLabel("Enter any values you have below:\n")
        self.formulaLabel = QLabel("Molecular Formula:")
        self.formula = QLineEdit()
        self.mrLabel = QLabel("Molar Mass (g/mol):")
        self.mr = QLineEdit()
        self.mLabel = QLabel("Mass (g):")
        self.m = QLineEdit()
        self.nLabel = QLabel("Amount (mol):")
        self.n = QLineEdit()
        self.vLabel = QLabel("Volume (dm3):")
        self.v = QLineEdit()
        self.cLabel = QLabel("Concentration (mol/dm3):")
        self.c = QLineEdit()

        self.calculateMr = QPushButton("Calculate molar mass")
        self.calculateM = QPushButton("Calculate mass")
        self.calculateN = QPushButton("Calculate moles")
        self.calculateV = QPushButton("Calculate volume")
        self.calculateC = QPushButton("Calculate concentration")
        self.menuButton = QPushButton("Return to menu")
        self.exitButton = QPushButton("Quit")

        self.formula.textChanged[str].connect(self.updateMrFromFormula)
        self.mr.textChanged[str].connect(self.clearFormula)
        self.calculateMr.clicked.connect(self.updateMr)
        self.calculateM.clicked.connect(self.updateM)
        self.calculateN.clicked.connect(self.updateN)
        self.calculateV.clicked.connect(self.updateV)
        self.calculateC.clicked.connect(self.updateC)
        self.menuButton.clicked.connect(self.menuWrapper)
        self.exitButton.clicked.connect(self.closeWrapper)

        self.layout = QVBoxLayout()
        self.layout.addWidget(self.info)
        self.layout.addWidget(self.formulaLabel)
        self.layout.addWidget(self.formula)
        self.layout.addWidget(self.mrLabel)
        self.layout.addWidget(self.mr)
        self.layout.addWidget(self.mLabel)
        self.layout.addWidget(self.m)
        self.layout.addWidget(self.nLabel)
        self.layout.addWidget(self.n)
        self.layout.addWidget(self.vLabel)
        self.layout.addWidget(self.v)
        self.layout.addWidget(self.cLabel)
        self.layout.addWidget(self.c)
        self.layout.addWidget(self.calculateMr)
        self.layout.addWidget(self.calculateM)
        self.layout.addWidget(self.calculateN)
        self.layout.addWidget(self.calculateV)
        self.layout.addWidget(self.calculateC)

        self.layout.addStretch()
        self.layout.addWidget(self.menuButton)
        self.layout.addWidget(self.exitButton)
        self.setLayout(self.layout)

    #The next 2 functions work with each other and use 2 booleans to check whether the text is being changed by the user or the program
    #This is because when the program changes the contents of the text box, it triggers the text changed function in the same way the user would
    def updateMrFromFormula(self):
        if self.clear2:
            output = calculateMr(self.formula.text(), self.window.elements)
            self.clear1 = False
            self.mr.setText(str(output))
            self.clear1 = True

    #This clears the formula box if the molar mass is changed as it no longer corresponds to the formula
    #Once again this uses the booleans to stop it from triggering itself recursively
    def clearFormula(self):
        if self.clear1:
            self.clear2 = False
            self.formula.setText("")
            self.clear2 = True

    #These functions handle the various calculations based on inputs
    def updateMr(self):
        m = self.m.text()
        n = self.n.text()
        try:
            self.mr.setText(str(float(m) / float(n)))
        except:
            self.mr.setText("") #If the input is not valid

    def updateM(self):
        n = self.n.text()
        mr = self.mr.text()
        try:
            self.m.setText(str(float(n) * float(mr)))
        except:
            self.m.setText("")

    def updateN(self):
        m = self.m.text()
        mr = self.mr.text()
        c = self.c.text()
        v = self.v.text()
        try:
            self.n.setText(str(float(m) / float(mr)))
        except:
            try:
                self.n.setText(str(float(c) * float(v)))
            except:
                self.n.setText("")

    def updateV(self):
        n = self.n.text()
        c = self.c.text()
        try:
            self.v.setText(str(float(n) / float(c)))
        except:
            self.v.setText("")

    def updateC(self):
        n = self.n.text()
        v = self.v.text()
        try:
            self.c.setText(str(float(n) / float(v)))
        except:
            self.c.setText("")

    def menuWrapper(self):
        menu(self.window)

    def closeWrapper(self):
        close(self)

#Calculates theoretical yield of a product from a known amount of reactant
class YieldCalculator(QWidget):
    def __init__(self, master):
        super().__init__()
        self.window = master
        self.clear1 = True
        self.clear2 = True

        self.info = QLabel("Enter any values you have below:\n")
        self.reactantLabel = QLabel("<b>Reactant</b>")
        self.reactant = QLineEdit()
        self.productLabel = QLabel("<b>Product</b>")
        self.product = QLineEdit()
        self.formulaLabel1 = QLabel("Molecular Formula")
        self.formulaLabel2 = QLabel("Molecular Formula")
        self.mrLabel1 = QLabel("Molar Mass (g/mol)")
        self.mrLabel2 = QLabel("Molar Mass (g/mol)")
        self.rMr = QLineEdit()  #Reactant Mr (relative formula mass)
        self.pMr = QLineEdit()  #Product Mr
        self.stoichiometryLabel1 = QLabel("Stoichiometry")
        self.stoichiometryLabel2 = QLabel("Stoichiometry")
        self.rStoichiometry = QLineEdit()
        self.pStoichiometry = QLineEdit()
        self.mLabel1 = QLabel("Mass (g)")
        self.mLabel2 = QLabel("Mass (g)")
        self.rM = QLineEdit()   #Reactant mass
        self.pM = QLineEdit()   #Product mass

        #Centering subtitles
        self.reactantLabel.setAlignment(Qt.AlignCenter)
        self.productLabel.setAlignment(Qt.AlignCenter)

        self.calcButton = QPushButton("Calculate mass")
        self.menuButton = QPushButton("Return to menu")
        self.exitButton = QPushButton("Quit")
        self.rMr.textChanged[str].connect(self.clearReactant)
        self.pMr.textChanged[str].connect(self.clearProduct)
        self.reactant.textChanged[str].connect(self.update)
        self.product.textChanged[str].connect(self.update)
        self.calcButton.clicked.connect(self.calculate)
        self.menuButton.clicked.connect(self.menuWrapper)
        self.exitButton.clicked.connect(self.closeWrapper)

        #Used a grid layout as I need 2 columns for reactant and product as well as the usual vertical rows layout
        self.grid = QGridLayout()
        self.grid.addWidget(self.reactantLabel, 0, 0)
        self.grid.addWidget(self.reactant, 2, 0)
        self.grid.addWidget(self.productLabel, 0, 1)
        self.grid.addWidget(self.product, 2, 1)
        self.grid.addWidget(self.formulaLabel1, 1, 0)
        self.grid.addWidget(self.formulaLabel2, 1, 1)
        self.grid.addWidget(self.mrLabel1, 3, 0)
        self.grid.addWidget(self.mrLabel2, 3, 1)
        self.grid.addWidget(self.rMr, 4, 0)
        self.grid.addWidget(self.pMr, 4, 1)
        self.grid.addWidget(self.stoichiometryLabel1, 5, 0)
        self.grid.addWidget(self.stoichiometryLabel2, 5, 1)
        self.grid.addWidget(self.rStoichiometry, 6, 0)
        self.grid.addWidget(self.pStoichiometry, 6, 1)
        self.grid.addWidget(self.mLabel1, 7, 0)
        self.grid.addWidget(self.mLabel2, 7, 1)
        self.grid.addWidget(self.rM, 8, 0)
        self.grid.addWidget(self.pM, 8, 1)

        #The grid layout is then added to the vertical box layout and the normal buttons are added separately at the bottom
        self.layout = QVBoxLayout()
        self.layout.addWidget(self.info)
        self.layout.addLayout(self.grid)
        self.layout.addWidget(self.calcButton)
        self.layout.addStretch()
        self.layout.addWidget(self.menuButton)
        self.layout.addWidget(self.exitButton)
        self.setLayout(self.layout)

    #Same bools clearing method as described earlier
    def clearReactant(self):
        if self.clear1:
            self.clear2 = False
            self.reactant.setText("")
            self.clear2 = True

    def clearProduct(self):
        if self.clear1:
            self.clear2 = False
            self.product.setText("")
            self.clear2 = True

    def update(self):
        if self.clear2:
            outputR = calculateMr(self.reactant.text(), self.window.elements)
            self.clear1 = False
            self.rMr.setText(str(outputR))
            self.clear1 = True

            outputP = calculateMr(self.product.text(), self.window.elements)
            self.clear1 = False
            self.pMr.setText(str(outputP))
            self.clear1 = True

    def calculate(self):
        rM = self.rM.text()
        rMr = self.rMr.text()
        pM = self.pM.text()
        pMr = self.pMr.text()

        rStoichiometry = self.rStoichiometry.text()
        pStoichiometry = self.pStoichiometry.text()
        if not rStoichiometry:
            rStoichiometry = 1
        if not pStoichiometry:
            pStoichiometry = 1

        if rM:
            try:
                self.pM.setText(str(((float(pMr) * int(pStoichiometry)) / (float(rMr) * int(rStoichiometry))) * float(rM)))
            except:
                self.pM.setText("")

        elif pM and not rM:
            try:
                self.rM.setText(str(((float(rMr) * int(rStoichiometry)) / (float(pMr) * int(pStoichiometry))) * float(pM)))
            except:
                self.rM.setText("")

    def menuWrapper(self):
        menu(self.window)

    def closeWrapper(self):
        close(self)

#Sorting and filtering of elements, almost all the code by this point has been explained previously or is self-explanatory
class SortElements(QWidget):
    def __init__(self, master):
        super().__init__()
        self.window = master

        self.sortLabel = QLabel("Sort by:")
        self.sortByLabel = QLabel("Type of sort")
        self.sortBy = QComboBox()
        self.directionLabel = QLabel("Direction of sort")
        self.direction = QComboBox()
        self.numberLabel = QLabel("Number of results")
        self.number = QLineEdit()
        self.filterLabel = QLabel("\nFilter results by:")
        self.groupLabel = QLabel("Group")
        self.group = QComboBox()
        self.periodLabel = QLabel("Period")
        self.period = QComboBox()
        self.blockLabel = QLabel("Block")
        self.block = QComboBox()
        self.info = QTextEdit()
        self.info.setReadOnly(True)

        #Adding all the sorting and filtering possibilities as choices
        self.sortBy.addItem("")
        self.sortBy.addItem("Atomic Number")
        self.sortBy.addItem("Alphabetical")
        self.sortBy.addItem("Relative Atomic Mass (g/mol)")
        self.sortBy.addItem("Melting Point (K)")
        self.sortBy.addItem("Boiling Point (K)")
        self.direction.addItem("")
        self.direction.addItem("Ascending")
        self.direction.addItem("Descending")
        self.group.addItem("")
        self.period.addItem("")
        for num in range(1,19):
            self.group.addItem(str(num))
            if num < 8:
                self.period.addItem(str(num))
        self.group.addItem("Lanthanides")
        self.group.addItem("Actinides")
        self.block.addItem("")
        self.block.addItem("S")
        self.block.addItem("P")
        self.block.addItem("D")
        self.block.addItem("F")

        self.menuButton = QPushButton("Return to menu")
        self.exitButton = QPushButton("Quit")
        self.sortBy.activated[str].connect(self.updateText)
        self.direction.activated[str].connect(self.updateText)
        self.number.textChanged[str].connect(self.updateText)
        self.group.activated[str].connect(self.updateText)
        self.period.activated[str].connect(self.updateText)
        self.block.activated[str].connect(self.updateText)
        self.menuButton.clicked.connect(self.menuWrapper)
        self.exitButton.clicked.connect(self.closeWrapper)

        self.grid = QGridLayout()
        self.grid.addWidget(self.sortByLabel, 0, 0)
        self.grid.addWidget(self.sortBy, 1, 0)
        self.grid.addWidget(self.directionLabel, 0, 1)
        self.grid.addWidget(self.direction, 1, 1)
        self.grid.addWidget(self.numberLabel, 0, 2)
        self.grid.addWidget(self.number, 1, 2)
        self.grid.addWidget(self.filterLabel, 2, 0)
        self.grid.addWidget(self.groupLabel, 3, 0)
        self.grid.addWidget(self.group, 4, 0)
        self.grid.addWidget(self.periodLabel, 3, 1)
        self.grid.addWidget(self.period, 4, 1)
        self.grid.addWidget(self.blockLabel, 3, 2)
        self.grid.addWidget(self.block, 4, 2)

        self.layout = QVBoxLayout()
        self.layout.addWidget(self.sortLabel)
        self.layout.addLayout(self.grid)
        self.layout.addWidget(self.info)
        self.layout.addWidget(self.menuButton)
        self.layout.addWidget(self.exitButton)
        self.setLayout(self.layout)

    def updateText(self):
        output = sortElements(self, self.window.elements)
        self.info.setText(output)

    def menuWrapper(self):
        menu(self.window)

    def closeWrapper(self):
        close(self)

#Non-interactive help and information tab, not particularly interesting
class HelpInfo(QWidget):
    def __init__(self, master):
        super().__init__()
        self.window = master
        text = "Periodic Program v" + version + " by Sasha Duke\nDate of this release: " + date + "\n\n\n"
        self.textBox = QTextEdit()

        try: #Checks if the help and license files can be found and displays them
            with open("help.txt", "r") as helpFile:
                text += helpFile.read()
            with open("LICENSE.txt", "r") as licenseFile:
                text += licenseFile.read()
        except:
            text += "help.txt and/or LICENSE.txt file(s) not found, they should have been included with the program"

        self.textBox.setText(text)
        self.textBox.setReadOnly(True)
        self.menuButton = QPushButton("Return to menu")
        self.exitButton = QPushButton("Quit")
        self.menuButton.clicked.connect(self.menuWrapper)
        self.exitButton.clicked.connect(self.closeWrapper)

        self.layout = QVBoxLayout()
        self.layout.addWidget(self.textBox)
        self.layout.addWidget(self.menuButton)
        self.layout.addWidget(self.exitButton)
        self.setLayout(self.layout)

    def menuWrapper(self):
        menu(self.window)

    def closeWrapper(self):
        close(self)

#This final bit of code runs the application, I have used this if statement to make sure it does not run unless it is the main application
if __name__ == "__main__":
    app = QApplication(sys.argv) #The PyQt application
    instance = Window()
    sys.exit(app.exec_()) #Should return PyQt error codes on exit

This snippet took 0.06 seconds to highlight.

Back to the Entry List or Home.

Delete this entry (admin only).