Chemistry, read from a balance's source

A two-pan balance stands on a laboratory bench between a flask heated over a small flame and a figure by a window, with shelves of coloured bottles behind.
An eighteenth-century laboratory bench with a balance, in the manner of Lavoisier's weighings.

In 1789 Antoine Lavoisier published his Traité élémentaire de chimie, and with it a clear statement of the law of conservation of mass: in a reaction nothing is lost and nothing is made, so what goes in weighs what comes out. Mikhail Lomonosov had said as much in 1756 and Jean Rey in 1630, but Lavoisier made it the method of chemistry, done with a balance. In 1808 John Dalton's A New System of Chemical Philosophy gave each element atoms of one weight, and set out to weigh them against hydrogen. To get from the weight of a compound to the weights of its atoms he needed its formula, and where he did not know it he took the simplest, one atom of each, a rule he called "merely an assumption, derived from faith in the simplicity of nature". His water was OH and his ammonia NH. For half a century chemists who agreed about the measurements disagreed about the formulas, and so about the weights; a common system gave carbon 6 and oxygen 8 against hydrogen's 1. At the congress of chemists at Karlsruhe in 1860, reprints of a paper Stanislao Cannizzaro had written in 1858 were handed out, insisting on the distinction Amedeo Avogadro had drawn between the weight of an atom and the weight of a molecule. Lothar Meyer read it and wrote: "I was astonished at its clarity, the little manuscript covered all the important points in dispute." Opinion swung toward weights of about 12 for carbon and 16 for oxygen, and the first periodic table to become generally accepted, Dmitri Mendeleev's, followed in 1869.

The chemistry library for DKE, the Deterministic Knowledge Engine, keeps chemistry's books in DKE Python, and this essay teaches the chemistry by reading it. You record elements as facts, and molecules as a chemist draws them, heavy atoms and the bonds between them, with the hydrogens left off. The library's rules give each atom the hydrogens its valence leaves over, conclude a molecule's formula, weight and functional groups, mark an atom drawn with too many bonds, and add up a reaction's enthalpy by Hess's law. Each answer names the facts it rests on, and correcting a fact withdraws the answers it supported. The second half of the library calculates: formulas, balanced equations, the pH of an acid and the course of a reaction in time. Every program below runs as shown once the library's source file has been compiled into a store, and every output is copied from a run.

1. An element as facts

An element is recorded by its symbol, with three facts: its atomic number, its usual valence in an organic molecule, which is the number of bonds it forms there, and its standard atomic weight.

class Element:
    z: int
    valence: int
    weight: real

The weights are those of IUPAC's Commission on Isotopic Abundances and Atomic Weights, and they are not quite constants. An element's atomic weight is an average over its isotopes, and since 2009 the Commission has given fourteen elements an interval rather than a single number, because the proportions of their isotopes vary from sample to sample: hydrogen's is 1.00784 to 1.00811, and carbon's 12.0096 to 12.0116. The library writes the conventional value the Commission gives beside each interval, 1.008 for hydrogen and 12.011 for carbon. elements() writes thirteen elements, and molar_mass() reads their weights to weigh a formula:

import chemistry

chemistry.elements()
print(chemistry.molar_mass("H2O"), chemistry.molar_mass("C6H12O6"), chemistry.molar_mass("NaCl"))
18.015 180.156 58.44

Those are grams per mole of water, glucose and salt. Since the revision of the SI of 2019 the mole has been exactly 6.02214076 × 10²³ entities, and the molar mass constant, which used to be exactly one gram per mole, is now close to it but measured.

2. A molecule as it is drawn

That a carbon atom forms four bonds was not obvious. Edward Frankland described an element's "combining power" in 1852; August Kekulé announced in 1857 that carbon's was four, and in May 1858 that carbon atoms link to one another, which is what makes chains and rings possible. Archibald Scott Couper reached the same idea independently, in a paper of June 1858 that gave the first formulas in which lines stand for bonds.

A chemist today draws a molecule as its heavy atoms and the bonds between them and leaves the hydrogens off, because each atom's valence says how many there are: the bonds a drawing does not show are bonds to hydrogen. The library does the same. An atom's hydrogens are its valence less its bonds, and its mass is its own weight and theirs:

class Atom:
    hydrogens: int = valence - bonds
    mass: real = weight + hydrogens * hweight

An atom's bonds is the sum of its bonds' orders, so a double bond counts twice. Acetic acid, the acid of vinegar, is two carbons, an oxygen double-bonded to the second carbon, and an OH:

import chemistry

