Skip to content

Fix rotational thermochemistry for linear and monatomic species - #59

Merged
galjos merged 1 commit into
mainfrom
fix-linear-monatomic-rotational
Jun 29, 2026
Merged

Fix rotational thermochemistry for linear and monatomic species#59
galjos merged 1 commit into
mainfrom
fix-linear-monatomic-rotational

Conversation

@galjos

@galjos galjos commented Jun 29, 2026

Copy link
Copy Markdown
Member

Found by an audit of the RRHO math against ASE IdealGasThermo: the nonlinear path is exact, but linear and monatomic species were broken.

Bug

The rotational block in thermo.py was hardwired to the nonlinear rigid rotor — it multiplied all three rotational temperatures (theta_x*theta_y*theta_z) and used (3/2)RT energy and (3/2)R heat capacity unconditionally. A linear molecule has one ~zero principal moment of inertia, so theta -> inf, q_rot -> 0, and:

  • S_rot = R(ln q_rot + 3/2) = -inftotal entropy = -inf, Gibbs = +inf for every linear molecule (CO2, N2, O2, CO, HCN, acetylene, …).
  • E_rot/Cv_rot were 50% too high for linear (should be RT/R) and nonzero for monatomic (should be 0).
  • Monatomic species additionally hit a divide-by-zero in the vibrational formula because dof() returned 3 (→ three zero-frequency "modes") instead of 0.

