Source code for rgpycrumbs.eon.tls_pairs

#!/usr/bin/env python3
"""Two-level-system pairs from an eOn aKMC run, with tunnelling splittings.

.. versionadded:: 1.11.0

A two-level system (TLS) in a glass is a pair of adjacent minima that the
system tunnels between at about one kelvin. aKMC already holds the pairs:
each row of a state's ``processtable`` is a reactant, a saddle and a product,
kept in ``procdata/``. For every pair this writes the asymmetry, the barrier,
the mass-weighted distance between the minima, and the one-dimensional WKB
tunnelling splitting with the TLS energy ``sqrt(delta**2 + delta0**2)``.

Where eOn has run an NEB on the pair, the splitting comes from that band: eOn
writes it into the first frame of ``neb.con`` (``tunnel_splitting``,
``hbar_omega_reactant``, ...), computed along the mass-weighted band. Without
a band, the pair gets a three-point estimate through reactant, saddle and
product, labelled ``three_point``: a screen, not a result.

Quantities carry units through pint; the CSV states them in its header.
"""

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "click",
#   "numpy",
#   "pint>=0.24",
#   "rich",
#   "ase",
#   "readcon>=0.14.9",
# ]
# ///

from __future__ import annotations

import csv
import logging
import math
import sys
from dataclasses import dataclass
from pathlib import Path

import click
import numpy as np
import pint
from readcon import read_con
from rich.logging import RichHandler