chemistry.atom("c1", "C", "acid")
chemistry.atom("c2", "C", "acid")
chemistry.atom("o1", "O", "acid")
chemistry.atom("o2", "O", "acid")
chemistry.bond("c1", "c2", 1)
chemistry.bond("c2", "o1", 2)
chemistry.bond("c2", "o2", 1)
print(chemistry.formula("acid"), chemistry.weight("acid"))
print(chemistry.groups("acid"))
C2H4O2 60.052
['carboxylic acid']

The formula is written in the order Edwin Hill of the United States Patent Office proposed in 1900, for indexing the chemical literature: carbon first, hydrogen next, then the other elements alphabetically, and every element alphabetically when there is no carbon.

3. Functional groups

A functional group is a small arrangement of atoms that behaves much the same in whatever molecule carries it, and naming a molecule's groups goes most of the way to saying what it will do. The library concludes each group on the atom that carries it. A carbonyl carbon, one double-bonded to an oxygen, is an ester's when its other oxygen carries no hydrogen and leads on to another carbon:

@rule
def ester():
    for c in Atom, o in Atom, y in Bond, z in Bond:
        if c.carbonyl == True and y.a == c and y.order == 1 and y.b == o and o.element == "O" and o.hydrogens == 0 and z.a == o and z.belement == "C" and z.b != y.a:
            c.group = "ester"

Here are five small molecules, and a sixth, sodium acetate, the salt of acetic acid:

import chemistry

chemistry.atom("e1", "C", "ethanol")
chemistry.atom("e2", "C", "ethanol")
chemistry.atom("e3", "O", "ethanol")
chemistry.bond("e1", "e2", 1)
chemistry.bond("e2", "e3", 1)
chemistry.atom("k1", "C", "acetone")
chemistry.atom("k2", "C", "acetone")
chemistry.atom("k3", "C", "acetone")
chemistry.atom("k4", "O", "acetone")
chemistry.bond("k1", "k2", 1)
chemistry.bond("k2", "k3", 1)
chemistry.bond("k2", "k4", 2)
chemistry.atom("m1", "C", "methyl acetate")
chemistry.atom("m2", "C", "methyl acetate")
chemistry.atom("m3", "O", "methyl acetate")
chemistry.atom("m4", "O", "methyl acetate")
chemistry.atom("m5", "C", "methyl acetate")
chemistry.bond("m1", "m2", 1)
chemistry.bond("m2", "m3", 2)
chemistry.bond("m2", "m4", 1)
chemistry.bond("m4", "m5", 1)
chemistry.atom("a1", "C", "acetamide")
chemistry.atom("a2", "C", "acetamide")
chemistry.atom("a3", "O", "acetamide")
chemistry.atom("a4", "N", "acetamide")
chemistry.bond("a1", "a2", 1)
chemistry.bond("a2", "a3", 2)
chemistry.bond("a2", "a4", 1)
chemistry.atom("n1", "C", "methylamine")
chemistry.atom("n2", "N", "methylamine")
chemistry.bond("n1", "n2", 1)
chemistry.atom("s1", "C", "sodium acetate")
chemistry.atom("s2", "C", "sodium acetate")
chemistry.atom("s3", "O", "sodium acetate")
chemistry.atom("s4", "O", "sodium acetate")
chemistry.atom("s5", "Na", "sodium acetate")
chemistry.bond("s1", "s2", 1)
chemistry.bond("s2", "s3", 2)
chemistry.bond("s2", "s4", 1)
chemistry.bond("s4", "s5", 1)
for m in ["ethanol", "acetone", "methyl acetate", "acetamide", "methylamine", "sodium acetate"]:
    print(m, chemistry.formula(m), chemistry.weight(m), chemistry.groups(m))
ethanol C2H6O 46.069 ['alcohol']
acetone C3H6O 58.08 ['ketone']
methyl acetate C3H6O2 74.07900000000001 ['ester']
acetamide C2H5NO 59.068 ['amide']
methylamine CH5N 31.058 ['amine']
sodium acetate C2H3NaO2 82.03399999999999 []

The ester rule's last two conditions are what tell methyl acetate from sodium acetate. In both, the carbonyl carbon's single-bonded oxygen carries no hydrogen; in the salt its other bond is to sodium, and the carbon is no ester's. Sodium has no valence in an organic molecule, so the library gives it no hydrogens and lets it weigh what its bonds say, which is how the salt's formula comes out right.

4. A drawing with a mistake

An atom drawn with more bonds than its valence allows has a negative number of hydrogens, and the library marks it. Here is acetic acid drawn wrong, with both oxygens double-bonded to the same carbon:

import chemistry