The earlier linearity() fix (#51) only corrected the geometry classification; the Thermo rotational math never consulted it.

Fix

  • Branch the rotational contribution on geometry using the existing linearity() classifier: n_rot = 0 (monatomic), 2 (linear), 3 (nonlinear).
  • General formulas: S_rot = R(ln q_rot + n_rot/2), E_rot = (n_rot/2)RT, Cv_rot = (n_rot/2)R; linear partition function q_rot = T/(sigma*theta). The nonlinear path is unchanged.
  • dof() returns 0 for a monatomic species (no rotational/vibrational DOF), so the vibrational formulas no longer divide by zero.
  • Use np.linalg.eigvalsh (not eig) on the symmetric inertia tensor — real, sorted eigenvalues.

Validation (vs ASE IdealGasThermo, total entropy cal/(mol*K))

molecule geometry TS ASE
H2O nonlinear 45.135 45.135
CO2 linear 51.064 51.063
N2 linear 45.768 45.768
Ar monatomic 36.983 36.983

New parametrized tests assert TS == ASE for all four geometry classes (ASE is already a dependency, so this runs in CI with no DFTB+ binary), plus a regression check that linear/monatomic rotational entropy is finite. The nonlinear anthraquinone regression (internal _rotational_* attributes) is unchanged.

Not in this PR (filed separately, both LOW severity)

The same audit found two minor issues: imaginary/negative vibrational modes silently produce NaN (has_imaginary_frequencies is computed but ignored), and spin/multiplicity is inferred from charge parity only (open-shell neutrals like O2/OH get the wrong electronic entropy).

The rotational block in Thermo was hardwired to the nonlinear rigid rotor: it
multiplied all three rotational temperatures and used (3/2)RT energy and (3/2)R
heat capacity unconditionally. For a linear molecule one principal moment of
inertia is ~zero, so a rotational temperature went to infinity and the
partition function to zero, giving rotational entropy = -inf (and thus -inf
total entropy and +inf Gibbs) for every linear molecule (CO2, N2, O2, CO,
acetylene, ...). Monatomic species were likewise broken.

Branch the rotational contribution on geometry via the existing linearity()
classifier: n_rot = 0 (monatomic), 2 (linear), 3 (nonlinear), with
S_rot=R(ln q+n_rot/2), E_rot=(n_rot/2)RT, Cv_rot=(n_rot/2)R, and the correct
linear partition function q_rot=T/(sigma*theta). Also use eigvalsh (not eig) on
the symmetric inertia tensor, and make dof() return 0 for a monatomic species
(no vibrational modes) so the vibrational formulas no longer divide by zero.

Validated against ASE IdealGasThermo: H2O (nonlinear) 45.135, CO2 (linear)
51.064 vs 51.063, N2 (linear) 45.768, Ar (monatomic) 36.983 cal/(mol*K) — all
match; the nonlinear anthraquinone regression is unchanged.
@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.15%. Comparing base (33b6469) to head (1db1fc6).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #59      +/-   ##
==========================================
+ Coverage   95.93%   96.15%   +0.21%     
==========================================
  Files          22       22              
  Lines        1280     1301      +21     
==========================================
+ Hits         1228     1251      +23     
+ Misses         52       50       -2     
Flag Coverage Δ
unittests 96.15% <100.00%> (+0.21%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@galjos
galjos merged commit 0b25e99 into main Jun 29, 2026
3 checks passed
@galjos
galjos deleted the fix-linear-monatomic-rotational branch June 29, 2026 11:02
galjos added a commit that referenced this pull request Jun 30, 2026
Closes #60.

An imaginary (non-positive) vibrational mode in the kept `dof` set made
the harmonic-oscillator formulas return `NaN` entropy/Gibbs **silently**
(`np.log(1 - exp(-theta/T))` of a negative argument). For the screening
framework that means a `NaN` row instead of a clean `error`.

## Fix
`_vibrational_contribution` raises `TSValueError` when
`real_vibrational_frequencies` contains a non-positive value — a
non-minimum geometry is now reported as an error (and isolated by the
screening loop) instead of producing `NaN`.

## Why the kept set, not `has_imaginary_frequencies`
Real DFTB+ output carries small **negative** translation/rotation
frequencies in the *input* (e.g. `frequency.txt` starts `-35.7, -14.34,
-3.23`) that `frequency_dof` correctly **drops**. Guarding on
`System.has_imaginary_frequencies` (the full input) would wrongly reject
every real molecule; the kept set of a true minimum is strictly positive
(verified: the anthraquinone fixture keeps 66 modes, min 40.6 cm⁻¹, and
still computes S = 103.743).

## Tests
A 3-atom system with an imaginary mode in the kept set now raises
`TSValueError`; the existing nonlinear/anthraquinone regressions
(negative input, positive kept) are unchanged. 163 passed; pylint 8.90.

Found by the thermochemistry audit alongside #59; #61
(spin/multiplicity) remains.
galjos added a commit that referenced this pull request Jul 2, 2026
Closes #61.

Electronic entropy (`q_elec = 2S+1`, `S_elec = R ln q`) took its spin
from `spin(charge)` — **charge parity** — which is wrong for open-shell
species.

## Change
- Replace it with `default_spin(atoms, charge)`: the **minimum-spin
ground state** from the electron-count parity — `even → singlet` (S=0),
`odd → doublet` (S=0.5). Even electron count is a singlet ~99% of the
time, and an odd count is essentially always a doublet, so this
auto-handles closed-shell molecules **and** simple radicals (e.g. every
1-electron-reduced redox species) correctly.
- Add an explicit, validated (non-negative) `spin` argument to `System`,
threaded through `run_thermo`, `dftbplus_thermo`, and `screen` (optional
`spin` manifest column) for the cases the guess cannot know:
**even-electron high-spin ground states** (triplet O2 → `spin=1`, →
`S_elec = R ln 3`).

```python
System(atoms, charge=0, spin=1.0, ...)   # triplet O2 (even electrons, user decides)
# radicals (odd electrons) are auto-guessed as doublets
```

## Tests
- `default_spin` electron-count parametrized (water/O2/hydroxide → 0;
methyl radical → 0.5); explicit-spin override; negative-spin rejection.
- Triplet O2 (`spin=1`) → `S_elec = R ln 3`.
- Manifest `spin` column → `ScreeningJob.spin`; `screen` passes it to
`dftbplus_thermo`.

172 passed; pylint 8.86.

Follow-up (separate PR, once this lands): opt-in **spin-polarised
DFTB+** so a declared/guessed open-shell spin also drives the
calculation (verified 3ob spin constants ready). Closes out the
thermochemistry audit (#59 rotational, #62 imaginary modes, #61 spin).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant