Demo entry 6360109

Periodic Program

   

Submitted by Sasha Duke on May 01, 2017 at 01:21
Language: Python 3. Code size: 50.7 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.4"
__date__ = "2017-4-30"
__license__ = "GNU GPLv3"

#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, QStyleFactory)
from PyQt5.QtCore import QTimer, Qt
from PyQt5.QtGui import QIcon, QFont, QFontDatabase


#Each element is saved as an object with each 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 capital letter or symbol
    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, then we move on to the next bracket
        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: #We don't count the first open bracket so when the counter drops below 0 the correct number of brackets have been closed
                pointer2 = bracketStart[pos] + 1 #Set a pointer to the item after the first bracket to multiply each item up to the final bracket

                #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: #From the start to the end bracket
                    if not search("[0-9]+", formulaList[pointer2]): #If there is no numerical component make it equal to 1
                        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): #Checks if there is a numerical component (stops errors)
                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 at separate points in the list, 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): #Skips to the next item in the list if it is just a symbol
            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 by the element with the greatest number of atoms to the least
    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 #Ar in chemistry means relative atomic mass
        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()
    #Sets defaults if not specified
    if not direction:
        direction = "Ascending"
    #A try statement is used here as casting something that is not a number to an int will cause an error
    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: #If no sorting type was chosen
        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
        #Extends are used instead of appends because it is a 2-dimensional list we want to add to the list of the last item, not add a new item to the list of lists
        if element.mp and element.bp:
            elementList[-1].extend([float(element.mp), float(element.bp)])
        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
    #Only keeps items in the list of the correct 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 without result number
            output += item[1] + " - " + str(item[index])
        elif index == 1: #Format for alphabetical sort results, no info is needed other than element name
            output += item[1]
        else: #Format for rest of sort results, showing the number of each result
            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, "Periodic Program", "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) #The original and minimum window size, it can be resized by dragging however
        self.setWindowTitle("Periodic Program")
        self.setWindowIcon(QIcon("Resources/appicon.ico"))

        #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 main menu widget and switching to it
        self.menuWidget = MainMenu(self)
        self.widgets.addWidget(self.menuWidget)
        self.widgets.setCurrentWidget(self.menuWidget)

    #Overriding the Windows close event (button in top right corner) to use my own in order to stop the download correctly if it is happening, and for confirmation dialogue
    #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() #We have to ignore the windows close event as it would trigger even if the cancel button was pressed our own quit confirmation dialogue
                       #In our own close function we use sys.exit which will override the windows event, so this one is not needed

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

        #Looks for a complete list of the saved element data, if it is not there then download the element data
        try:
            self.window.elements = load(open("elements.pkl", "rb"))
            if len(self.window.elements) != 118:
                QTimer.singleShot(0, self.fetchElements)
        except:
            QTimer.singleShot(0, self.fetchElements) #The reason these functions are triggered with timers is explain 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, to switch to another widget
        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, "Periodic Program", "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 avoid overwriting it and therefore 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 any outputs will stay as we are switching between widgets
                                                                #This means values can easily be copied and pasted across widgets

    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 fancy 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.numberOfElements = 118

        self.status1 = QLabel("Fetching element data from internet:")
        self.status2 = QLabel("Starting download")
        self.pbar = QProgressBar()
        self.pbar.setFormat("0/" + str(self.numberOfElements)) #This will show the progress of the download
        self.pbar.setMaximum(self.numberOfElements)

        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 there is not a complete elements list, the download should not be able to be cancelled as the program depends on all elements in the list
        if len(self.window.elements) == self.numberOfElements:
            self.layout.addWidget(self.cancelButton) #This button only shows when the user activates this widget manually
        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, self.numberOfElements): #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("Unable to finish download: could not connect to element " + str(e + 1) + " page")
                    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
                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()
                #Electron configuration is written with superscript HTML tags which need to be stripped from the string, as well as other tags
                ec = (self.get(table, 21, -3) + " "
                      + self.get(table, 21).replace("</sup><sup>","").replace("<sup>", "").replace("</sup>", " ")).replace('text_bold"> ', "")[:-1]

                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, e + 1, 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 the download is cancelled halfway through partially complete element data may overwrite the saved data if we do not prevent it
            if len(elements) == self.numberOfElements:
                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) #Updates the progress bar value and text
                self.pbar.setFormat(str(e + 1) + "/" + str(self.numberOfElements))
                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 structured in a similar 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, "Periodic Program", "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 formula or element name / 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 menu 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 text is changed
        self.dropDown.activated[str].connect(self.updateText) #This makes the program very quick and responsive and easy to use
        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 as the dropdown menu only contains elements
            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 and therefore a valid compound
                text += "Compound\nMolar Mass: " + mr + " g/mol\n\nComposition:"
                compList = multiplyElements(self.lookup.text())

                #Breaking down a compound into its constituents, see explanation in the multiplyElements function
                for item in compList:
                    e = lookupElement(item[0], self.window.elements)
                    text += "\n\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
        self.formulaLabel = QLabel("Molecular Formula:")
        self.formula = QLineEdit()
        self.mrLabel = QLabel("Molar Mass (g/mol):")     #Mr for relative formula mass
        self.mr = QLineEdit()
        self.mLabel = QLabel("Mass (g):")                #M for mass
        self.m = QLineEdit()
        self.nLabel = QLabel("Amount (mol):")            #N for moles
        self.n = QLineEdit()
        self.vLabel = QLabel("Volume (dm3):")            #V for volume
        self.v = QLineEdit()
        self.cLabel = QLabel("Concentration (mol/dm3):") #C for concentration
        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) #If a molar mass is entered by the user, the formula box is cleared as it will no longer be correct
        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.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, these correspond to the different formulae in chemistry
    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

        #The grid layout used below does not let me use the same labels twice so I have created duplicate labels for the reactant and product columns
        self.reactantLabel = QLabel("Reactant\n")
        self.reactant = QLineEdit()
        self.productLabel = QLabel("Product\n")
        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 row 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.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 boolean 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()
        #By default the stoichiometry is equal to 1 if the user has not entered any value
        if not rStoichiometry:
            rStoichiometry = 1
        if not pStoichiometry:
            pStoichiometry = 1

        #This will work out the product mass if the reactant mass is specified
        if rM:
            try:
                self.pM.setText(str(((float(pMr) * int(pStoichiometry)) / (float(rMr) * int(rStoichiometry))) * float(rM)))
            except:
                self.pM.setText("")
        #However the maths works backwards in the same way so the reactant mass can also be worked out from a known product mass
        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("Sorting type")
        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("Molar 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))      #Groups go from 1-18
            if num < 8:
                self.period.addItem(str(num)) #And periods from 1-7
        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")
        #When any choices are made on any of the drop down menus, the output is updated
        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): #For the moment only this widget uses the sort function but others could make use of it in the future
        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("Resources/help.txt", "r") as helpFile:
                text += helpFile.read()
            with open("Resources/LICENSE.txt", "r") as licenseFile:
                text += licenseFile.read()
        except: #These should always be present if the program has been downloaded set up correctly (there is next to no setup required anyway)
            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__": #For example if another application is using this code to import a specific function, the lines below will not run
    app = QApplication(sys.argv) #The PyQt application
    #These next couple of lines are optional but make the program look pretty (in my opinion), I have included a nice font for the UI in the resources
    app.setStyle(QStyleFactory.create("Fusion")) #Overrides the default system UI language
    QFontDatabase.addApplicationFont("Resources/NotoSansUI-Regular.ttf")
    app.setFont(QFont("Noto Sans UI", 10)) #This does not need to be already installed on the system, it is included in the resource folder
    instance = Window() #This one line runs the rest of the program :)
    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).