chemistry.atom("x1", "C", "drawing")
chemistry.atom("x2", "C", "drawing")
chemistry.atom("x3", "O", "drawing")
chemistry.atom("x4", "O", "drawing")
chemistry.bond("x1", "x2", 1)
chemistry.bond("x2", "x3", 2)
chemistry.bond("x2", "x4", 2)
print(chemistry.faults())
print(chemistry.formula("drawing"), chemistry.groups("drawing"))
['overbonded: x2']
C2H2O2 []

The formula shows the mistake in its arithmetic: the carbon's hydrogens are four less five, minus one, and the molecule's total is two short. A correction is the same source recording a fact again. Draw the second bond to oxygen as single, and the mark is withdrawn, the hydrogen restored, and the group concluded. why follows a conclusion back to the facts it rests on; these are some of the lines under the group:

import chemistry

chemistry.bond("x2", "x4", 1)
print(chemistry.faults(), chemistry.formula("drawing"), chemistry.groups("drawing"))
w = why(chemistry.Atom.x2.group)
for c in w:
    print(str(c.path) + " = " + str(c.value))
[] C2H4O2 ['carboxylic acid']
chemistry.Atom.x2.group = carboxylic acid
chemistry.Atom.x2.carbonyl = True
chemistry.Bond.x2-x3.order = 2
chemistry.Bond.x2-x4.order = 1
chemistry.Bond.x2-x4.bhydrogens = 1
chemistry.Atom.x4.hydrogens = 1
chemistry.Element.O.valence = 2

5. Hess's law

In 1840 Germain Hess published the law that bears his name: the heat a reaction gives out is the same whatever path it takes from its reactants to its products. Enthalpy changes add. So a reaction's enthalpy can be read from tables, as the sum over its products of each one's standard enthalpy of formation, the heat of making it from its elements, less the same sum over its reactants. An element in its standard state, such as the oxygen of the air, has an enthalpy of formation of zero by definition. The rule is a sum:

@rule
def reaction_enthalpy():
    # The enthalpy of a reaction is the sum, over its species, of each one's
    # coefficient times its enthalpy of formation, whatever path the reaction
    # takes: Hess's law.
    for r in Reaction, t in Term:
        if t.reaction == r and absent(r.disputed):
            r.enthalpy = sum(t.contribution)

Its last premise holds the sum back while a term is bound to the reaction twice, which section 6 comes to.

A term's coefficient is positive for a product and negative for a reactant, and a term whose species has no enthalpy of formation recorded marks its reaction incomplete. Here is propane burning in oxygen to carbon dioxide and liquid water, C₃H₈ + 5 O₂ → 3 CO₂ + 4 H₂O, with the values at 298.15 K, before oxygen's zero is written down:

import chemistry

chemistry.species("propane_g", -104.6)
chemistry.species("co2_g", -393.509)
chemistry.species("water_l", -285.8)
chemistry.term("burn", "propane_g", -1)
chemistry.term("burn", "oxygen_g", -5)
chemistry.term("burn", "co2_g", 3)
chemistry.term("burn", "water_l", 4)
print(chemistry.faults())
['incomplete: burn']

Record it, and the reaction is complete. A second reaction leaves the water as steam:

import chemistry

chemistry.species("oxygen_g", 0.0)
chemistry.species("water_g", -241.818)
chemistry.term("burn_steam", "propane_g", -1)
chemistry.term("burn_steam", "oxygen_g", -5)
chemistry.term("burn_steam", "co2_g", 3)
chemistry.term("burn_steam", "water_g", 4)
print(chemistry.faults())
print(chemistry.enthalpy("burn"), chemistry.enthalpy("burn_steam"))
[]
-2219.127 -2043.199

NIST gives the heat of combustion of propane, as Pittam and Pilcher measured it in 1972, as −2219.2 ± 0.46 kJ/mol, and the sum of the tabulated values is within a tenth of a kilojoule of it. The difference between the two reactions is the heat four moles of steam give up as they condense, and fuel tables give both figures, as a fuel's higher and lower heating value.

6. When two sources disagree

Dalton's water can be recorded as Dalton recorded it. A lone oxygen, bonded to nothing, carries its whole valence in hydrogens, which is how the library reads water, and recording Dalton's valence of one for oxygen beside the Commission's two puts the two readings side by side:

import chemistry

chemistry.atom("w1", "O", "water")
print(chemistry.formula("water"), chemistry.weight("water"))
update(chemistry.Element("O").valence, 1, "Dalton 1808")
print(chemistry.unsettled(), chemistry.disputed_molecules())
try:
    print(chemistry.formula("water"))
except refuse as r:
    print(r.reason)
