Molecular Equilibrium¶
In cool stellar atmospheres (\(T_{\rm eff} \lesssim 4500\) K), molecules form in significant quantities and become dominant opacity sources. Accurate spectrum synthesis therefore requires solving the molecular equilibrium: the chemistry that determines the partial pressure of every molecular species at every depth in the atmosphere.
Intuition¶

As you move down (and cool down) through a cool-dwarf photosphere, atoms first pair into simple diatomics (CO, OH at \(T \sim 4000\) K), then more complex molecules dominate (TiO, H₂O, FeH at \(T \sim 2800\) K). Every species is coupled — solving one equilibrium constant in isolation gives the wrong answer.
In hot gas, almost everything is atomic — bonds break thermally as fast as they form. As the gas cools, simple molecules (H₂, CO, OH, …) start to "freeze out": their formation energy is below \(k_B T\) and they remain bound on average. By 3000–4000 K, oxides like TiO and VO can carry several percent of the gas density and produce the characteristic broad absorption bands of M dwarfs and red giants.
The key complication is that everything is coupled: the partial pressure of TiO depends on how much atomic Ti is left, which depends on how much Ti is locked up in O-bearing molecules, which depends on how much O is left after CO formation, … Every species has to be solved simultaneously, conserving the total elemental abundance.
This is what NMOLEC does.
Derivation¶
Equilibrium for a single diatomic¶
For a generic diatomic \(AB\) with constituent atoms \(A\) and \(B\), the law of mass action gives
where the equilibrium constant \(K_{AB}(T)\) is set by the dissociation energy \(D_0\) and the rotational/vibrational/electronic partition functions of the three species:
In data/lines/molecules.dat the temperature dependence of each
\(\log_{10} K(T)\) is stored as a polynomial (typically degree 4 in
\(\log_{10} T\)). NMOLEC evaluates the polynomial at every depth's local
\(T\) to get \(K(T)\) for each species.
The coupled system¶
For a real photosphere we have \(N\) atomic species and \(M\) molecular species. The unknowns are the partial pressures \(\{P_A\}\) for the atoms and \(\{P_{AB}\}\) for the molecules. The equations are:
- Equilibrium: one equation per molecule, relating its partial pressure to those of its constituents.
- Conservation: one equation per element, requiring the total amount of element \(X\) (free atoms + every molecule containing \(X\), weighted by stoichiometry) to equal the prescribed elemental abundance.
- Charge neutrality / electron balance, since some molecules ionise.
That gives \(N + M\) algebraic equations in \(N + M\) unknowns. The system is non-linear because conservation couples atoms to every molecule they appear in, and molecule equilibrium couples back to atomic partial pressures.
How NMOLEC iterates¶
NMOLEC solves the coupled system with a fixed-point iteration on the atomic partial pressures:
- Make an initial guess for \(\{P_A\}\) (typically: ignore molecules and take the Saha–Boltzmann atomic pressures).
- Use the current \(\{P_A\}\) to compute every \(P_{AB}\) via mass action.
- For each element, sum up the partial pressures (free + molecular, with stoichiometry) and compare against the target abundance.
- Adjust \(\{P_A\}\) to reduce the residuals, and repeat from step 2.
- Converge when every element's partial-pressure residual is below a tolerance, typically a few × 10⁻⁵ in relative terms.
The original Fortran NMOLEC uses a damped Newton-style update on \(\log P\); the Python translation reproduces the same step structure, so the same number of iterations is needed for convergence.
Species and data¶
atlas_py and synthe_py solve equilibrium for ~50 molecular
species. The list in synthe_py/physics/mol_populations.py includes
every species needed for cool dwarfs through M giants:
- Hydrides: H₂, CH, NH, OH, MgH, AlH, SiH, CaH, ScH, TiH, VH, CrH, MnH, FeH, CoH, NiH, CuH, …
- Oxides: CO, NO, O₂, SiO, MgO, AlO, CaO, ScO, TiO, VO, CrO, MnO, FeO, YO, ZrO, LaO, …
- Carbides / cyanides: C₂, CN, C₃, HCN
- Sulphides / halides: SH, SO, FH, ClH, ClO, S₂, HeH, LiH, BeH, …
- Triatomics: H₂O, CO₂
In all there are about 50 species; the exact count depends on which isotopologues are counted separately.
For each species, molecules.dat carries:
- the dissociation energy \(D_0\) in eV,
- the polynomial coefficients of \(\log_{10} K(T)\),
- isotope corrections where relevant.
Cool-star spectral signatures¶
The dominant molecular bands you can expect to see in cool-star spectra (approximate bandhead wavelengths, given as vacuum values consistent with pykurucz's internal convention):
| Feature | Bandhead (nm) | Molecule | Stellar type |
|---|---|---|---|
| TiO γ system bandheads | ~516, 544, 586, 615, 705 | TiO | M dwarfs/giants |
| H₂O bands (NIR) | ~940, 1130, 1380 | H₂O | Late M, brown dwarfs |
| CN violet system | ~388, 421 | CN | Carbon stars, giants |
| CO first-overtone | ~2300 | CO | All cool stars |
| CH G-band | ~430.5 | CH | F–G dwarfs |
| MgH | ~478 | MgH | K giants |
These wavelengths refer to bandheads (the sharp blue edge of a rotational-vibrational band envelope), not individual line centres. For exact line positions, consult the line lists themselves.
Why NMOLEC matters for cool stars
In a 4000 K dwarf, TiO alone can account for more than 10 % of the
total opacity in the optical. Neglecting molecular equilibrium
produces spectra that are systematically too blue and too shallow
in molecular bands. Early pykurucz cool-star validation found a
~90 % discrepancy in the Na D doublet that traced directly to
missing molecular opacity; switching MOLECULES ON resolved it.
Implementation¶
| File | Role |
|---|---|
atlas_py/physics/nmolec.py |
NMOLEC solver invoked inside the atlas iteration |
atlas_py/io/molecules.py |
Parser for molecules.new / molecules.dat |
synthe_py/physics/mol_populations.py |
Standalone molecular-equilibrium solver used by convert_atm_to_npz |
How abundance changes propagate¶
NMOLEC's elemental conservation constraint reads the same per-element
abundance vector that the rest of the code uses
(_build_xabund_from_atm in synthe_py/physics/mol_populations.py,
or the xabund argument inside atlas_py/physics/nmolec.py). When you
change --mh, --am, or any individual --abund Z:offset, the new
abundance vector is what NMOLEC conserves against — so the partial
pressures of every species are recomputed for the actual pattern, and
TiO / H₂O / CO / etc. are not borrowed from a scaled-solar template.
This matters in particular for CEMP-style cases where C/O > 1 flips
the carbon-bearing chemistry on its head: the same code path handles
it correctly with no special casing because the abundance vector is
the only input that changed.
Where it runs in the pipeline:
- In Stellar Parameters, NMOLEC is invoked from inside POPSALL on
every
atlas_pyiteration; molecular populations are written into the converged.atmand reused by synthesis. - In Existing Atmosphere, NMOLEC is invoked once by
convert_atm_to_npz.pyand the populations are stored in the.npzcache; synthesis never re-solves the chemistry inside the wavelength loop.
Next Steps¶
- See opacity for how molecular populations turn into line opacity in the synthesis grid.
- See line broadening for the Voigt profiles applied to every molecular line.
- See the atlas_py architecture for how NMOLEC plugs into the atmosphere iteration.