Source code for ThermoScreening.calculator.qm

"""Import QM outputs (Gaussian, Turbomole, ORCA, Psi4, NWChem, ...) via cclib.

`cclib <https://cclib.github.io/>`_ auto-detects the program that produced an
output file and exposes a uniform data model, so a single reader consumes a
frequency calculation from any of the programs it supports. cclib is an optional
dependency; install it with ``pip install thermoscreening[qm]``.
"""

import numpy as np
from ase import Atoms

from ..exceptions import TSValueError

# 1 Hartree in eV (cclib 1.x reports energies in eV; the qm extra pins < 2.0).
_EV_PER_HARTREE = 27.211386245988


def _import_cclib():
    """Import cclib, or raise a helpful error if the optional dep is missing."""
    try:
        import cclib  # noqa: F401
        import cclib.io
    except ImportError as exc:  # pragma: no cover - exercised via monkeypatch
        raise TSValueError(
            "cclib is required to import QM outputs; install it with "
            "'pip install thermoscreening[qm]'."
        ) from exc
    return cclib


def _best_energy_ev(data):
    """
    The highest-level electronic energy available (eV), or ``None``.

    Prefers coupled-cluster, then Moller-Plesset, then SCF -- matching the level
    the Hessian was most likely computed at.
    """
    ccenergies = getattr(data, "ccenergies", None)
    if ccenergies is not None and len(ccenergies):
        return float(ccenergies[-1])
    mpenergies = getattr(data, "mpenergies", None)
    if mpenergies is not None and len(mpenergies):
        return float(mpenergies[-1][-1])  # last step, highest MP order
    scfenergies = getattr(data, "scfenergies", None)
    if scfenergies is not None and len(scfenergies):
        return float(scfenergies[-1])
    return None


def _normalize_source(path):
    """
    Coerce ``path`` into what ``cclib.io.ccread`` expects: a single path string,
    or a list of path strings for a multi-file program.
    """
    if isinstance(path, (list, tuple)):
        return [str(p) for p in path]
    return str(path)


def _describe_source(path):
    """A short, readable description of ``path`` for error messages."""
    if isinstance(path, (list, tuple)):
        return ", ".join(str(p) for p in path)
    return str(path)


[docs] def read_cclib(path): """ Read geometry, vibrational frequencies and energy from a QM output file. Uses cclib to parse any supported program's output (Gaussian, ORCA, Psi4, NWChem, ...). Most programs write one logfile per job; pass its path. **Turbomole** splits a job's output across many small files instead of one logfile (``control``, ``coord``, ``aoforce.out``, ...); pass a list of every relevant file's path (cclib's own multi-file mode) rather than a single path. Parameters ---------- path : str or list of str Path to a QM frequency-calculation output file, or (for a multi-file program such as Turbomole) a list of paths to every relevant file from the same job. Returns ------- atoms : ase.Atoms The final geometry (cclib reports coordinates in Angstrom). frequencies : np.ndarray The vibrational frequencies in cm^-1 (cclib gives the real vibrational modes; imaginary modes appear as negatives). energy : float or None The electronic energy in Hartree (converted from cclib's eV), or ``None`` if the output has no parseable energy. Notes ----- cclib returns only the real vibrational modes (3N-6, or 3N-5 for a linear molecule). The thermochemistry keeps the top ``dof`` modes for the geometry's own linearity classification, so this matches for clearly (non)linear molecules; a near-linear geometry where the classification disagrees with the QM program's mode count could drop or miss a mode. Raises ------ TSValueError If cclib is not installed, the file(s) cannot be parsed (including a parser crash on an unusual output format, wrapped from cclib), or there are no vibrational frequencies. """ cclib = _import_cclib() try: data = cclib.io.ccread(_normalize_source(path)) except Exception as exc: # cclib's parsers are format/version-sensitive and can raise on an # output format they don't fully handle (seen on real Gaussian 16 logs # with cclib 1.8.1); surface that as our own exception type instead of # letting an arbitrary cclib-internal exception escape uncontrolled. raise TSValueError( f"cclib raised {type(exc).__name__} while parsing " f"'{_describe_source(path)}': {exc}" ) from exc if data is None: raise TSValueError(f"cclib could not parse '{_describe_source(path)}' as a QM output.") if getattr(data, "vibfreqs", None) is None or not len(data.vibfreqs): raise TSValueError( f"'{_describe_source(path)}' has no vibrational frequencies " "(run a frequency calculation)." ) atoms = Atoms(numbers=np.asarray(data.atomnos), positions=np.asarray(data.atomcoords[-1])) frequencies = np.asarray(data.vibfreqs, dtype=float) energy_ev = _best_energy_ev(data) energy = energy_ev / _EV_PER_HARTREE if energy_ev is not None else None return atoms, frequencies, energy