H2O 18.015
['element O'] ['acetamide', 'acetone', 'acid', 'drawing', 'ethanol', 'methyl acetate', 'sodium acetate', 'water']
molecule water is disputed: an atom of it, or a bond of one, is bound to it twice; forget the claim that is not to stand

A careful record keeps both values rather than choosing, and the library goes on reasoning with both: the oxygen carries two hydrogens by one valence and one by the other. A formula or a weight is a sum, and a sum would add both, a water of three hydrogens weighing 35.022 that neither Dalton nor the Commission describes. So while an atom carries two counts of hydrogens into its molecule, the molecule is disputed and has no formula or weight, and so is every other molecule in the store with an oxygen in it, as disputed_molecules() lists. formula() and weight() refuse such a molecule and say what to do. A query that refused while anything at all was in dispute would leave its reader with nothing, and one that quietly picked a winner would hide the dispute; these refuse only what the dispute reaches, and unsettled() names the fact to settle. Settling it here is removing Dalton's claim, as the chemists after Karlsruhe did:

import chemistry

forget(chemistry.Element("O").valence, 1, "Dalton 1808")
print(chemistry.unsettled(), chemistry.disputed_molecules(), chemistry.formula("water"), chemistry.weight("water"))
[] [] H2O 18.015

7. Balancing an equation

Jeremias Benjamin Richter named the measurement of chemical proportions stoichiometry in 1792. Balancing an equation is Lavoisier's bookkeeping in arithmetic: every element must appear on both sides the same number of times, and so must the charge. Each element gives a linear equation in the unknown coefficients, and a balanced equation is a whole-number solution of all of them at once. It is unique, up to scale, when the equations leave exactly one degree of freedom. balance() reads each formula with parse(), brackets and charges included, and solves the equations by fraction-free elimination of the kind Bareiss published in 1968, which keeps every number whole by dividing, at each step, by the pivot of the step before; the division always comes out exact.

import chemistry

print(chemistry.parse("Ca(OH)2"), chemistry.charge("MnO4^-"))
print(chemistry.balance(["C3H8", "O2"], ["CO2", "H2O"]))
print(chemistry.balance(["MnO4^-", "Fe^2+", "H^+"], ["Mn^2+", "Fe^3+", "H2O"]))
print(chemistry.balance(["H2", "O2"], ["H2O", "H2O2"]))
{'Ca': 1, 'O': 2, 'H': 2} -1
[1, 5, 3, 4]
[1, 5, 8, 1, 5, 4]
[]

The second is permanganate oxidising iron, and its charges balance too: −1 + 10 + 8 on the left, 2 + 15 on the right. The third has no single answer. Hydrogen and oxygen can make water and hydrogen peroxide in any proportion, so there are two degrees of freedom, and balance() returns an empty list rather than choose one.

8. Acids

In his doctoral dissertation of 1884 Svante Arrhenius proposed that a salt in solution comes apart into ions, and he took an acid to be a substance that gives hydrogen ions in water; in 1903 he became the first Swede to receive the Nobel Prize in Chemistry. In 1923 Johannes Brønsted in Denmark and Martin Lowry in England, independently, took an acid to be anything that gives up a proton. Søren Sørensen introduced pH at the Carlsberg Laboratory in 1909. In 1908 Lawrence Henderson derived an equation for the hydrogen-ion concentration of a bicarbonate buffer, which Karl Hasselbalch put in logarithmic form in 1916, and it says that where half of a weak acid has been neutralised, the pH equals the acid's pKa.

charge_ph() finds the pH at which a solution's charges balance: hydrogen ions and a strong base's cations on one side, hydroxide and the acid's anions on the other. It bisects on the pH between −2 and 16 until the two sides agree:

f = h + base - kw / h - acid * given / denom
if f > 0.0:
    lo = mid
else:
    hi = mid

Acetic acid's pKa is 4.76. Here is the pH of a tenth-molar solution, and then a titration: 25 mL of it, with a tenth-molar strong base added a few millilitres at a time:

import chemistry

print(chemistry.ph(0.1, [4.76]))
for vb in [0.0, 5.0, 12.5, 20.0, 24.0, 25.0, 26.0, 30.0]:
    print(vb, chemistry.titration(0.1, 25.0, 0.1, vb, [4.76]))
2.882862536135045
0.0 2.882862536135045
5.0 4.16018896726322
12.5 4.760452344012249
20.0 5.36227207049626
24.0 6.140368655731267
25.0 8.729537177572983
26.0 11.2924330113531
30.0 11.958607452344964

