Skip to content

Commit c403e97

Browse files
authored
Add Grimme's quasi-RRHO vibrational entropy option (#67)
Adds a `quasi_rrho` option (CLI `--quasi-rrho`) for the vibrational entropy. ## Why The rigid-rotor-harmonic-oscillator entropy of a mode **diverges as its frequency → 0**, so low-frequency modes (floppy torsions, weak/hindered rotors, non-covalent complexes) get a spuriously large entropy — a well-known RRHO weakness. This is also the dominant error left in implicit-solvent free energies (#66). ## What Grimme's quasi-RRHO ([Chem. Eur. J. 2012, 18, 9955](https://doi.org/10.1002/chem.201200497)): each mode's entropy is interpolated between the harmonic-oscillator and free-rotor values, weighted by `w = 1 / (1 + (100/ν)^4)`. High-frequency modes stay harmonic; low-frequency modes approach the finite free-rotor limit. - **`thermo/thermo.py`** — `Thermo(..., quasi_rrho=False)`; `_compute_vibrational_entropy` uses the new `_quasi_rrho_entropy` interpolation when enabled. The harmonic path is numerically unchanged, so the **default preserves existing results**. - **`thermo/api.py`, `thermo/screening.py`, `cli/thermo.py`** — thread `quasi_rrho` through `run_thermo`, `dftbplus_thermo`, `screen`, and `screen --quasi-rrho`. Engine-independent (it acts on the frequencies), so it also applies to a future xTB engine. ## Usage ``` thermo screen mols/ --solvent water --quasi-rrho ``` ## Validation - **Physics**: a molecule with 25/40 cm⁻¹ modes has its entropy reduced by ~3.4 cal/mol/K (≈ +1 kcal/mol in G) — the spurious low-mode entropy is tamed; a high-frequency-only case is unchanged (ΔS ≈ 0.01). - **Real DFTB+**: ethane (lowest real vibration 277 cm⁻¹, all > 100) gives qRRHO ≈ harmonic, and the harmonic S = 54.5 cal/mol/K matches the experimental ~54.8 — confirming the correct mode set is used. - Offline suite green (206 passed); real-DFTB+ integration green (54 passed, 0 skips); codecov patch fully covered.
1 parent f6cdc58 commit c403e97

7 files changed

Lines changed: 169 additions & 13 deletions

File tree

ThermoScreening/cli/thermo.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ def _command_parser():
106106
help="GBSA/ALPB implicit-solvation solvent (e.g. 'water') applied to every "
107107
"molecule. Default gas phase. Install with 'setup-dftb --solvent <name>'.",
108108
)
109+
screen_parser.add_argument(
110+
"--quasi-rrho", action="store_true",
111+
help="Use Grimme's quasi-RRHO vibrational entropy (better for low-frequency "
112+
"modes) instead of the pure harmonic oscillator.",
113+
)
109114

110115
return parser
111116

@@ -187,6 +192,7 @@ def run_screen(parser_args):
187192
directory=parser_args.directory,
188193
parameter_set=parser_args.parameter_set,
189194
solvent=parser_args.solvent,
195+
quasi_rrho=parser_args.quasi_rrho,
190196
)
191197

192198
failed = sum(1 for record in results if record["status"] != "ok")

