The molar mass of a mixture¶
The molar mass of a mixture from its composition by amount, the amount-weighted mean of its gases', M = sum x_i M_i with x_i = n_i / sum n: the mass of a mole of the mixture.
The kernel takes the game's whole table of parts (a list of any table with these fields): each part's mixture (the item it belongs to), its amount (a fraction, or any amounts: they are divided by their sum, so only the proportions count) and its gas, a relation to whatever table the game's column relates to, whose molar_mass it reads. It folds the parts of the item's own mixture (mixture == it); bound over the mixtures, mixtures.molar_mass = lusine.properties.molar_mass(parts);. Each sum is a float fold, added in the fixed tree. Source: Cengel & Boles, Thermodynamics: An Engineering Approach, ch. 13 (gas mixtures). Checked against a reference in numpy, which has its own tests.
- Source
- Cengel & Boles, Thermodynamics, ch. 13 (gas mixtures).
- Proof
- reference, limit, invariant.
molar_mass.trait
namespace lusine.properties;
float<kg/mol> molar_mass(list<struct { relation mixture; float fraction; relation gas; }> parts)
{
let total = sum(fraction) where mixture == it;
return (sum(fraction * gas.molar_mass) where mixture == it) / total;
}
A mixture's molar mass, its gases' weighted by amount, M = Σ x_i M_i. The kernel takes a game's whole table of parts, list<struct { relation mixture; float fraction; relation gas; }>: any table with those fields, its gas a relation to whatever table the game's column relates to. Bound over the mixtures, it folds each one's own parts.
import os, sys
root = os.getcwd()
while not os.path.isfile(os.path.join(root, 'docsite.json')):
root = os.path.dirname(root)
sys.path.insert(0, os.path.join(root, 'tools'))
sys.path.insert(0, os.path.join(root, 'kernels'))
import numpy as np
import matplotlib.pyplot as plt
from lusine import trait_run
from lusine import rows
from lusine.kernels.properties._gases import DRY_AIR, GASES
F32 = np.float32
U = float(np.finfo(F32).eps) / 2 # binary32's unit roundoff, 2^-24
The kernel¶
The law as the library states it: the signature names the fields it needs of a part, and nothing of the game.
print(trait_run.source('properties/molar_mass.trait'))
// lusine.properties.molar_mass: the molar mass of a mixture from its composition by amount, the amount-weighted mean
// of its gases', M = sum x_i M_i with x_i = n_i / sum n: the mass of a mole of the mixture. The kernel takes the
// game's whole table of parts (a list of any table with these fields): each part's mixture (the item
// it belongs to), its amount (a fraction, or any amounts: they are divided by their sum, so only the proportions
// count) and its gas, a relation to whatever table the game's column relates to, whose molar_mass it reads. It folds
// the parts of the item's own mixture (mixture == it); bound over the mixtures, `mixtures.molar_mass =
// lusine.properties.molar_mass(parts);`. Each sum is a float fold, added in the fixed tree.
// Source: Cengel & Boles, Thermodynamics: An Engineering Approach, ch. 13 (gas mixtures).
// Checked against a reference in numpy, which has its own tests.
namespace lusine.properties;
float<kg/mol> molar_mass(list<struct { relation mixture; float fraction; relation gas; }> parts)
{
let total = sum(fraction) where mixture == it;
return (sum(fraction * gas.molar_mass) where mixture == it) / total;
}
The program¶
A game's tables, written out: its gases (each a molar mass, the CRC Handbook's, as the reference's), its mixtures, and its parts, each part a mixture's amount of a gas. The mixtures are dry air (the US Standard Atmosphere's composition by amount), the same air in thousands of moles, nitrogen alone given as 1 and as 3 moles, and air with its water rising from none to 5% of the moles. A second table of parts, shares, relates to another table of gases, the library's own lusine.materials.chemical, and the same kernel binds it: each table's struct has its own instance. main() binds the kernel over each mixtures table; the planted faults below bind the page's own wrong kernel in its place.
WATER = [round(0.005 * i, 3) for i in range(11)]
MIXTURES = {'air': DRY_AIR, 'kiloair': {g: 1000 * x for g, x in DRY_AIR.items()}, 'nitrogen': {'N2': 1.0},
'nitrogen3': {'N2': 3.0}}
MIXTURES.update({f'humid{i}': dict(DRY_AIR, H2O=w / (1 - w)) for i, w in enumerate(WATER)})
SHARES = {'steam': {'water': 1.0}, 'damp': {'nitrogen': 0.97, 'water': 0.03}, 'stack': {'nitrogen': 0.7, 'carbon_dioxide': 0.2, 'water': 0.1}}
def program(kernel='lusine.properties.molar_mass', extra=''):
gases = ',\n'.join(f' {{ name = "{g}", molar_mass = {r["molar_mass"]!r} kg/mol }}' for g, r in GASES.items())
mixtures = ', '.join(f'{{ name = "{m}" }}' for m in MIXTURES)
parts = ',\n'.join(f' {{ mixture = mixtures["{m}"], fraction = {float(x)!r}, gas = gases["{g}"] }}'
for m, c in MIXTURES.items() for g, x in c.items())
blends = ', '.join(f'{{ name = "{m}" }}' for m in SHARES)
shares = ',\n'.join(f' {{ mixture = blends["{m}"], fraction = {float(x)!r}, gas = lusine.materials.chemical["{g}"] }}'
for m, c in SHARES.items() for g, x in c.items())
return f'''namespace mix;
lusine.version version = {{ {{ number = 1 }} }};
struct gas {{ text name; float<kg/mol> molar_mass; }};
table<gas> gases(capacity = 8, key = name) = {{
{gases},
}};
struct mixture {{ text name; float<kg/mol> molar_mass; }};
table<mixture> mixtures(capacity = 16, key = name) = {{ {mixtures} }};
struct part {{ relation<mixtures> mixture; float fraction; relation<gases> gas; }};
table<part> parts(capacity = 96) = {{
{parts},
}};
table<mixture> blends(capacity = 4, key = name) = {{ {blends} }};
struct share {{ relation<blends> mixture; float fraction; relation<lusine.materials.chemical> gas; }};
table<share> shares(capacity = 8) = {{
{shares},
}};
{extra}
void main()
{{
mixtures.molar_mass = {kernel}(parts);
blends.molar_mass = {kernel}(parts = shares);
}}
'''
LAW = program()
print(LAW)
def molar_masses(text=LAW):
"""{mixture: its molar mass} of both tables, and the run."""
run = trait_run.Program(['properties/molar_mass.trait'], text, {})
m, b = run.steps('mix.main', 1, [('mix.mixtures', 'molar_mass'), ('mix.blends', 'molar_mass')])
return {**dict(zip(MIXTURES, m[0])), **dict(zip(SHARES, b[0]))}, run
M, run = molar_masses()
namespace mix;
lusine.version version = { { number = 1 } };
struct gas { text name; float<kg/mol> molar_mass; };
table<gas> gases(capacity = 8, key = name) = {
{ name = "N2", molar_mass = 0.0280134 kg/mol },
{ name = "O2", molar_mass = 0.0319988 kg/mol },
{ name = "Ar", molar_mass = 0.039948 kg/mol },
{ name = "CO2", molar_mass = 0.0440095 kg/mol },
{ name = "H2O", molar_mass = 0.01801528 kg/mol },
};
struct mixture { text name; float<kg/mol> molar_mass; };
table<mixture> mixtures(capacity = 16, key = name) = { { name = "air" }, { name = "kiloair" }, { name = "nitrogen" }, { name = "nitrogen3" }, { name = "humid0" }, { name = "humid1" }, { name = "humid2" }, { name = "humid3" }, { name = "humid4" }, { name = "humid5" }, { name = "humid6" }, { name = "humid7" }, { name = "humid8" }, { name = "humid9" }, { name = "humid10" } };
struct part { relation<mixtures> mixture; float fraction; relation<gases> gas; };
table<part> parts(capacity = 96) = {
{ mixture = mixtures["air"], fraction = 0.78084, gas = gases["N2"] },
{ mixture = mixtures["air"], fraction = 0.20946, gas = gases["O2"] },
{ mixture = mixtures["air"], fraction = 0.00934, gas = gases["Ar"] },
{ mixture = mixtures["air"], fraction = 0.000412, gas = gases["CO2"] },
{ mixture = mixtures["kiloair"], fraction = 780.84, gas = gases["N2"] },
{ mixture = mixtures["kiloair"], fraction = 209.46, gas = gases["O2"] },
{ mixture = mixtures["kiloair"], fraction = 9.34, gas = gases["Ar"] },
{ mixture = mixtures["kiloair"], fraction = 0.412, gas = gases["CO2"] },
{ mixture = mixtures["nitrogen"], fraction = 1.0, gas = gases["N2"] },
{ mixture = mixtures["nitrogen3"], fraction = 3.0, gas = gases["N2"] },
{ mixture = mixtures["humid0"], fraction = 0.78084, gas = gases["N2"] },
{ mixture = mixtures["humid0"], fraction = 0.20946, gas = gases["O2"] },
{ mixture = mixtures["humid0"], fraction = 0.00934, gas = gases["Ar"] },
{ mixture = mixtures["humid0"], fraction = 0.000412, gas = gases["CO2"] },
{ mixture = mixtures["humid0"], fraction = 0.0, gas = gases["H2O"] },
{ mixture = mixtures["humid1"], fraction = 0.78084, gas = gases["N2"] },
{ mixture = mixtures["humid1"], fraction = 0.20946, gas = gases["O2"] },
{ mixture = mixtures["humid1"], fraction = 0.00934, gas = gases["Ar"] },
{ mixture = mixtures["humid1"], fraction = 0.000412, gas = gases["CO2"] },
{ mixture = mixtures["humid1"], fraction = 0.005025125628140704, gas = gases["H2O"] },
{ mixture = mixtures["humid2"], fraction = 0.78084, gas = gases["N2"] },
{ mixture = mixtures["humid2"], fraction = 0.20946, gas = gases["O2"] },
{ mixture = mixtures["humid2"], fraction = 0.00934, gas = gases["Ar"] },
{ mixture = mixtures["humid2"], fraction = 0.000412, gas = gases["CO2"] },
{ mixture = mixtures["humid2"], fraction = 0.010101010101010102, gas = gases["H2O"] },
{ mixture = mixtures["humid3"], fraction = 0.78084, gas = gases["N2"] },
{ mixture = mixtures["humid3"], fraction = 0.20946, gas = gases["O2"] },
{ mixture = mixtures["humid3"], fraction = 0.00934, gas = gases["Ar"] },
{ mixture = mixtures["humid3"], fraction = 0.000412, gas = gases["CO2"] },
{ mixture = mixtures["humid3"], fraction = 0.015228426395939085, gas = gases["H2O"] },
{ mixture = mixtures["humid4"], fraction = 0.78084, gas = gases["N2"] },
{ mixture = mixtures["humid4"], fraction = 0.20946, gas = gases["O2"] },
{ mixture = mixtures["humid4"], fraction = 0.00934, gas = gases["Ar"] },
{ mixture = mixtures["humid4"], fraction = 0.000412, gas = gases["CO2"] },
{ mixture = mixtures["humid4"], fraction = 0.020408163265306124, gas = gases["H2O"] },
{ mixture = mixtures["humid5"], fraction = 0.78084, gas = gases["N2"] },
{ mixture = mixtures["humid5"], fraction = 0.20946, gas = gases["O2"] },
{ mixture = mixtures["humid5"], fraction = 0.00934, gas = gases["Ar"] },
{ mixture = mixtures["humid5"], fraction = 0.000412, gas = gases["CO2"] },
{ mixture = mixtures["humid5"], fraction = 0.025641025641025644, gas = gases["H2O"] },
{ mixture = mixtures["humid6"], fraction = 0.78084, gas = gases["N2"] },
{ mixture = mixtures["humid6"], fraction = 0.20946, gas = gases["O2"] },
{ mixture = mixtures["humid6"], fraction = 0.00934, gas = gases["Ar"] },
{ mixture = mixtures["humid6"], fraction = 0.000412, gas = gases["CO2"] },
{ mixture = mixtures["humid6"], fraction = 0.030927835051546393, gas = gases["H2O"] },
{ mixture = mixtures["humid7"], fraction = 0.78084, gas = gases["N2"] },
{ mixture = mixtures["humid7"], fraction = 0.20946, gas = gases["O2"] },
{ mixture = mixtures["humid7"], fraction = 0.00934, gas = gases["Ar"] },
{ mixture = mixtures["humid7"], fraction = 0.000412, gas = gases["CO2"] },
{ mixture = mixtures["humid7"], fraction = 0.036269430051813475, gas = gases["H2O"] },
{ mixture = mixtures["humid8"], fraction = 0.78084, gas = gases["N2"] },
{ mixture = mixtures["humid8"], fraction = 0.20946, gas = gases["O2"] },
{ mixture = mixtures["humid8"], fraction = 0.00934, gas = gases["Ar"] },
{ mixture = mixtures["humid8"], fraction = 0.000412, gas = gases["CO2"] },
{ mixture = mixtures["humid8"], fraction = 0.04166666666666667, gas = gases["H2O"] },
{ mixture = mixtures["humid9"], fraction = 0.78084, gas = gases["N2"] },
{ mixture = mixtures["humid9"], fraction = 0.20946, gas = gases["O2"] },
{ mixture = mixtures["humid9"], fraction = 0.00934, gas = gases["Ar"] },
{ mixture = mixtures["humid9"], fraction = 0.000412, gas = gases["CO2"] },
{ mixture = mixtures["humid9"], fraction = 0.04712041884816754, gas = gases["H2O"] },
{ mixture = mixtures["humid10"], fraction = 0.78084, gas = gases["N2"] },
{ mixture = mixtures["humid10"], fraction = 0.20946, gas = gases["O2"] },
{ mixture = mixtures["humid10"], fraction = 0.00934, gas = gases["Ar"] },
{ mixture = mixtures["humid10"], fraction = 0.000412, gas = gases["CO2"] },
{ mixture = mixtures["humid10"], fraction = 0.052631578947368425, gas = gases["H2O"] },
};
table<mixture> blends(capacity = 4, key = name) = { { name = "steam" }, { name = "damp" }, { name = "stack" } };
struct share { relation<blends> mixture; float fraction; relation<lusine.materials.chemical> gas; };
table<share> shares(capacity = 8) = {
{ mixture = blends["steam"], fraction = 1.0, gas = lusine.materials.chemical["water"] },
{ mixture = blends["damp"], fraction = 0.97, gas = lusine.materials.chemical["nitrogen"] },
{ mixture = blends["damp"], fraction = 0.03, gas = lusine.materials.chemical["water"] },
{ mixture = blends["stack"], fraction = 0.7, gas = lusine.materials.chemical["nitrogen"] },
{ mixture = blends["stack"], fraction = 0.2, gas = lusine.materials.chemical["carbon_dioxide"] },
{ mixture = blends["stack"], fraction = 0.1, gas = lusine.materials.chemical["water"] },
};
void main()
{
mixtures.molar_mass = lusine.properties.molar_mass(parts);
blends.molar_mass = lusine.properties.molar_mass(parts = shares);
}
Reference: dry air¶
The US Standard Atmosphere's dry air is 28.9645 g/mol. From the CRC's molar masses and the composition the reference takes (its CO₂ today's mean, 412 ppm), the float64 reference gives 28.9662, within 0.002 g/mol of it, and the kernel the same within binary32's rounding.
from lusine.kernels.properties.molar_mass import molar_mass as reference
print(f"{M['air'] * 1000:.5f} g/mol; the float64 reference {reference(DRY_AIR) * 1000:.5f}")
assert abs(M['air'] - 0.028965) < 2e-6 and abs(M['air'] - reference(DRY_AIR)) <= 8 * U * M['air']
28.96619 g/mol; the float64 reference 28.96619
Reference: bit for bit, the folds in binary32¶
Every mixture of both tables is a binary32 model's bit for bit: the mixture's parts in slot order (the order the table declares them), their amounts and their amounts times their gas's molar mass each added in Trait's fixed tree (the first ceil(n / 2) and the rest, each folded so, then added), and the second sum divided by the first. The molar masses are the cells as the program holds them, the page's table and the library's alike; the float64 reference agrees within 8 × 2⁻²⁴.
def tree(values):
if not values:
return F32(0.0)
if len(values) == 1:
return F32(values[0])
mid = (len(values) + 1) // 2
return F32(tree(values[:mid]) + tree(values[mid:]))
def table_masses(table):
return {r['key']: F32(r['molar_mass']) for r in rows.rows(run.unit, table)}
gas_mass = table_masses('mix.gases')
library_mass = table_masses('lusine.materials.chemical')
def model(composition, masses):
fractions = [F32(x) for x in composition.values()]
weighted = [F32(F32(x) * masses[g]) for g, x in composition.items()]
return F32(tree(weighted) / tree(fractions))
def model_holds(M):
return (all(F32(M[m]) == model(c, gas_mass) for m, c in MIXTURES.items())
and all(F32(M[m]) == model(c, library_mass) for m, c in SHARES.items()))
assert model_holds(M)
gases = {**GASES, 'water': {'molar_mass': float(library_mass['water'])},
'nitrogen': {'molar_mass': float(library_mass['nitrogen'])},
'carbon_dioxide': {'molar_mass': float(library_mass['carbon_dioxide'])}}
worst = max(abs(M[m] - reference(c, gases)) / M[m] for m, c in {**MIXTURES, **SHARES}.items())
assert worst <= 8 * U, worst / U
print(f'{len(MIXTURES) + len(SHARES)} mixtures over two tables bit for bit with the binary32 model; '
f'the float64 reference within {worst / U:.1f} x 2^-24')
18 mixtures over two tables bit for bit with the binary32 model; the float64 reference within 1.8 x 2^-24
Limit: one gas alone¶
A mixture of one gas is that gas's molar mass: exactly where its amount is 1 (M × 1 / 1), within an ulp where it is 3 moles (fl(3 M) / 3). Steam, the library's water alone, is the library's water row's molar mass.
def alone_holds(M):
return (F32(M['nitrogen']) == gas_mass['N2'] and abs(M['nitrogen3'] - gas_mass['N2']) <= 2 * U * gas_mass['N2']
and F32(M['steam']) == library_mass['water'])
assert alone_holds(M)
print('nitrogen alone', M['nitrogen'], 'in 3 mol', M['nitrogen3'], '; steam', M['steam'])
nitrogen alone 0.028013400733470917 in 3 mol 0.028013400733470917 ; steam 0.01801528036594391
Invariant: proportions, not quantities¶
The same air in thousands of moles has the same molar mass, within the sums' rounding; and air is lighter by the mole as its water rises.
def proportions_hold(M):
return abs(M['kiloair'] - M['air']) <= 8 * U * M['air']
humid = np.array([M[f'humid{i}'] for i in range(len(WATER))])
assert proportions_hold(M) and np.all(np.diff(humid) < 0)
fig, ax = plt.subplots(figsize=(7, 3))
ax.plot(WATER, humid * 1000, 'o-')
ax.set_xlabel('water, amount fraction')
ax.set_ylabel('M (g/mol)')
plt.show()
Planted faults¶
Two wrong kernels over the same lists, written here in Trait and bound in the library's place: the amounts summed without dividing by their total (so the scale of the amounts leaks in), and the where left off (every part of every mixture folded into each). The proportions catch the first, a gas alone the second, and the binary32 model both.
UNSCALED = program('unscaled', '''
float<kg/mol> unscaled(list<struct { relation mixture; float fraction; relation gas; }> parts)
{
return sum(fraction * gas.molar_mass) where mixture == it;
}
''')
EVERY = program('every', '''
float<kg/mol> every(list<struct { relation mixture; float fraction; relation gas; }> parts)
{
return sum(fraction * gas.molar_mass) / sum(fraction);
}
''')
W, _ = molar_masses(UNSCALED)
assert not proportions_hold(W) and not model_holds(W)
W, _ = molar_masses(EVERY)
assert not alone_holds(W) and not model_holds(W)
print('PLANTED: the undivided amounts and the missing where are each caught')
PLANTED: the undivided amounts and the missing where are each caught