[docs] log = logging.getLogger("rich")
[docs] ureg = pint.UnitRegistry()
[docs] Q_ = ureg.Quantity
#: hbar in the band's native units, eV**0.5 amu**0.5 Angstrom.
[docs] HBAR = Q_(1, "hbar").to("(eV * dalton)**0.5 * angstrom").magnitude
#: Boltzmann constant in eV / K.
[docs] KB = Q_(1, "boltzmann_constant").to("eV / K").magnitude
#: Displacement past which an atom counts as moved, Angstrom.
[docs] MOVED_CUTOFF = 0.1
#: CSV columns, units in the names.
[docs] FIELDS = ( "state", "process", "delta_eV", "barrier_eV", "distance_mw_amu^0.5_A", "max_displacement_A", "moved_atoms", "delta0_eV", "tls_energy_eV", "tls_K", "path", )
#: The processtable columns eOn writes (eon/akmcstate.py). _SADDLE_ENERGY, _PRODUCT_ENERGY, _BARRIER = 1, 4, 6 @dataclass
[docs] class Pair: """One pair of minima and what it would be as a two-level system."""
[docs] state: str
[docs] process: int
[docs] delta: pint.Quantity
[docs] barrier: pint.Quantity
[docs] distance_mw: pint.Quantity
[docs] max_displacement: pint.Quantity
[docs] moved_atoms: int
[docs] delta0: pint.Quantity
[docs] tls_energy: pint.Quantity
[docs] path: str
@property
[docs] def tls_kelvin(self) -> pint.Quantity: return (self.tls_energy / Q_(KB, "eV/K")).to("K")
[docs] def row(self) -> dict[str, object]: return { "state": self.state, "process": self.process, "delta_eV": self.delta.to("eV").magnitude, "barrier_eV": self.barrier.to("eV").magnitude, "distance_mw_amu^0.5_A": self.distance_mw.to( "dalton**0.5 * angstrom" ).magnitude, "max_displacement_A": self.max_displacement.to("angstrom").magnitude, "moved_atoms": self.moved_atoms, "delta0_eV": self.delta0.to("eV").magnitude, "tls_energy_eV": self.tls_energy.to("eV").magnitude, "tls_K": self.tls_kelvin.magnitude, "path": self.path, }
[docs] def _atoms(con: Path): return read_con(str(con))[0].to_ase()
[docs] def _displacement(a, b) -> np.ndarray: """Minimum-image displacement from ``a`` to ``b`` under the cell of ``a``.""" dr = b.get_positions() - a.get_positions() cell = np.asarray(a.get_cell()) if not np.any(cell): return dr frac = dr @ np.linalg.inv(cell) frac = np.where(a.get_pbc(), frac - np.round(frac), frac) return frac @ cell
[docs] def _mass_weighted(dr: np.ndarray, masses: np.ndarray) -> float: if np.any(masses <= 0): msg = "every atom needs a positive mass for a mass-weighted distance" raise ValueError(msg) return float(np.sqrt(np.sum(masses[:, None] * dr**2)))
[docs] def three_point_splitting(d1: float, d2: float, barrier: float, delta: float) -> float: """WKB splitting of a double well through reactant, saddle and product. Each side is a cubic flat at both ends (``V (3 t**2 - 2 t**3)``), whose curvature at a minimum is ``6 V / d**2``. The level is the higher of the two harmonic ground states, the prefactor frequency the geometric mean, the same convention eOn's client uses along a band. Energies in eV, distances in amu**0.5 Angstrom. """ if d1 <= 0 or d2 <= 0 or barrier <= max(0.0, delta): msg = "the saddle has to lie between and above both minima" raise ValueError(msg) hw1 = HBAR * math.sqrt(6.0 * barrier / d1**2) hw2 = HBAR * math.sqrt(6.0 * (barrier - delta) / d2**2) level = max(0.5 * hw1, delta + 0.5 * hw2) t = np.linspace(0.0, 1.0, 2001) smooth = 3 * t**2 - 2 * t**3 s = np.concatenate([t * d1, d1 + t[1:] * d2]) v = np.concatenate([barrier * smooth, barrier + (delta - barrier) * smooth[1:]]) gap = np.sqrt(np.clip(2.0 * (v - level), 0.0, None)) action = float(np.trapezoid(gap, s)) / HBAR return math.sqrt(hw1 * hw2) / math.pi * math.exp(-action)
[docs] def _band_values(neb_con: Path) -> dict[str, float] | None: """The tunnelling values eOn wrote on a band's first frame, if any.""" md = read_con(str(neb_con))[0].metadata return md if "tunnel_splitting" in md else None
[docs] def pairs_of_state(state_dir: Path, neb_root: Path | None) -> list[Pair]: table = state_dir / "processtable" proc = state_dir / "procdata" out: list[Pair] = [] for line in table.read_text().splitlines()[1:]: cols = line.split() if len(cols) < 9: continue pid = int(cols[0]) e_saddle = float(cols[_SADDLE_ENERGY]) barrier = float(cols[_BARRIER]) e_reactant = e_saddle - barrier delta = float(cols[_PRODUCT_ENERGY]) - e_reactant try: reactant = _atoms(proc / f"reactant_{pid}.con") saddle = _atoms(proc / f"saddle_{pid}.con") product = _atoms(proc / f"product_{pid}.con") except (OSError, IndexError) as exc: log.warning("%s process %d: %s", state_dir.name, pid, exc) continue masses = reactant.get_masses() dr = _displacement(reactant, product) norms = np.linalg.norm(dr, axis=1) band = neb_root / state_dir.name / str(pid) / "neb.con" if neb_root else None values = _band_values(band) if band and band.is_file() else None if values is not None: delta0 = values["tunnel_splitting"] path = "band" else: try: delta0 = three_point_splitting( _mass_weighted(_displacement(reactant, saddle), masses), _mass_weighted(_displacement(saddle, product), masses), barrier, delta, ) except ValueError as exc: log.warning("%s process %d: %s", state_dir.name, pid, exc) continue path = "three_point" out.append( Pair( state=state_dir.name, process=pid, delta=Q_(delta, "eV"), barrier=Q_(barrier, "eV"), distance_mw=Q_(_mass_weighted(dr, masses), "dalton**0.5 * angstrom"), max_displacement=Q_(float(norms.max()), "angstrom"), moved_atoms=int(np.count_nonzero(norms > MOVED_CUTOFF)), delta0=Q_(delta0, "eV"), tls_energy=Q_(math.hypot(delta, delta0), "eV"), path=path, ) ) return out
[docs] def _state_dirs(akmc_dir: Path) -> list[Path]: states = akmc_dir / "states" if (akmc_dir / "states").is_dir() else akmc_dir if (states / "processtable").is_file(): return [states] dirs = [p for p in states.iterdir() if (p / "processtable").is_file()] return sorted(dirs, key=lambda p: (len(p.name), p.name))
@click.command() @click.argument("akmc_dir", type=click.Path(exists=True, file_okay=False, path_type=Path)) @click.option( "-o", "--output", type=click.Path(path_type=Path), help="CSV (default stdout)." ) @click.option( "--neb-root", type=click.Path(exists=True, file_okay=False, path_type=Path), help="NEB jobs laid out as NEB_ROOT/<state>/<process>/neb.con; their " "band splittings replace the three-point estimate.", ) @click.option("--max-asymmetry", type=float, help="Keep |delta| <= this, eV.") @click.option("--max-barrier", type=float, help="Keep barriers <= this, eV.") @click.option("--max-kelvin", type=float, help="Keep TLS energies <= this, K.")
[docs] def main(akmc_dir, *, output, neb_root, max_asymmetry, max_barrier, max_kelvin): """Write the TLS pairs an aKMC run found, with WKB tunnelling splittings.""" logging.basicConfig(level="INFO", handlers=[RichHandler()], format="%(message)s") pairs = [p for s in _state_dirs(akmc_dir) for p in pairs_of_state(s, neb_root)] kept = [ p for p in pairs if (max_asymmetry is None or abs(p.delta.magnitude) <= max_asymmetry) and (max_barrier is None or p.barrier.magnitude <= max_barrier) and (max_kelvin is None or p.tls_kelvin.magnitude <= max_kelvin) ] rows = [p.row() for p in kept] fields = list(FIELDS) handle = open(output, "w", newline="") if output else sys.stdout try: writer = csv.DictWriter(handle, fieldnames=fields) writer.writeheader() writer.writerows(rows) finally: if output: handle.close() log.info("%d of %d pairs kept", len(kept), len(pairs))
if __name__ == "__main__": main()