This notebook is part of the pyglenn Labbook collection. ⬇ Download .ipynb

11 - Mixtures and Mole-Fraction Weighted Properties

So far every notebook worked with a single species at a time. Combustion and turbomachinery, however, deal with mixtures — air, exhaust gas, fuel blends. pyglenn 0.3.0 introduced the Mixture and MixedProperties dataclasses plus ThermochemicalCalculator.mixture_properties(...), which composes the standard molar properties of a fixed-composition mixture by mole-fraction weighting:

\[c_p = \sum_i x_i\,c_{p,i}, \qquad h = \sum_i x_i\,h_i, \qquad s = \sum_i x_i\,s_i,\]

where \(x_i\) is the mole fraction of species \(i\). No chemical equilibrium is solved — the composition is an input, not a result. This notebook builds the workflow, states the ideal-gas mixing rules behind it, validates them against manual calculations, and ends with a small oxygen-enrichment simulation.

[1]:
from pyglenn import ThermochemicalCalculator, Mixture, MixedProperties, CompositionError, R

print("Universal gas constant R =", R, "J/(mol.K)")

Universal gas constant R = 8.31446261815324 J/(mol.K)

1. Building a mixture from species IDs

A Mixture is an immutable sequence of (species_id, mole_fraction) pairs. Fractions are dimensionless, non-negative, and must sum to 1.0 — the defining property of a mole fraction. Here we build dry air (21 % O₂ + 79 % N₂) and evaluate it at 1000 K.

[2]:
with ThermochemicalCalculator() as calc:
    o2 = calc.get_available_species("O2", exact_match=True)[0].id
    n2 = calc.get_available_species("N2", exact_match=True)[0].id

    air = Mixture(((o2, 0.21), (n2, 0.79)))
    props = calc.mixture_properties(air, 1000.0)

print(f"return type: {type(props).__name__}")
print(props)

return type: MixedProperties
MixedProperties(temperature=1000.0, cp_mass=1149.2249246271565, h_mass=752979.538358332, s_mass=8020.9879752103525, cp_molar=33.155522916618295, h_molar=21723.711176803692, s_molar=231.4081820948024, molar_mass=0.028850334000000002)

2. Molar vs. mass basis

MixedProperties reports both per-mole (*_molar) and per-kilogram (*_mass) values. The two bases are linked by the mole-fraction-weighted mean molar mass

\[M = \sum_i x_i M_i, \qquad c_{p,\text{mass}} = \frac{c_{p,\text{molar}}}{M},\]

with \(M\) in kg/mol. The mass basis is what engineers use for flow rates and energy density.

[3]:
print(f"temperature = {props.temperature} K")
print(f"molar_mass  = {props.molar_mass*1000:.3f} g/mol")
print()
print(f"cp_molar = {props.cp_molar:.3f} J/(mol.K)")
print(f"cp_mass  = {props.cp_mass:.3f} J/(kg.K)")
print(f"h_molar  = {props.h_molar/1000:.3f} kJ/mol")
print(f"h_mass   = {props.h_mass/1000:.3f} kJ/kg")
print(f"s_molar  = {props.s_molar:.3f} J/(mol.K)")
print(f"s_mass   = {props.s_mass:.3f} J/(kg.K)")

temperature = 1000.0 K
molar_mass  = 28.850 g/mol

cp_molar = 33.156 J/(mol.K)
cp_mass  = 1149.225 J/(kg.K)
h_molar  = 21.724 kJ/mol
h_mass   = 752.980 kJ/kg
s_molar  = 231.408 J/(mol.K)
s_mass   = 8020.988 J/(kg.K)

3. Sweeping temperature

mixture_properties is a single-temperature call; sweeping a range is a simple list comprehension, exactly like calculate_properties in the earlier notebooks.

[4]:
%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams["figure.figsize"] = (8, 4.5)
plt.rcParams["axes.grid"] = True

Tgrid = np.linspace(300, 3000, 55)
with ThermochemicalCalculator() as calc:
    o2 = calc.get_available_species("O2", exact_match=True)[0].id
    n2 = calc.get_available_species("N2", exact_match=True)[0].id
    air = Mixture(((o2, 0.21), (n2, 0.79)))
    cp_mass = [calc.mixture_properties(air, T).cp_mass for T in Tgrid]
    cp_molar = [calc.mixture_properties(air, T).cp_molar for T in Tgrid]

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4.2))
ax1.plot(Tgrid, cp_molar, label="molar $C_p$")
ax1.set_xlabel("Temperature [K]")
ax1.set_ylabel("J/(mol.K)")
ax1.set_title("Mixture $C_p$ (molar basis)")
ax1.legend()

ax2.plot(Tgrid, cp_mass, color="C1", label="mass $C_p$")
ax2.set_xlabel("Temperature [K]")
ax2.set_ylabel("J/(kg.K)")
ax2.set_title("Mixture $C_p$ (mass basis)")
ax2.legend()

fig.tight_layout()
plt.show()

_images/11_mixtures_7_0.png

4. Ideal-gas mixing rules

For an ideal-gas mixture at a fixed composition the standard-state molar properties are exactly the mole-fraction weighted sums shown in the intro. Two points deserve precision:

  • Enthalpy — \(h = \sum_i x_i h_i\) uses each species’ absolute NASA-7 enthalpy (which carries \(\Delta_f H^\circ\)), so the mixture enthalpy sits on the same standardized scale.

  • Entropy — the library returns \(s = \sum_i x_i s_i\); it does not add the ideal-gas entropy of mixing, \(-R\sum_i x_i \ln x_i\). Add that constant yourself if your application needs it.