At 12.5 mL, half the acid neutralised, the pH is the pKa, as Henderson and Hasselbalch say. At 25 mL all of it is, and the solution is not neutral but basic, at 8.73, because acetate takes protons back from water. Between 24 and 26 mL the pH climbs by more than five units, and that jump is what an indicator, or a pH meter, finds. Phosphoric acid gives up three protons, with pKa values of 2.14, 7.20 and 12.37:

import chemistry

for vb in [12.5, 25.0, 37.5, 50.0, 62.5, 75.0]:
    print(vb, chemistry.titration(0.1, 25.0, 0.1, vb, [2.14, 7.2, 12.37]))
12.5 2.2783997785632684
25.0 4.699543161682456
37.5 7.1999983962916545
50.0 9.668876366051848
62.5 11.870303016453015
75.0 12.181154439060382

The first equivalence point is at 25 mL and the second at 50, each a jump of about five units. Halfway between them, at 37.5 mL, the pH is the second pKa to five figures. Halfway to the first it is not: phosphoric acid's first proton is a strong enough acid that much of it has gone before any base is added, which the shortcut of Henderson and Hasselbalch leaves out and the charge balance does not. Past 50 mL the curve has no third step. At 75 mL, where the arithmetic says the last proton is neutralised, the pH has reached 12.18, because the third proton is too weak an acid to be titrated in water.

9. A reaction in time

Ludwig Wilhelmy was the first to measure the rate of a reaction, in a paper of 1850 on the action of acids on cane sugar, which was overlooked until Wilhelm Ostwald drew attention to it in 1884. In 1864 Guldberg and Waage stated the law of mass action, that the driving force of a reaction is proportional to the active masses of what reacts. Jacobus van 't Hoff published Études de dynamique chimique in 1884 and received the first Nobel Prize in Chemistry, in 1901, and in 1889 Arrhenius gave the equation for how a rate depends on temperature.

A reaction through an intermediate, A to B to C, is the simplest chain, and the same equations describe a chain of radioactive decays. The intermediate rises, peaks and falls. consecutive() integrates the two rate equations by the fourth-order Runge–Kutta method, consecutive_exact() is their solution in closed form, and peak_time() is when B is at its most, ln(k₁/k₂)/(k₁ − k₂). equilibrium() integrates A + B ⇌ C to its equilibrium, which equilibrium_exact() finds from the equilibrium constant alone:

import chemistry

t = chemistry.peak_time(1.0, 0.25)
print(t)
print(chemistry.consecutive(1.0, 0.25, t, t / 200.0))
print(chemistry.consecutive_exact(1.0, 0.25, t))
print(chemistry.equilibrium(2.0, 1.0, 1.0, 1.0, 20.0, 0.01), chemistry.equilibrium_exact(2.0, 1.0, 1.0))
1.8483924814931874
[0.15749013125469388, 0.6299605249237493, 0.21254934382155677]
[0.15749013123685918, 0.6299605249474365, 0.21254934381570434]
0.49999999999999906 0.5

The integration agrees with the closed form to about ten figures. At its peak, 1.85 time units in, B holds 63 per cent of what began as A. The reversible reaction, with a forward rate constant twice the reverse and one mole per litre of each reactant, settles at half a mole per litre of C, where C / (A B) = 0.5 / (0.5 × 0.5) = 2, the equilibrium constant.

10. What the library leaves out

A valence is one number for each element, which serves carbon, nitrogen, oxygen and the halogens and fails the elements that form different numbers of bonds in different compounds: sulfur in sulfuric acid forms six, and drawn with them it is marked overbonded. Charges are not drawn on atoms, so an ammonium ion or a nitro group cannot be written as it is. The library knows no geometry, so it cannot tell a molecule from its mirror image. Its pH is calculated from concentrations rather than activities, with water's ion product fixed at 1.0 × 10⁻¹⁴, its value near 25 °C, which serves dilute solutions at room temperature. And its calculations are the textbook cases, each chosen because its answer is known some other way.

Closing

Lavoisier's balance said that what goes into a reaction weighs what comes out. The library keeps the same books. Every atom carries the hydrogens its valence leaves, every weight is the sum of what a molecule is made of, every balanced equation has the same atoms and the same charge on both sides, and a reaction's heat is the sum of its parts. Because each conclusion names what it rests on, a reader can ask why a molecule weighs what it does and follow the answer down to the table its weight came from. And when two tables disagree, as Dalton's and Cannizzaro's did, the library says so rather than choosing.

Free to read

Paul Flowers, Klaus Theopold, Richard Langley and William R. Robinson, Chemistry 2e (OpenStax, Rice University, Houston, 2019). Section 2.1 traces atomic theory from Lavoisier's conservation of mass, and Chapter 4 the stoichiometry of reactions. Read it free at OpenStax.

Read on