ThermoScreening/thermo/api.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ def run_thermo(
346346
charge=0.0,
347347
atoms=None,
348348
spin=None,
349+
quasi_rrho=False,
349350
):
350351
"""
351352
Run the thermo calculation. Returns thermo calculation object.
@@ -369,6 +370,9 @@ def run_thermo(
369370
The system charge.
370371
atoms : ase.Atoms, optional
371372
An optimized geometry to use directly instead of reading ``coord_file``.
373+
quasi_rrho : bool
374+
If True, use Grimme's quasi-RRHO treatment for the vibrational entropy
375+
instead of the pure harmonic oscillator. Default False.
372376
373377
The coordinate file should be in xyz format.
374378
@@ -411,7 +415,11 @@ def run_thermo(
411415
)
412416

413417
thermo_setup = Thermo(
414-
system=system_info, temperature=temperature, pressure=pressure, engine=engine
418+
system=system_info,
419+
temperature=temperature,
420+
pressure=pressure,
421+
engine=engine,
422+
quasi_rrho=quasi_rrho,
415423
)
416424

417425
thermo_setup.run()
@@ -507,6 +515,7 @@ def dftbplus_thermo(
507515
spin_constants=None,
508516
solvent=None,
509517
solvation_param_file=None,
518+
quasi_rrho=False,
510519
**kwargs
511520
):
512521
"""
@@ -544,6 +553,9 @@ def dftbplus_thermo(
544553
solvation_param_file : str, optional
545554
Explicit path to a GBSA parameter file, overriding ``solvent`` (use a
546555
method-consistent set instead of the default GFN-fit one).
556+
quasi_rrho : bool
557+
If True, use Grimme's quasi-RRHO treatment for the vibrational entropy,
558+
which tames the entropy of low-frequency modes. Default False.
547559
548560
Other Parameters
549561
----------------
@@ -596,6 +608,7 @@ def dftbplus_thermo(
596608
engine='dftb+',
597609
charge=charge,
598610
spin=spin,
611+
quasi_rrho=quasi_rrho,
599612
)
600613

601614
return thermo

ThermoScreening/thermo/screening.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ def screen(
143143
spin=None,
144144
parameter_set="3ob",
145145
solvent=None,
146+
quasi_rrho=False,
146147
):
147148
"""
148149
Run a thermochemistry screen over a set of molecules.
@@ -175,6 +176,10 @@ def screen(
175176
Solvent name for GBSA/ALPB implicit solvation applied to every molecule
176177
(e.g. ``"water"``). Defaults to gas phase. The solvent parameter file
177178
must be installed (``thermo setup-dftb --solvent <name>``).
179+
quasi_rrho : bool
180+
If True, use Grimme's quasi-RRHO treatment for the vibrational entropy
181+
(recommended for flexible molecules with low-frequency modes). Default
182+
False (pure harmonic oscillator).
178183
179184
Returns
180185
-------
@@ -207,6 +212,7 @@ def screen(
207212
spin=job.spin,
208213
spin_constants=spin_constants,
209214
solvent=solvent,
215+
quasi_rrho=quasi_rrho,
210216
**parameters,
211217
)
212218
record.update(_thermo_summary(thermo))

ThermoScreening/thermo/thermo.py

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,19 @@ class Thermo:
4949
logger = logging.getLogger(__package_name__).getChild(__name__)
5050
logger = setup_logger(logger)
5151

52+
# Quasi-RRHO constants (Grimme, Chem. Eur. J. 2012, 18, 9955): the damping
53+
# frequency and the average molecular moment of inertia used for the
54+
# free-rotor limit of low-frequency modes.
55+
_QRRHO_FREQ_CM = 100.0 # cm^-1
56+
_QRRHO_BAV = 1.0e-44 # kg m^2
57+
5258
def __init__(
53-
self, temperature: float, pressure: float, system: System, engine: str
59+
self,
60+
temperature: float,
61+
pressure: float,
62+
system: System,
63+
engine: str,
64+
quasi_rrho: bool = False,
5465
):
5566
"""
5667
Initializes the Thermo class with the temperature, pressure, system information
@@ -66,6 +77,10 @@ def __init__(
6677
engine : str
6778
The engine used for the calculation to compute the thermochemical
6879
properties with correct units.
80+
quasi_rrho : bool
81+
If True, use Grimme's quasi-RRHO treatment for the vibrational
82+
entropy (interpolating low-frequency modes towards a free rotor)
83+
instead of the pure rigid-rotor-harmonic-oscillator model.
6984
7085
Raises
7186
------
@@ -96,6 +111,7 @@ def __init__(
96111
self._pressure = pressure
97112
self._system = system
98113
self._engine = engine
114+
self._quasi_rrho = quasi_rrho
99115

100116
if self._engine != "dftb+":
101117
raise TSValueError("The engine is not supported.")
@@ -352,26 +368,74 @@ def _compute_vibrational_entropy(self):
352368
"""
353369
Computes the vibrational entropy of the system.
354370
371+
Uses the harmonic-oscillator model, or Grimme's quasi-RRHO interpolation
372+
towards a free rotor for low-frequency modes when ``quasi_rrho`` is set.
373+
355374
Returns
356375
-------
357376
None
358377
"""
359378

360-
self._vibrational_entropy = np.subtract(
361-
np.divide(
362-
(self._vib_temp_K / self._temperature),
363-
(np.exp(self._vib_temp_K / self._temperature) - 1),
364-
),
365-
np.log(1 - np.exp(-self._vib_temp_K / self._temperature)),
379+
# Per-mode harmonic-oscillator entropy (in units of R).
380+
x = self._vib_temp_K / self._temperature
381+
harmonic = np.subtract(
382+
np.divide(x, (np.exp(x) - 1)),
383+
np.log(1 - np.exp(-x)),
384+
)
385+
386+
entropy_per_mode = (
387+
self._quasi_rrho_entropy(harmonic)
388+
if self._quasi_rrho
389+
else harmonic
366390
)
367391

368392
self._vibrational_entropy = (
369393
PhysicalConstants["R"]
370-
* np.sum(self._vibrational_entropy)
394+
* np.sum(entropy_per_mode)
371395
/ PhysicalConstants["cal"]
372396
)
373397

374398

399+
def _quasi_rrho_entropy(self, harmonic):
400+
"""
401+
Grimme's quasi-RRHO per-mode entropy (Chem. Eur. J. 2012, 18, 9955).
402+
403+
Each mode's entropy is interpolated between the harmonic-oscillator value
404+
and the free-rotor value, weighted by ``w = 1 / (1 + (nu0/nu)^4)`` so that
405+
high-frequency modes stay harmonic while low-frequency modes (whose HO
406+
entropy diverges as nu -> 0) approach the finite free-rotor limit.
407+
408+
Parameters
409+
----------
410+
harmonic : np.ndarray
411+
Per-mode harmonic-oscillator entropy in units of R.
412+
413+
Returns
414+
-------
415+
np.ndarray
416+
Per-mode quasi-RRHO entropy in units of R.
417+
"""
418+
h = PhysicalConstants["h"]
419+
kB = PhysicalConstants["kB"]
420+
temperature = self._temperature
421+
422+
# Moment of inertia of each mode (h*nu = kB*theta, so mu = h^2 / (8 pi^2 kB theta)),
423+
# then damped towards an average molecular moment B_av for the free rotor.
424+
moment = h**2 / (8 * np.pi**2 * kB * self._vib_temp_K)
425+
effective_moment = moment * self._QRRHO_BAV / (moment + self._QRRHO_BAV)
426+
427+
free_rotor = 0.5 + np.log(
428+
np.sqrt(8 * np.pi**3 * effective_moment * kB * temperature / h**2)
429+
)
430+
431+
weight = 1.0 / (
432+
1.0
433+
+ (self._QRRHO_FREQ_CM / self._system.real_vibrational_frequencies) ** 4
434+
)
435+
436+
return weight * harmonic + (1.0 - weight) * free_rotor
437+
438+
375439
def _compute_vibrational_energy(self):
376440
"""
377441
Computes the vibrational energy of the system and the zero point energy correction.

tests/thermo/test_api.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,9 @@ class FakeModes:
408408
def __init__(self):
409409
self.wave_numbers = np.array([1.0, 2.0, 3.0])
410410

411-
def fake_run_thermo(frequencies, atoms=None, spin=None, **kwargs):
411+
def fake_run_thermo(frequencies, atoms=None, spin=None, quasi_rrho=False, **kwargs):
412412
seen["run_thermo_spin"] = spin
413+
seen["run_thermo_quasi_rrho"] = quasi_rrho
413414
return "thermo-result"
414415

415416
monkeypatch.setattr(api, "Geoopt", FakeGeoopt)
@@ -498,5 +499,16 @@ def test_dftbplus_thermo_gas_phase_has_no_solvation(monkeypatch, tmp_path):
498499
assert "Hamiltonian_Solvation" not in seen["geoopt_kwargs"]
499500

500501

502+
def test_dftbplus_thermo_forwards_quasi_rrho(monkeypatch, tmp_path):
503+
api, seen = _mock_pipeline(monkeypatch)
504+
api.dftbplus_thermo(
505+
Atoms("OH2", positions=[[0, 0, 0.12], [0, 0.76, -0.48], [0, -0.76, -0.48]]),
506+
directory=str(tmp_path / "j"),
507+
quasi_rrho=True,
508+
)
509+
510+
assert seen["run_thermo_quasi_rrho"] is True
511+
512+
501513
if __name__ == "__main__":
502514
unittest.main()

tests/thermo/test_screening.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,26 @@ def fake_thermo(atoms, solvent=None, **kwargs):
146146
assert captured["solvent"] == "water"
147147

148148

149+
def test_screen_passes_quasi_rrho_to_dftbplus_thermo(monkeypatch, tmp_path):
150+
_write_xyz(tmp_path / "mol.xyz")
151+
152+
captured = {}
153+
154+
def fake_thermo(atoms, quasi_rrho=False, **kwargs):
155+
captured["quasi_rrho"] = quasi_rrho
156+
return _FakeThermo()
157+
158+
monkeypatch.setattr(screening, "dftbplus_thermo", fake_thermo)
159+
screening.screen(
160+
str(tmp_path),
161+
out=str(tmp_path / "r"),
162+
directory=str(tmp_path / "runs"),
163+
quasi_rrho=True,
164+
)
165+
166+
assert captured["quasi_rrho"] is True
167+
168+
149169
def test_load_jobs_rejects_unknown_source(tmp_path):
150170
bad = tmp_path / "thing.txt"
151171
bad.write_text("x", encoding="utf-8")
@@ -253,6 +273,10 @@ def test_cli_parse_args_routes_screen():
253273

254274
solv_args = cli.parse_args(["screen", "molecules.csv", "--solvent", "water"])
255275
assert solv_args.solvent == "water"
276+
assert solv_args.quasi_rrho is False # harmonic by default
277+
278+
qrrho_args = cli.parse_args(["screen", "molecules.csv", "--quasi-rrho"])
279+
assert qrrho_args.quasi_rrho is True
256280

257281

258282
def test_cli_run_screen_returns_failure_count(monkeypatch):
@@ -265,7 +289,7 @@ def test_cli_run_screen_returns_failure_count(monkeypatch):
265289
args = Namespace(
266290
source="x", out="res", charge=0.0, temperature=298.15,
267291
pressure=101325.0, directory="screening", parameter_set="3ob",
268-
solvent=None,
292+
solvent=None, quasi_rrho=False,
269293
)
270294

271295
assert cli.run_screen(args) == 1 # one molecule failed

tests/thermo/test_thermo.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,17 @@ def test_thermo_rejects_negative_pressure():
126126
_R_CALMOLK = 8.314462618 / 4.184
127127

128128

129-
def _ts_thermo(symbols, positions, real_freqs, dof):
129+
def _ts_thermo(symbols, positions, real_freqs, dof, quasi_rrho=False):
130130
atoms = [Atom(symbol=s, position=np.array(p, float)) for s, p in zip(symbols, positions)]
131131
pad = np.concatenate([np.zeros(3 * len(atoms) - dof), np.asarray(real_freqs, float)])
132132
system = System(
133133
atoms, periodicity=False, cell=None, charge=0,
134134
electronic_energy=0.0, vibrational_frequencies=pad,
135135
)
136-
thermo = Thermo(temperature=_T, pressure=_P, system=system, engine="dftb+")
136+
thermo = Thermo(
137+
temperature=_T, pressure=_P, system=system, engine="dftb+",
138+
quasi_rrho=quasi_rrho,
139+
)
137140
thermo.run()
138141
return thermo
139142

@@ -168,6 +171,34 @@ def test_total_entropy_matches_ase(symbols, positions, freqs, dof, geometry, sig
168171
assert ts_total == pytest.approx(ase_total, abs=0.05)
169172

170173

174+
def test_quasi_rrho_matches_harmonic_for_high_frequencies():
175+
# water: all modes are high (>1500 cm^-1) -> weight ~ 1 -> qRRHO == harmonic
176+
args = (["O", "H", "H"],
177+
[[0, 0, 0.119], [0, 0.763, -0.477], [0, -0.763, -0.477]],
178+
[1595.0, 3657.0, 3756.0], 3)
179+
harmonic = _ts_thermo(*args).total_entropy("cal/(mol*K)")
180+
qrrho = _ts_thermo(*args, quasi_rrho=True).total_entropy("cal/(mol*K)")
181+
182+
assert qrrho == pytest.approx(harmonic, abs=0.05)
183+
184+
185+
def test_quasi_rrho_reduces_low_frequency_entropy():
186+
# a floppy molecule with very low modes: harmonic overestimates their entropy
187+
# (S_HO -> inf as nu -> 0), quasi-RRHO tames it towards the free rotor
188+
args = (["C", "C", "H", "H", "H", "H", "H", "H"],
189+
[[0, 0, 0], [1.5, 0, 0], [-0.4, 1.0, 0], [-0.4, -0.5, 0.87],
190+
[-0.4, -0.5, -0.87], [1.9, 1.0, 0], [1.9, -0.5, 0.87], [1.9, -0.5, -0.87]],
191+
[25.0, 40.0, 820.0, 995.0, 1206.0, 1388.0, 1469.0, 1479.0,
192+
2896.0, 2915.0, 2954.0, 2969.0, 2985.0, 2985.0, 1206.0, 1486.0, 995.0, 300.0],
193+
18)
194+
harmonic = _ts_thermo(*args).total_entropy("cal/(mol*K)")
195+
qrrho = _ts_thermo(*args, quasi_rrho=True).total_entropy("cal/(mol*K)")
196+
197+
# the low modes' spurious harmonic entropy is removed -> qRRHO is lower, finite
198+
assert qrrho < harmonic - 1.0
199+
assert np.isfinite(qrrho)
200+
201+
171202
def test_thermo_rejects_imaginary_vibrational_mode():
172203
# an imaginary (negative) mode in the kept dof set used to give NaN; it now
173204
# raises (the geometry is not a minimum)

0 commit comments

Comments
 (0)