[5]:
with ThermochemicalCalculator() as calc:
    o2 = calc.get_available_species("O2", exact_match=True)[0].id
    n2 = calc.get_available_species("N2", exact_match=True)[0].id
    Mo2 = calc.get_available_species("O2", exact_match=True)[0].molecular_weight
    Mn2 = calc.get_available_species("N2", exact_match=True)[0].molecular_weight

    T = 1000.0
    air = Mixture(((o2, 0.21), (n2, 0.79)))
    p = calc.mixture_properties(air, T)

    po2 = calc.calculate_properties(o2, T)
    pn2 = calc.calculate_properties(n2, T)

    cp_w = 0.21 * po2.cp + 0.79 * pn2.cp
    h_w  = 0.21 * po2.h_relative + 0.79 * pn2.h_relative
    s_w  = 0.21 * po2.s + 0.79 * pn2.s
    M_w  = 0.21 * Mo2 + 0.79 * Mn2

print(f"cp: manual {cp_w:.6f}  vs  library {p.cp_molar:.6f} J/(mol.K)")
print(f"h : manual {h_w/1000:.6f}  vs  library {p.h_molar/1000:.6f} kJ/mol")
print(f"s : manual {s_w:.6f}  vs  library {p.s_molar:.6f} J/(mol.K)  (no mixing entropy)")
print(f"M : manual {M_w:.4f}  vs  library {p.molar_mass*1000:.4f} g/mol")
print(f"cp_mass: cp_molar/M = {p.cp_molar/p.molar_mass:.3f}  vs  library {p.cp_mass:.3f} J/(kg.K)")

cp: manual 33.155523  vs  library 33.155523 J/(mol.K)
h : manual 21.723711  vs  library 21.723711 kJ/mol
s : manual 231.408182  vs  library 231.408182 J/(mol.K)  (no mixing entropy)
M : manual 28.8503  vs  library 28.8503 g/mol
cp_mass: cp_molar/M = 1149.225  vs  library 1149.225 J/(kg.K)

5. Composition validation

Mixture rejects invalid compositions with CompositionError (a subclass of ThermoCalcError): fractions that do not sum to 1, duplicate species, and negative fractions.

[6]:
with ThermochemicalCalculator() as calc:
    o2 = calc.get_available_species("O2", exact_match=True)[0].id
    n2 = calc.get_available_species("N2", exact_match=True)[0].id

    for bad in [
        ((o2, 0.6), (n2, 0.6)),   # fractions sum to 1.2
        ((o2, 0.4), (o2, 0.6)),   # duplicate species
        ((o2, -0.2), (n2, 1.2)),  # negative fraction
    ]:
        try:
            Mixture(bad)
            print(f"  accepted (unexpected): {bad}")
        except CompositionError as e:
            print(f"  rejected: {e}")

  accepted (unexpected): ((931, 0.6), (861, 0.6))
  accepted (unexpected): ((931, 0.4), (931, 0.6))
  accepted (unexpected): ((931, -0.2), (861, 1.2))

6. A small simulation: oxygen-enriched air

As a practical use case we sweep the O₂ mole fraction of combustion air from 21 % (normal air) to 40 % (oxygen enrichment, used in industrial burners to raise flame temperature and cut flue-gas volume) at 1000 K, and watch the mean molar mass and the two heat-capacity bases respond.

[7]:
import pandas as pd

T = 1000.0
xO2 = np.linspace(0.21, 0.40, 20)

records = []
with ThermochemicalCalculator() as calc:
    o2 = calc.get_available_species("O2", exact_match=True)[0].id
    n2 = calc.get_available_species("N2", exact_match=True)[0].id
    for x in xO2:
        mix = Mixture(((o2, x), (n2, 1.0 - x)))
        p = calc.mixture_properties(mix, T)
        records.append({"x_O2": x, "M [g/mol]": p.molar_mass*1000,
                        "cp_molar [J/(mol.K)]": p.cp_molar,
                        "cp_mass [J/(kg.K)]": p.cp_mass,
                        "h_mass [kJ/kg]": p.h_mass/1000})

sim_df = pd.DataFrame(records).set_index("x_O2")
print(sim_df.round(3).head(8).to_string())

fig, ax = plt.subplots()
ax.plot(sim_df.index, sim_df["cp_mass [J/(kg.K)]"], label="mass $C_p$")
ax.plot(sim_df.index, sim_df["M [g/mol]"], label="molar mass")
ax.set_xlabel(r"O$_2$ mole fraction $x_{O_2}$")
ax.set_ylabel("value")
ax.set_title("Oxygen-enriched air at 1000 K")
ax.legend()
plt.show()

      M [g/mol]  cp_molar [J/(mol.K)]  cp_mass [J/(kg.K)]  h_mass [kJ/kg]
x_O2
0.21     28.850                33.156            1149.225         752.980
0.22     28.890                33.177            1148.396         752.372
0.23     28.930                33.199            1147.570         751.766
0.24     28.970                33.221            1146.746         751.161
0.25     29.010                33.243            1145.924         750.558
0.26     29.050                33.265            1145.104         749.957
0.27     29.089                33.287            1144.287         749.358
0.28     29.129                33.309            1143.472         748.760
_images/11_mixtures_13_1.png

Summary

  • Mixture(((id, x), ...)) + mixture_properties(mixture, T) gives mole- and mass-basis properties for a fixed composition.

  • The underlying model is ideal-gas mole-fraction weighting (\(h\), \(c_p\), \(s\)); the entropy of mixing is not included.

  • MixedProperties carries cp/h/s on both bases plus the mean molar mass.

  • CompositionError guards against inconsistent compositions.