Presentation
We intend to model chemical reactions.
For example, in the presence of methane and oxygen molecules, a combustion reaction will produce carbon dioxide and water:
CH4 + O2 + O2 → CO2 + H2O + H2O
Here is a reminder of few basic notions:
- An atom is made up of nucleons (protons and neutrons) and electrons. For this exercise, we'll ignore electrons.
The mass number of an atom is its number of nucleons. - Atoms with the same number of protons are classified by a chemical element: hydrogen atoms have one proton,
oxygen atoms have eight. The number of protons is called the atomic number. - Atoms characterized by the same chemical element may differ in the number of neutrons they contain. They are isotopes.
For example, there are 3 isotopes of hydrogen: protium has no neutrons, deuterium has one neutron
and tritium has 2 neutrons. - A molecule is a set of atoms (we won't take isomers into account).
Chemical Component
We'll call chemical component the entities that we intend to represent, i.e. atoms and molecules. A chemical component has a name and a mass (for an atom: the mass number, for a molecule: the sum of the mass numbers of its constituent atoms).
- Define the
ChemicalComponent
class; as this class is abstract, it will be a subclass of theABC
class of theabc
module. - Define the constructor of this class; it will receive the name as an argument. This name will not be modifiable after initialization, so it will be stored in a private attribute (starting with
__
), and accessible as a property (decorator@property
on the accessor). - Define an abstract method (decorator
@abstractmethod
)mass()
. - Try to create an instance of
ChemicalComponent
, you should get an error.
ANSWER ELEMENTS
from abc import ABC, abstractmethod class ChemicalComponent(ABC): def __init__(self, name): self.__name = name @property def name(self): return self.__name @abstractmethod def mass(self): pass #chemical_component = ChemicalComponent("Test ChemicalComponent")
Chemical Element
A chemical element is a chemical component made of a single atom whose number of protons is known.
We could store this number in a non-modifiable attribute using the same approach as for the name. However, to make our code easier to use, we're going to directly propose the creation of a hydrogen atom, rather than the creation of an atom with a single proton. In other words, we'll have a Hydrogen
class (the same goes for the other chemical elements). The number of protons will be a characteristic of this class, not of any particular instance of it.
In other words, all hydrogen atoms have one proton.
Unlike a class method, a class attribute cannot be redefined in a subclass, so this is the solution we'll use.
A chemical element also defines a symbol (H
for hydrogen, O
for oxygen...), so we'll use the same approach for this attribute.
- Define the
ChemicalElement
class; this class will also be abstract, a subclass ofChemicalComponent
. - Define the constructor of this class, it will receive the name as an argument.
- Define the class methods (decorator
@classmethod
)atomic_number()
andsymbol()
. - Check that it is still not possible to create an instance of this class.
ANSWER ELEMENTS
class ChemicalElement(ChemicalComponent): def __init__(self, name): super().__init__(name) @classmethod def symbol(cls): pass @classmethod def atomic_number(cls): pass #chemical_element = ChemicalElement("Test ChemicalElement")
Atom
An atom is a chemical element whose number of neutrons is known. This number cannot be modified after initialization, and is stored in a private attribute.
- Define the
Atom
class; this class is a subclass of theChemicalElement
class. - Define the constructor of this class; it takes the name and number of neutrons as arguments.
- Define the
neutron_number()
method and redefine themass()
method with the correct semantics. - Check that it is now possible to create an instance of this class (example of instance: the deuterium), display its name and neutron number.
- However, this class is not intended to be instantiated directly, as its number of protons is not known yet: check this by trying to display the mass of the atom you've created.
ANSWER ELEMENTS
class Atom(ChemicalElement): def __init__(self, name, neutron_number): super().__init__(name) self.__neutron_number = neutron_number def neutron_number(self): return self.__neutron_number def mass(self): return self.atomic_number() + self.neutron_number() deuterium = Atom("Deuterium", 1) print(deuterium.name) print(deuterium.neutron_number()) #print(deuterium.mass())
Hydrogen
Hydrogen is the chemical element with atomic number 1 and symbol H.
There are three isotopes of hydrogen: protium has no neutrons (99.99% of the hydrogen present on Earth), deuterium has one (0.01%) and tritium has two (does not exist naturally, unstable).
- Define the
Hydrogen
class; this class is a subclass of theAtom
. - Define the constructor of this class, that takes as arguments the name (which by default will be Hydrogen) and the number of neutrons (0 by default). If the latter is invalid, a
TypeError
exception will be thrown. - Redefine the
atomic_number()
andsymbol()
methods with the correct semantics. - Create a deuterium atom and display its mass.
- Display the created deuterium atom. The result looks as follows
<__main__.Hydrogen object at 0x1043e6390>
It is not satisfactory.
We're not going to try to display it in the official form (2H
), we'll just write it [1/2]H
: the first number is the atomic number, the second is the mass number, and the letter is the symbol.
When Python wants to convert data into a character string to display it, it uses the __str__()
method, which has a default definition in the object
class, the root of the inheritance tree. We will now redefine this method to display the desired string.
- In which class is it relevant to redefine this method? Add this redefinition.
ANSWER ELEMENTS
# Method added in class Atom def __str__(self): return f"[{self.atomic_number()}/{self.mass()}]{self.symbol()}" class Hydrogen(Atom): def __init__(self, name = 'Hydrogen', neutron_number = 0): if not 0 <= neutron_number <= 2: raise TypeError("Invalid neutron number in Hydrogen") super().__init__(name, neutron_number) @classmethod def atomic_number(cls): return 1 @classmethod def symbol(cls): return 'H' deuterium = Hydrogen("Deuterium", 1) print(deuterium.mass())
Other atoms
For the chemical reaction we wish to model, we need the atoms of carbon (symbol C, 6 protons, 6 to 8 neutrons, the most common isotope is 12C) and oxygen (symbol O, 8 protons, 6 to 20 neutrons, the most common isotope is 160).
- Define the
Carbon
andOxygen
classes.
ANSWER ELEMENTS
class Carbon(Atom): def __init__(self, name = 'Carbon', neutron_number = 6): if not 6 <= neutron_number <= 8: raise TypeError("Invalid neutron number in Carbon") super().__init__(name, neutron_number) @classmethod def atomic_number(cls): return 6 @classmethod def symbol(cls): return 'C' class Oxygen(Atom): def __init__(self, name = 'Oxygen', neutron_number = 8): if not 6 <= neutron_number <= 20: raise TypeError("Invalid neutron number in Oxygen") super().__init__(name, neutron_number) @classmethod def atomic_number(cls): return 8 @classmethod def symbol(cls): return 'O'
Molecule
A molecule is a chemical component made of at least two atoms.
- Define the class
Molecule
, a subclass ofChemicalComponent
. - Define the constructor of this class. It takes a name and a list of atoms as arguments, stored in a private attribute. The constructor checks that the list contains only atoms (otherwise, it raises a
TypeError
); to do this, you can use the function
isinstance(one_instance, OneClass)
which tests whether one_instance
is an instance of the OneClass
class or one of its subclasses.
- Redefine the
mass()
method with correct semantics. - Create a water molecule, and display its mass.
- Display this molecule, the result will not be not satisfactory.
The aim is to display it as :
H2O
i.e. display each type of atom in the molecule, followed, if there are several of that type, by the number of occurrences. No attempt will be made to respect the order of atom types.
- Define a method in the
Molecule
class that returns a dictionary describing the composition of a molecule in terms of atom types. - Redefine the
__str__()
method of this class to obtain the desired display.
Python classes can be used as dictionary keys.The class of an object is obtained by the function type()
.
ANSWER ELEMENTS
class Molecule(ChemicalComponent): def __init__(self, name, atoms): for atom in atoms: if not isinstance(atom, Atom): raise TypeError("Not an Atom") super().__init__(name) self.__atoms = atoms def mass(self): return sum([atom.mass() for atom in self.__atoms]) def composition(self): d = {} for atom in self.__atoms: d[type(atom)] = d.get(type(atom), 0) + 1 return d def __str__(self): return "".join([f"{a.symbol()}{'' if n == 1 else n}" for a, n in self.composition().items()]) water = Molecule("Water", [Hydrogen(), Hydrogen(), Oxygen()]) print(water.mass()) print(water)
Water and other molecules
We need the following kinds of molecules: methane, dioxygen, carbon dioxide, water.
- Define the classes
Methane
,Dioxygen
,CarbonDioxide
andWater
, subclasses ofMolecule
. - Define the constructors of these classes, they take as arguments a name, with the class name as default value, and the list of atoms; the default value of the list will be a list of the atoms composing these molecules. The constructor checks that the list contains only the correct atoms, and will throw a
TypeError
exception if this is not the case.
ANSWER ELEMENTS
class Methane(Molecule): def __init__(self, name = "Methane", atoms = [Carbon(), Hydrogen(), Hydrogen(), Hydrogen(), Hydrogen()]): super().__init__(name, atoms) d = self.composition() if len(d) != 2: raise TypeError("Bad atoms in methane") if not Hydrogen in d.keys() or d[Hydrogen] != 4: raise TypeError("Methane needs 4 atoms of Hydrogen") if not Carbon in d.keys() or d[Carbon] != 1: raise TypeError("Methane needs 1 atom of Carbon") class Dioxygen(Molecule): def __init__(self, name = "Dioxygen", atoms = [Oxygen(), Oxygen()]): super().__init__(name, atoms) d = self.composition() if len(d) != 1: raise TypeError("Bad atoms in dioxigen") if not Oxygen in d.keys() or d[Oxygen] != 2: raise TypeError("Dioxygen needs 2 atoms of Oxygen") class CarbonDioxide(Molecule): def __init__(self, name = "CarbonDioxide", atoms = [Carbon(), Oxygen(), Oxygen()]): super().__init__(name, atoms) d = self.composition() if len(d) != 2: raise TypeError("Bad atoms in carbon dioxide") if not Oxygen in d.keys() or d[Oxygen] != 2: raise TypeError("Carbon dioxide needs 2 atoms of Oxygen") if not Carbon in d.keys() or d[Carbon] != 1: raise TypeError("Carbon dioxide needs 1 atom of Carbon") class Water(Molecule): def __init__(self, name = "Water", atoms = [Hydrogen(), Hydrogen(), Oxygen()]): super().__init__(name, atoms) d = self.composition() if len(d) != 2: raise TypeError("Bad atoms in water") if not Hydrogen in d.keys() or d[Hydrogen] != 2: raise TypeError("Water needs 2 atoms of Hydrogen") if not Oxygen in d.keys() or d[Oxygen] != 1: raise TypeError("Water needs 1 atom of Oxygen")
Chemical reaction
We'll model a chemical reaction as a ChemicalReaction
function.
- This function takes in a list of molecules.
- It searches this list to find molecules that can cause a chemical reaction (we'll limit ourselves to the chemical reaction of combustion mentioned at the beginning of this topic).
- If these molecules are found, it removes these molecules from the list, runs the reaction and returns the list of molecules produced by the reaction. Otherwise, it returns
None
. The returned molecules are created from the atoms of the molecules at the origin of the chemical reaction.
We know how to create the molecules produced by the chemical reaction, thanks to the constructors, but we currently have no way of recovering the atoms of the molecules at the origin of the chemical reaction.
- Add to the
Molecule
class adestroy()
method that removes the atoms from a molecule and returns a list containing the removed atoms.
ANSWER ELEMENTS
# Method added in class Molecule def destroy(self): atoms = self.__atoms self.__atoms = [] return atoms def ChemicalReaction(molecules): def count_molecules(kind): return sum([1 for m in molecules if isinstance(m, kind)]) def remove_molecule(kind): for i in range(0, len(molecules)): if isinstance(molecules[i], kind): return molecules.pop(i) return None def remove_atom(atoms, kind): for i in range(0, len(atoms)): if isinstance(atoms[i], kind): return atoms.pop(i) return None dioxygen_count = count_molecules(Dioxygen) methane_count = count_molecules(Methane) if methane_count >= 1 and dioxygen_count >= 2: needed_molecules = [remove_molecule(Methane), remove_molecule(Dioxygen), remove_molecule(Dioxygen)] all_atoms = sum([m.destroy() for m in needed_molecules], []) return [ Water ("w1", [remove_atom(all_atoms, Hydrogen), remove_atom(all_atoms, Hydrogen), remove_atom(all_atoms, Oxygen)]), Water ("w2", [remove_atom(all_atoms, Hydrogen), remove_atom(all_atoms, Hydrogen), remove_atom(all_atoms, Oxygen)]), CarbonDioxide("c", [remove_atom(all_atoms, Carbon), remove_atom(all_atoms, Oxygen), remove_atom(all_atoms, Oxygen)]) ] return None