Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ cover/

# Django stuff:
*.log
# ... except real QM-program logfiles kept as test fixtures.
!tests/data/**/*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
Expand Down
17 changes: 14 additions & 3 deletions ThermoScreening/calculator/qm.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,23 @@ def read_cclib(path):
Raises
------
TSValueError
If cclib is not installed, the file(s) cannot be parsed, or there are no
vibrational frequencies.
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()

data = cclib.io.ccread(_normalize_source(path))
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):
Expand Down
47 changes: 47 additions & 0 deletions tests/calculator/test_qm.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
if p.suffix != ".md"
)

_GAUSSIAN_DATA_DIR = Path(__file__).resolve().parents[1] / "data" / "calculator" / "gaussian"
_REAL_GAUSSIAN_WATER = str(_GAUSSIAN_DATA_DIR / "water_neutral_opt_freq.out")
_REAL_GAUSSIAN_UNPARSEABLE = str(_GAUSSIAN_DATA_DIR / "mp2_avdz_freq_tight.log")

# water: geometry (Angstrom), three real modes, SCF energy in eV (~ -76.4 Ha)
_WATER = dict(
atomnos=np.array([8, 1, 1]),
Expand Down Expand Up @@ -187,3 +191,46 @@ def test_cclib_thermo_real_turbomole_output():
assert thermo.electronic_energy() == pytest.approx(-951.0820621320888)
assert math.isfinite(thermo.total_EeGtot())
assert thermo.total_entropy("cal/(mol*K)") > 0


# --- real Gaussian 16 output --- #
#
# See tests/data/calculator/gaussian/README.md for provenance.


def test_read_cclib_real_gaussian_output():
atoms, freqs, energy = read_cclib(_REAL_GAUSSIAN_WATER)

assert list(atoms.get_chemical_symbols()) == ["O", "H", "H"]
assert list(freqs) == pytest.approx([2169.7613, 4141.3837, 4392.5759])
assert energy == pytest.approx(-74.96589788571758)


def test_cclib_thermo_real_gaussian_output():
thermo = cclib_thermo(_REAL_GAUSSIAN_WATER)

assert thermo.electronic_energy() == pytest.approx(-74.96589788571758)
# gas-phase water's experimental standard entropy is ~45 cal/(mol K)
assert thermo.total_entropy("cal/(mol*K)") == pytest.approx(45.0, abs=2.0)
assert math.isfinite(thermo.total_EeGtot())


def test_read_cclib_wraps_a_real_cclib_parser_crash():
# this real Gaussian 16 file's "Leave Link" timing line crashes cclib
# 1.8.1's own Gaussian parser with an unhandled ValueError (confirmed:
# still the latest cclib release at the time this test was written) --
# that must not leak out of read_cclib as a raw, arbitrary exception
with pytest.raises(TSValueError, match="cclib raised"):
read_cclib(_REAL_GAUSSIAN_UNPARSEABLE)


def test_read_cclib_wraps_any_ccread_exception(monkeypatch):
# a cclib-version-independent guard for the same behaviour: whatever
# cclib.io.ccread raises internally must come out as a clean TSValueError,
# not the original exception type
def fake_ccread(source):
raise ValueError("some internal cclib parsing failure")

monkeypatch.setattr(cclib.io, "ccread", fake_ccread)
with pytest.raises(TSValueError, match="cclib raised ValueError.*some internal"):
read_cclib("whatever.log")
18 changes: 18 additions & 0 deletions tests/data/calculator/gaussian/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Real Gaussian 16 log files, used to test `read_cclib`/`cclib_thermo` against
genuine (non-mocked) Gaussian output.

- `water_neutral_opt_freq.out` — a clean neutral water optimization + frequency
job. Parses successfully; used as the real-data success-path test.
- `mp2_avdz_freq_tight.log` — an MP2/aug-cc-pVDZ frequency job whose "Leave
Link" timing line (`MaxMem=... cpu: ... elap: ...`) crashes cclib 1.8.1's
Gaussian parser (confirmed: still the latest cclib release at the time).
Root cause is an unhandled `ValueError` inside cclib's own parser; what
actually reaches `read_cclib` depends on the surrounding logging setup (a
bare `cclib.io.ccread` call raises that `ValueError` directly, but cclib's
own `logger.error()` call right before it can itself raise first under some
logging configurations -- either way, `read_cclib` wraps whatever comes out
into a clean `TSValueError`). Used as the regression test for that
wrapping, so no cclib parsing exception escapes uncontrolled.

Source: [cclib/cclib-data](https://github.com/cclib/cclib-data), the cclib
project's own public regression-test data, `Gaussian/Gaussian16/`.
Loading
Loading