Source code for thermochemistry_step.cbh_approach

# -*- coding: utf-8 -*-

"""The isodesmic (CBH) approach with ATcT references.

The enthalpy of formation of the target molecule is obtained from an
error-cancelling reaction of the connectivity-based hierarchy (CBH), whose
other species have reference enthalpies of formation in the Active
Thermochemical Tables (ATcT):

    ΔfH(target, T) = Σ_j ν_j ΔfH_j(ATcT, T) - Δ_rH(T)

with Δ_rH(T) = Σ_i ν_i H_i(T) computed with the subflowchart's engine for
every species (the target has ν = -1). The reaction, the ATcT lookups, the
covariance-propagated 95 % uncertainty and the combination are done here; the
science lives in ``seamm_thermochemistry`` (``atct`` and ``cbh``).

The energies H_i(T) come from one of three places:

* "finite-difference harmonic": the subflowchart is run on each species, first
  to optimize it (ASE BFGS) and then for the finite-difference Hessian; the
  rigid-rotor harmonic-oscillator thermochemistry of the Harmonic approach
  gives H(0 K) = E + ZPE and H(298.15 K);
* "finite-difference harmonic" with "thermal corrections" = none: the
  subflowchart's energy already is the enthalpy of formation at 298.15 K, as
  for MOPAC's NDDO methods, so only Δ_rH(298.15 K) is formed;
* "existing results (run nothing)": the species' energies are read from
  properties already stored on the systems named ``CBH:<SMILES>`` (and the
  target), e.g. by an earlier parallel Loop, so the expensive part can be
  run in parallel and at two levels of theory.

Results are reported at 0 K and 298.15 K and paired with the ATcT values at
the same temperature, never converted between them. ATcT's plain gas-phase
entries are the lowest conformer at 0 K and a conformer average at 298.15 K,
so the 0 K comparison is the cleaner one for a single-conformer calculation.
"""

from collections import Counter
import json
import logging
import math
import os
from pathlib import Path

import numpy as np
from tabulate import tabulate

from seamm_util import Q_, parse_list
import seamm_util.printing as printing
from seamm_util.printing import FormattedText as __

logger = logging.getLogger(__name__)
printer = printing.getPrinter("Thermochemistry")

T_REF = 298.15

#: The presets of the CBH approach (thermo's defaults, adopted 2026-11-08): the
#: composite recipe (composite_step), and its geometry/frequency level and energy
#: level (None: the geometry level). With composite_step installed the levels come
#: from the recipe; these spellings are the fallback without it, and a test keeps
#: them equal to the recipes'.
PRESETS = {
    "quick": {
        "recipe": "SEAMM:CMP@r2SCAN-3c-HO",
        "geometry": "ORCA:CMP@R2SCAN-3C",
        "energy": None,
    },
    "default": {
        "recipe": "SEAMM:CMP@revDSD-r2SCAN-3c-HO",
        "geometry": "ORCA:CMP@R2SCAN-3C",
        "energy": "ORCA:DFT@REVDSD-PBEP86-D4_2021/def2-TZVPP",
    },
    # The energy level is chosen per target (high_level.py): canonical or DLPNO
    # CCSD(T)-F12, the same for every species of the reaction
    "high": {
        "recipe": None,
        "geometry": "ORCA:CMP@R2SCAN-3C",
        "energy": None,  # replaced by HIGH_ENERGY below
    },
}

#: The 'high' preset's energy level, chosen when the target is known
HIGH_ENERGY = (
    "CCSD(T)-F12/cc-pVTZ-F12 (canonical, or DLPNO for a large saturated "
    "closed-shell target)"
)
PRESETS["high"]["energy"] = HIGH_ENERGY
#: The default largest canonical CCSD(T)-F12 of the 'high' preset (96 GB nodes)
CANONICAL_MAX_BF_DEFAULT = 546

#: The owner and type of SEAMM's composite recipes as model chemistries.
COMPOSITE_PREFIX = "SEAMM:CMP@"


[docs] def is_composite(level): """Whether a model chemistry is one of SEAMM's composite recipes.""" return str(level or "").startswith(COMPOSITE_PREFIX)
[docs] def recipe_levels(level, data_path=None): """The levels of a composite recipe ``SEAMM:CMP@<recipe>`` (composite_step). Returns ------- dict ``recipe`` (the level spec), ``name``, ``version``, ``fingerprint``, ``geometry`` (the geometry/frequency level), ``energy`` (the energy level: None when E0 is the geometry level's energy, the component's level when it is one other energy, or the composite itself, ``recipe``, when it combines several), ``components`` ({name: level}), ``zpe`` ((factor, source)) and ``object`` (the composite_step Recipe). The step runs ordinary levels, so the species' cached properties, the fragment store's rows and the ZPE table are those of the component levels; the recipe's name never keys anything. """ try: from composite_step.evaluate import component_levels, single_source from composite_step.recipes import RecipeLibrary except ImportError: raise RuntimeError( f"The composite method {level} needs the Composite step " "(composite-step) installed." ) name = str(level)[len(COMPOSITE_PREFIX) :] extra = [Path(p) / "recipes" for p in (data_path or ())] library = RecipeLibrary(extra_directories=extra) try: recipe = library.get(name) except KeyError as e: raise RuntimeError(f"No composite recipe for {level}: {e}") source = single_source(recipe) levels = component_levels(recipe) if source is None: # Several calculations: the composite itself is the energy level energy = recipe.model_chemistry elif source == "geometry": energy = None else: energy = levels[source] return { "recipe": recipe.model_chemistry, "name": recipe.name, "version": recipe.version, "fingerprint": recipe.fingerprint(), "geometry": recipe.geometry["level"], "energy": energy, "components": levels, "zpe": recipe.zpe_scale, "object": recipe, }
_PROTECT = { "default": "default", "none": "none", "aromatic rings": ("aromatic",), "aromatic + small rings": ("aromatic", "small rings"), "aromatic, small rings and charged groups": "default", "all rings": ("all rings", "groups"), }
[docs] class CBHApproach: """Mixin for Thermochemistry: the CBH approach."""
[docs] def describe_cbh(self, P): """The description of the CBH approach for the step's summary.""" if species_only(P): preset = P.get("preset", "custom") if preset in PRESETS: levels = PRESETS[preset] how = ( f"with the '{preset}' preset: the geometry and frequencies with " f"{levels['geometry']}, the energy with " f"{levels['energy'] or levels['geometry']}" ) else: how = "with the flowchart's Model Chemistry" return ( "Compute only the system itself as a CBH species, for the fragment " f"store, {how}: optimized, its harmonic frequencies and its energies " "stored with the settings fingerprints the store's harvest reads. " "No CBH reactions are run and ATcT is not used." ) text = ( "Calculate the enthalpy of formation of the system with the isodesmic " "reactions of the connectivity-based hierarchy (CBH), using the Active " "Thermochemical Tables (ATcT) for the reference species." ) rungs = P["cbh rungs"] if rungs == "all usable": text += ( " Every rung from CBH-0 to CBH-4 whose reference species are all in " "ATcT will be used and the results compared." ) else: text += f" The rungs {rungs} will be used where possible." protect = P["protect"] if protect == "none": text += " No groups will be kept whole (plain Kekulé CBH)." else: text += f" Groups kept whole: {protect}." mode = P["cbh energies"] if mode == "existing results (run nothing)": text += ( " The energies of the species will be read from the properties " "already stored on the systems (nothing will be run)." ) else: if P["thermal corrections"] == "harmonic": text += ( " The subflowchart will be run on the target and on each " "fragment, optimizing the structure if requested, and the " "finite-difference harmonic thermochemistry will give H(0 K) " "and H(298.15 K)." ) else: text += ( " The subflowchart's energy will be taken as the enthalpy at " "298.15 K (as for MOPAC), with no thermal corrections." ) if _yes(P["compare with ATcT"]): text += " If the target is itself in ATcT the result will be compared." return text
# -----------------------------------------------------------------------
[docs] def run_cbh(self, _P): """Run the CBH approach. Returns the data dictionary stored as results.""" from seamm_thermochemistry import ( ATcT, AmbiguousEntry, MissingEntry, cbh, reaction_uncertainty, ) from seamm_thermochemistry.atct import canonical_smiles from .resonance import lookup_target _P = self.apply_preset(_P) self._reset_fingerprints() self._set_frequency_correction(_P) preset = _P.get("preset", "custom") system_db = self.get_variable("_system_db") target_system, target_configuration = self.get_system_configuration() directory = self._species_directory() if directory is not None and (directory / "species.sdf").exists(): (directory / "species.sdf").unlink() n_atoms = target_configuration.n_atoms if target_configuration.bonds.n_bonds == 0 and n_atoms > 1: raise RuntimeError( "The CBH approach needs the bonds of the molecule: build it from " "SMILES, or read a file with bonds (SDF, MOL)." ) charge = target_configuration.charge multiplicity = target_configuration.spin_multiplicity or 1 smiles = self._target_smiles(target_configuration, charge, multiplicity) target = canonical_smiles(smiles) self._high_choice = None self._high_max_bf = _P.get( "maximum basis functions for canonical F12", CANONICAL_MAX_BF_DEFAULT ) self._high_target = ( smiles, multiplicity, list(target_configuration.atoms.atomic_numbers), ) if species_only(_P): return self._run_species_only( _P, smiles, target, target_system, target_configuration ) text = "" if preset in PRESETS: levels = PRESETS[preset] text += ( f"The '{preset}' preset ({levels['recipe']}): geometries and " "frequencies with " f"{levels['geometry']}, energies with " f"{levels['energy'] or levels['geometry']}, the highest CBH rung " f"available up to CBH-{max(self._preset_rungs(_P))}.\n" ) text += f"The target is {smiles}" if charge != 0 or multiplicity != 1: text += f" with charge {charge} and multiplicity {multiplicity}" text += "." # The structure's multiplicity is used; say so where the SMILES shows more # unpaired electrons, and where the ground state differs (a singlet carbene # built as a triplet, or O2 as a singlet) _, _, radicals, _ = cbh._composition(smiles) ground = cbh_multiplicity(smiles) if radicals > multiplicity - 1: text += ( f" Its SMILES shows {radicals} unpaired electrons, but the " f"structure's spin multiplicity, {multiplicity}, is used" ) if ground == multiplicity: text += f", which is the ground state of {target}." else: text += ( f"; check it: the ground state of {target} is taken to be " f"{ground}." ) elif ground != multiplicity: text += ( f" Its spin multiplicity, {multiplicity}, is the structure's; the " f"ground state of {target} is {ground}." ) text += "\n" # The rungs rungs = _P["cbh rungs"] if rungs in ("all usable", "default", ""): rungs = [0, 1, 2, 3, 4] else: rungs = sorted({int(x) for x in parse_list(str(rungs))}) protect = _PROTECT.get(_P["protect"], _P["protect"]) reactions = {} unavailable = {} for rung in rungs: try: reactions[rung] = cbh.cbh_reactions( smiles, rungs=[rung], protect=protect )[rung] except cbh.RungUnavailable as e: unavailable[rung] = str(e) for rung, reaction in reactions.items(): if not reaction.trivial and not reaction.balanced: raise RuntimeError( f"The CBH-{rung} reaction does not balance: " + "; ".join(reaction.balance_errors) + f"\n{reaction}" ) protected = next(iter(reactions.values())).protected if reactions else [] if protected: text += "Groups kept whole: " + "; ".join(protected) + ".\n" # ATcT base = _P["ATcT directory"] base = None if base in ("default", "") else os.path.expanduser(base) version = _P["ATcT version"] version = None if version in ("latest", "") else version downloaded = self._ensure_atct(base, version) tables = ATcT(version=version, base=base) if downloaded: text += ( f"No ATcT data were installed, so ver. {tables.version} was " "downloaded from atct.anl.gov.\n" ) text += ( f"Reference values from ATcT ver. {tables.version} of the " f"Thermochemical Network ({tables.directory}).\n" ) def lookup(can): _, q, _, _ = cbh._composition(can) return tables.lookup(can, charge=q, multiplicity=cbh_multiplicity(can)) # The reference policy: ATcT, a ladder, or ATcT below a threshold policy = _P.get("reference values", "ATcT") ladder = None threshold = None if policy != "ATcT": from seamm_thermochemistry import Ladder name = str(_P.get("ladder", "")).strip() if not name: raise RuntimeError( "The reference values are to be laddered but no ladder is " "named; build one with seamm-thermochemistry-ladder." ) store_file = _P.get("fragment store file", "default") store_file = ( None if store_file in ("default", "") else os.path.expanduser(store_file) ) ladder = Ladder.load(name, store_file) if ladder.atct_version != tables.version: raise RuntimeError( f"The ladder '{name}' is on ATcT ver. {ladder.atct_version} but " f"the step uses ver. {tables.version}: one version per reaction." ) text += ( f"Reference values: {policy}, ladder '{name}' (energies " f"{ladder.data['energy_level']}, frequencies " f"{ladder.data['frequency_level']}, ZPE x {ladder.data['zpe_scale']})" ) mu = ladder.method_uncertainty if mu.get("n"): text += ( f"; its method term, from {mu['n']} ladder species in ATcT, is " f"{mu['rms_0K']:.2f} kJ/mol RMS at 0 K (an upper bound)" ) text += ".\n" if policy.startswith("ATcT below"): threshold = _P["ATcT uncertainty threshold"] if hasattr(threshold, "m_as"): threshold = threshold.m_as("kJ/mol") threshold = float(threshold) # The supplementary reference library, for species ATcT lacks # (only with ATcT as the reference values, not with a ladder) resolver = None library = {} supplementary = _P.get("supplementary references", "none") use_user = _yes(_P.get("use my own references", "no")) if supplementary == "none": use_user = False if policy == "ATcT" and (supplementary != "none" or use_user): from seamm_thermochemistry.atct import atct_directory from seamm_thermochemistry.references import LayeredReferences from seamm_thermochemistry.resolver import ReferenceResolver # The libraries sit beside the ATcT data the step uses where = atct_directory(base).parent resolver = ReferenceResolver( tables, supplementary=supplementary, use_user=use_user, multiplicity=cbh_multiplicity, layers=LayeredReferences( where / "references.db", where / "references-official.db" ), ) if not resolver.layers.layers: text += ( "No supplementary reference library is installed, so only " "ATcT is used.\n" ) resolver = None else: wanted = sorted( { can for reaction in reactions.values() if not reaction.trivial for can in reaction.references } ) library, _, _, library_notes = resolver.resolve(wanted) library = { k: v for k, v in library.items() if v.get("source") != "ATcT" } versions = resolver.layers.versions text += ( "Species ATcT lacks are taken from the reference library (" + ", ".join( f"{layer} {v or 'unversioned'}" for layer, (v, _) in versions.items() ) + f"; {supplementary}" + ("; with your own references" if use_user else "") + ").\n" ) for note in library_notes: text += note + "\n" def reference(can): """The reference entry under the policy: an ATcT entry (with atct_coefficients {id: 1}), the ladder's entry, or the reference library's (with no ATcT coefficients).""" atct_entry = None atct_error = None try: atct_entry = lookup(can) except (MissingEntry, AmbiguousEntry) as e: atct_error = e if atct_entry is not None: atct_entry = dict(atct_entry) atct_entry["atct_coefficients"] = {atct_entry["id"]: 1.0} atct_entry["source"] = "ATcT" if ladder is None: if atct_entry is None: if can in library: return library[can] raise atct_error return atct_entry laddered = ladder.entry(can) if laddered is not None and laddered["source"] == "ATcT": laddered = None # a CBH-0 anchor: the ATcT value itself if policy == "laddered": if laddered is not None: return laddered if atct_entry is not None and _own_rung_is_zero(can): return atct_entry raise MissingEntry( f"'{can}' is not in the ladder '{ladder.name}'" + ( f" (ATcT has it: {atct_entry['id']})" if atct_entry is not None else "" ) ) # ATcT below the threshold, else laddered if atct_entry is not None and ( atct_entry["exact"] or (atct_entry["uncertainty_298K"] or 0.0) <= threshold ): return atct_entry if laddered is not None: return laddered if atct_entry is not None: return atct_entry raise atct_error def _own_rung_is_zero(can): try: return cbh.cbh_reactions(can, rungs=[0], protect=protect)[0].trivial except cbh.CBHError: return True # H2 entries = {} usable = [] missing = {} ambiguous = {} for rung, reaction in sorted(reactions.items()): if reaction.trivial: continue absent = [] for can in reaction.references: if can in entries: continue try: entries[can] = reference(can) except MissingEntry as e: if ladder is not None: ambiguous[can] = str(e) absent.append(can) except AmbiguousEntry as e: ambiguous[can] = str(e) absent.append(can) if absent: missing[rung] = absent else: usable.append(rung) target_entry = None target_via = None if _yes(_P["compare with ATcT"]): target_entry, target_via = lookup_target( tables, target, charge, multiplicity ) # A target that ATcT lacks but the reference library has: its library # value is reported, and checked against the CBH value when a rung works library_target = None if resolver is not None: check_atct = target_entry if check_atct is None and not _yes(_P["compare with ATcT"]): check_atct, _ = lookup_target(tables, target, charge, multiplicity) if check_atct is None: own, _, _, _ = resolver.resolve([target]) if target in own and own[target].get("source") != "ATcT": library_target = own[target] # Report the reactions lines = [] for rung in rungs: if rung in unavailable: lines.append(f"CBH-{rung}: not available. {unavailable[rung]}") elif reactions[rung].trivial: lines.append(f"CBH-{rung}: trivial, the rung reproduces the target.") elif rung in missing: lines.append( f"{reactions[rung]}\n not usable: no " + ("reference value" if resolver is not None else "ATcT entry") + " for " + ", ".join(missing[rung]) ) else: lines.append(str(reactions[rung])) text += "\nThe reactions (the target has coefficient -1):\n " text += "\n ".join(lines) + "\n" for can, message in ambiguous.items(): text += f"\n{message}\n" printer.important(__(text, indent=4 * " ", wrap=False, dedent=False)) if not usable: # A target that is itself a reference species (methane, benzene, the # methyl radical, CO): its rungs are trivial or need other species # that ATcT lacks, but its own value is the answer if target_entry is None: target_entry, target_via = lookup_target( tables, target, charge, multiplicity ) if target_entry is not None: return self._reference_species_result( target, target_entry, tables, via=target_via ) if library_target is not None: return self._library_species_result( target, library_target, tables, resolver ) if resolver is not None: raise RuntimeError( "No CBH rung has all its reference species in ATcT " f"(ver. {tables.version}) or the reference library; see the " "reactions above. Supply the missing ones as your own " "references ('seamm-thermochemistry-references', with 'Use my " "own references'), or compute them." ) raise RuntimeError( "No CBH rung has all its reference species in ATcT " f"(ver. {tables.version}); see the reactions above." ) # The species to compute: the target and the unique fragments of the # usable rungs, each once fragments = cbh.unique_fragments([reactions[r] for r in usable]) mode = _P["cbh energies"] # The species are optimized over the warm engine, so the CBH approach # drives the Model Chemistry as an engine unless told otherwise route = _P.get("hessian route", "automatic") or "automatic" if route == "automatic": route = "warm engine over MDI" self._hessian_route = route if mode != "existing results (run nothing)": text = f"The Model Chemistry is driven as: {route}.\n" printer.important(__(text, indent=4 * " ", wrap=False, dedent=False)) energies = {} sources = {} configurations = {target: target_configuration} store = self._fragment_store(_P) energy_mc = self._energy_level(_P) if energy_mc is not None: if mode == "existing results (run nothing)": raise RuntimeError( "Energies at another level than the geometries need the species " "computed: choose 'finite-difference harmonic' for the species' " "energies, or leave 'Energies at' as the geometry level." ) if _P["thermal corrections"] != "harmonic": raise RuntimeError( "Energies at another level than the geometries need harmonic " "thermal corrections at the geometry level." ) printer.important( __( f"The energies are at {energy_mc['level']}, on the geometries " f"and with the frequencies of {self.model}.\n", indent=4 * " ", wrap=False, dedent=False, ) ) if mode == "existing results (run nothing)": energies[target] = self._energies_from_properties( _P, target_configuration, target ) sources[target] = "properties" for can in fragments: configuration = self._find_fragment(system_db, can) if configuration is None: stored = self._energies_from_store(_P, store, can) if stored is None: raise RuntimeError( f"No system named 'CBH:{can}' with results for the " f"fragment {can}, and nothing in the fragment store; " "run the fragments first or use another mode." ) energies[can], sources[can] = stored else: energies[can] = self._energies_from_properties( _P, configuration, can ) sources[can] = "properties" else: embed = str(_P.get("target conformer", "as given")).startswith("lowest") reused = self._target_from_store( _P, store, target, smiles, target_configuration, embed ) if reused is not None: energies[target], sources[target] = reused else: energies[target] = self._compute_species( _P, target, smiles, target_system, target_configuration, embed=embed, ) sources[target] = "computed" for can, generated in fragments.items(): # Already computed in this job (e.g. by an earlier iteration of a # Loop), with the fingerprints expected for it? in_job = self._from_job(_P, system_db, can, energy_mc is not None) if in_job is not None: energies[can], configurations[can] = in_job sources[can] = "job" printer.important( __( f"\nSpecies {can}: already computed in this job.", indent=4 * " ", wrap=False, dedent=False, ) ) continue if energy_mc is not None: found = self._geometry_from_store(store, can) if found is not None: energies[can], geometry, sources[can] = found configurations[can] = self._configuration_from_geometry( system_db, can, geometry ) printer.important( __( f"\nSpecies {can}: geometry and frequencies from the " f"fragment store ({sources[can]}).", indent=4 * " ", wrap=False, dedent=False, ) ) continue energies[can] = self._compute_species(_P, can, generated) sources[can] = "computed" configurations[can] = self._find_fragment(system_db, can) continue stored = self._energies_from_store(_P, store, can) if stored is not None: energies[can], sources[can] = stored how = "unverified" if "unverified" in sources[can] else "verified" printer.important( __( f"\nSpecies {can}: from the fragment store ({how}).", indent=4 * " ", wrap=False, dedent=False, ) ) continue energies[can] = self._compute_species(_P, can, generated) sources[can] = "computed" # The energy level's single points on the geometries if energy_mc is not None: self._energy_level_energies( energy_mc, store, target, [target] + list(fragments), configurations, energies, sources, ) self._report_store_notes(store) # Add what was computed here to the fragment store (write-back) computed = {} for can in [target] + list(fragments): if "computed" not in sources.get(can, ""): continue if not self._storable(can, energies.get(can)): continue configuration = configurations.get(can) if configuration is None: configuration = self._find_fragment(system_db, can) computed[can] = configuration self._write_back(_P, computed) # Back to the target system_db.system = target_system target_system.configuration = target_configuration self._working_configuration = target_configuration # The ZPE scale factor zpe_scale, zpe_source, zpe_flag = self._zpe_scale(_P) if zpe_scale != 1.0 or zpe_flag: text = f"The ZPEs are scaled by {zpe_scale} ({zpe_source})" text += f"; note: {zpe_flag}." if zpe_flag else "." printer.important(__(text, indent=4 * " ", wrap=True, dedent=False)) if zpe_scale != 1.0: for e in energies.values(): if "ZPE" not in e: continue shift = (zpe_scale - 1.0) * e["ZPE"] e["ZPE scaled"] = zpe_scale * e["ZPE"] if "H0" in e: e["H0"] += shift if "H298" in e: e["H298"] += shift # Combine data = {} rows = [] table = self.get_table("CBH") for column, coltype in ( ("rung", "integer"), ("reaction", "string"), ("DfH(0 K)", "float"), ("DfH(298.15 K)", "float"), ("uncertainty (95%)", "float"), ("uncertainty method", "string"), ("DrH(0 K)", "float"), ("DrH(298.15 K)", "float"), ("ATcT DfH(0 K)", "float"), ("ATcT DfH(298.15 K)", "float"), ("ATcT uncertainty (95%)", "float"), ("ATcT version", "string"), ): if column not in table.columns: table.add_column(column, coltype) # A library value given at one temperature only is converted to the # other with this run's own H298 - H0 of the species (science: never a # cached or other-level value), and says so converted = {} def ref_value(can, column): entry = entries[can] value = entry.get(column) if value is not None: return value other = entry.get("dfH0_298K" if column == "dfH0_0K" else "dfH0_0K") e = energies.get(can, {}) if other is None or "H298" not in e or "H0" not in e: return None if not entry.get("formula"): return None from seamm_thermochemistry.resolver import dfh_0k_from_298k thermal = e["H298"] - e["H0"] elements = dfh_0k_from_298k(0.0, 0.0, entry["formula"]) if column == "dfH0_0K": value = other - thermal + elements converted[can] = ("0 K", value, thermal) else: value = other + thermal - elements converted[can] = ("298.15 K", value, thermal) return value results = {} fetch_covariances = _P["covariances"] == "fetch if needed" fetch_failed = False for rung in usable: reaction = reactions[rung] result = {"rung": rung, "reaction": str(reaction)} for key, label in (("H0", "0 K"), ("H298", "298.15 K")): if all(key in energies[s] for s in reaction.coefficients): drH = sum( c * energies[s][key] for s, c in reaction.coefficients.items() ) column = "dfH0_0K" if key == "H0" else "dfH0_298K" values = {s: ref_value(s, column) for s in reaction.references} if any(v is None for v in values.values()): continue ref = sum(c * values[s] for s, c in reaction.references.items()) result[f"DrH {label}"] = drH result[f"DfH {label}"] = ref - drH # The ATcT part: the reaction's coefficients on ATcT species, # through the ladder's coefficients where a reference is laddered atct_c = {} method_c2 = 0.0 for s, c in reaction.references.items(): for _id, cc in entries[s]["atct_coefficients"].items(): atct_c[_id] = atct_c.get(_id, 0.0) + c * cc if entries[s].get("source") == "laddered": method_c2 += c * c atct_c = {k: v for k, v in atct_c.items() if abs(v) > 1e-12} ids = list(atct_c) coefficients = list(atct_c.values()) uncertainties = [ tables.entries[_id]["uncertainty_298K"] or 0.0 for _id in ids ] # Fetch a missing block at most until a fetch fails (each failure # waits for the timeout, e.g. on a compute node without network) fetch = fetch_covariances and not fetch_failed covariance = tables.covariance(ids, fetch=fetch) if ids else None if ids and covariance is None and fetch: fetch_failed = True u_atct, used = reaction_uncertainty(coefficients, uncertainties, covariance) u_method = 0.0 if method_c2 > 0 and ladder is not None: rms = ladder.method_uncertainty.get("rms_0K") if rms: u_method = rms * method_c2**0.5 # Library references: no covariance with ATcT, in quadrature u_library = 0.0 for s, c in reaction.references.items(): if entries[s].get("source") not in ("ATcT", "laddered"): u_library += (c * (entries[s]["uncertainty_298K"] or 0.0)) ** 2 u_library = u_library**0.5 u = (u_atct**2 + u_method**2 + u_library**2) ** 0.5 result["uncertainty"] = u result["uncertainty ATcT"] = u_atct if used: result["uncertainty method"] = "ATcT covariance" elif fetch_covariances: result["uncertainty method"] = "quadrature (covariance fetch failed)" else: result["uncertainty method"] = "quadrature (no covariance cached)" if u_method: result["uncertainty ladder method"] = u_method result["uncertainty method"] += " + ladder method term" if u_library: result["uncertainty reference library"] = u_library result["uncertainty method"] += " + reference library (quadrature)" results[rung] = result rows.append(result) table.append_row( **{ "rung": rung, "reaction": str(reaction), "DfH(0 K)": result.get("DfH 0 K"), "DfH(298.15 K)": result.get("DfH 298.15 K"), "uncertainty (95%)": u, "uncertainty method": result["uncertainty method"], "DrH(0 K)": result.get("DrH 0 K"), "DrH(298.15 K)": result.get("DrH 298.15 K"), "ATcT DfH(0 K)": target_entry["dfH0_0K"] if target_entry else None, "ATcT DfH(298.15 K)": ( target_entry["dfH0_298K"] if target_entry else None ), "ATcT uncertainty (95%)": ( target_entry["uncertainty_298K"] if target_entry else None ), "ATcT version": tables.version, } ) # The same reactions at the geometry level, when the energies are at # another level: E(geometry level) + the same thermal parts. With the # same references this is the result of a run at the geometry level # alone (the 'quick' preset for r2SCAN-3c geometries) geometry_results = self._geometry_level_results( usable, reactions, energies, ref_value ) # Print the species and the results species_rows = [] for can in [target] + list(fragments): e = energies[can] entry = entries.get(can) if can != target else target_entry species_rows.append( [ can if can != target else f"{can} (target)", sources.get(can, ""), f"{e['E']:.3f}" if "E" in e else "", (f"{e['E geometry level']:.3f}" if "E geometry level" in e else ""), f"{e['ZPE']:.3f}" if "ZPE" in e else "", f"{e['H298'] - e['E']:.3f}" if "E" in e and "H298" in e else "", ( ( f"ladder (CBH-{entry['rung']})" if entry.get("source") == "laddered" else ( entry["id"] if entry.get("source", "ATcT") == "ATcT" else entry["source"] ) ) if entry else "" ), _reference_cell(entry, converted.get(can), "0 K", "dfH0_0K"), _reference_cell(entry, converted.get(can), "298.15 K", "dfH0_298K"), ( "exact" if entry and entry["exact"] else f"{entry['uncertainty_298K']:.3f}" if entry else "" ), ] ) used_library = { can: entries[can] for rung in usable for can in reactions[rung].references if entries[can].get("source") not in ("ATcT", "laddered") } ref_name = "Ref." if used_library else "ATcT" text = "\nThe species (energies in kJ/mol):\n" text += tabulate( species_rows, headers=[ "Species", "Source", "E", "E (geometry level)", "ZPE", "H(298.15 K) - E", "Reference", f"{ref_name} ΔfH°(0 K)", f"{ref_name} ΔfH°(298.15 K)", "± (95%)", ], tablefmt="rounded_outline", disable_numparse=True, colalign=( "left", "left", "decimal", "decimal", "decimal", "decimal", "left", "decimal", "decimal", "decimal", ), ) text += "\n" result_rows = [] for rung in usable: r = results[rung] row = [f"CBH-{rung}"] for key in ("DrH 0 K", "DfH 0 K", "DrH 298.15 K", "DfH 298.15 K"): row.append(f"{r[key]:.2f}" if key in r else "") row.append(f"{r['uncertainty']:.2f}") if target_entry is not None: for key, column in ( ("DfH 0 K", "dfH0_0K"), ("DfH 298.15 K", "dfH0_298K"), ): if key in r and target_entry[column] is not None: row.append(f"{r[key] - target_entry[column]:+.2f}") else: row.append("") result_rows.append(row) headers = [ "Rung", "ΔrH(0 K)", "ΔfH°(0 K)", "ΔrH(298.15 K)", "ΔfH°(298.15 K)", "± ref (95%)", ] if target_entry is not None: headers += ["Δ vs ATcT (0 K)", "Δ vs ATcT (298.15 K)"] text += "\nEnthalpy of formation of the target (kJ/mol):\n" text += tabulate( result_rows, headers=headers, tablefmt="rounded_outline", disable_numparse=True, colalign=("left",) + ("decimal",) * (len(headers) - 1), ) text += "\n" if geometry_results: text += self._geometry_level_text( geometry_results, results, usable, target_entry ) if target_entry is not None: unc = ( "exact" if target_entry["exact"] else f"± {target_entry['uncertainty_298K']:.2f}" ) text += ( f"\nATcT ({target_entry['id']}, ver. {tables.version}) gives " f"ΔfH°(0 K) = {target_entry['dfH0_0K']:.2f} and ΔfH°(298.15 K) = " f"{target_entry['dfH0_298K']:.2f} {unc} kJ/mol for the target.\n" ) library_check = None if library_target is not None: lt = library_target lu = lt["uncertainty_298K"] or 0.0 parts = [] if lt["dfH0_0K"] is not None: parts.append(f"ΔfH°(0 K) = {lt['dfH0_0K']:.2f}") if lt["dfH0_298K"] is not None: parts.append(f"ΔfH°(298.15 K) = {lt['dfH0_298K']:.2f}") text += ( f"\nThe target is in the reference library ({lt['source']}: " f"{lt['citation']}): " + " and ".join(parts) + f" ± {lu:.2f} kJ/mol " "(95 %). As a check of that value:\n" ) for rung in usable: r = results[rung] for key, column in ( ("DfH 298.15 K", "dfH0_298K"), ("DfH 0 K", "dfH0_0K"), ): if key in r and lt[column] is not None: delta = r[key] - lt[column] flag = abs(delta) > 2 * lu r["library delta"] = delta text += ( f" CBH-{rung} - library ({key[4:]}) = {delta:+.2f} " "kJ/mol" + ( ", more than twice the library's uncertainty" if flag else "" ) + "\n" ) if rung == usable[-1]: library_check = (delta, flag) break if used_library: text += "\nReference values from the reference library:\n" for can, entry in used_library.items(): text += f" {can}: {entry['source']}, {entry['citation']}" if entry.get("override_reason"): text += f" (replaces an earlier source: {entry['override_reason']})" text += ".\n" if converted: text += ( "* Converted between 0 K and 298.15 K with this calculation's " "own H(298.15 K) - H(0) of the species (0 K via computed " "H298-H0).\n" ) text += ( "Library values have no covariance with ATcT; their uncertainties " "(95 %, with each source's offset against ATcT) are added in " "quadrature. The source offsets come from well-studied species and " "are a lower bound for these.\n" ) text += ( "\nThe reference uncertainty is the 95 % confidence limit of the ATcT " "part only; ATcT uncertainties and covariances are defined at 298.15 K " "and are used for the 0 K result as an approximation. The computed " "reaction enthalpy adds its own, unquantified, error. A plain ATcT " "gas-phase entry is the lowest conformer at 0 K but a conformer average " "at 298.15 K, which limits the 298.15 K comparison for flexible molecules." ) if any(src.startswith("store (unverified") for src in sources.values()): text += ( "\nSome species came from the fragment store without a settings " "fingerprint (matched on the model chemistry only), so they are " "unverified: check that their settings match this calculation." ) methods = [r["uncertainty method"] for r in results.values()] if any("fetch failed" in m for m in methods): text += ( "\nFetching the ATcT covariance blocks from atct.anl.gov failed " "(see the log), so some reference uncertainties ignore the " "correlations (quadrature); run 'seamm-thermochemistry-atct " "covariance' on a machine with network access to cache them." ) elif any(m.startswith("quadrature") for m in methods): text += ( "\nNo ATcT covariance block was cached for some reactions, so their " "reference uncertainties ignore the correlations (quadrature); run " "'seamm-thermochemistry-atct covariance' to cache them, or set " "'ATcT covariances' to fetch them." ) printer.important(__(text, indent=4 * " ", wrap=False, dedent=False)) # The spread between the rungs (CBH-1 up), an indicator of the uncertainty spread, spread_rungs = rung_spread(results, usable) if spread is not None: printer.important( __( "\nThe spread of ΔfH°(298.15 K) over CBH-" + ", CBH-".join(str(r) for r in spread_rungs) + f" is {spread:.2f} kJ/mol. A large spread points to trouble " "with the method or the reference values.", indent=4 * " ", wrap=True, dedent=False, ) ) # The results: the highest usable rung best = results[usable[-1]] if preset == "quick" and best["rung"] < 2: printer.important( __( f"\nWarning: the highest rung available is CBH-{best['rung']}, " "where r2SCAN-3c is weak (several kJ/mol at CBH-1; aromatic " "rings and small rings are kept whole, so small aromatics stop " "below CBH-2). Use the 'default' preset for this molecule.", indent=4 * " ", wrap=True, dedent=False, ) ) data["ZPE scale factor"] = zpe_scale data["ZPE scale factor source"] = zpe_source + ( f" ({zpe_flag})" if zpe_flag else "" ) if spread is not None: data["CBH rung spread"] = spread data["CBH energy level"] = ( energy_mc["level"] if energy_mc is not None else self.model ) data["CBH geometry level"] = self.model composite = getattr(self, "_composite", None) if composite is not None: data["CBH composite recipe"] = composite["recipe"] data["CBH composite recipe version"] = composite["version"] data["CBH composite recipe fingerprint"] = composite["fingerprint"] if getattr(self, "_high_choice", None) is not None: data["CBH high level"] = self._high_choice["reason"] data["CBH high canonical limit"] = int(self._high_max_bf) data["CBH rung"] = best["rung"] data["CBH reaction"] = best["reaction"] data.update(self._flags(energies)) if "DfH 0 K" in best: data["DfH 0K"] = best["DfH 0 K"] if "DfH 298.15 K" in best: data["DfH 298K"] = best["DfH 298.15 K"] data["DfH uncertainty"] = best["uncertainty"] data["DfH uncertainty method"] = best["uncertainty method"] if geometry_results: geometry_best = geometry_results.get(best["rung"], {}) data["CBH geometry level preset"] = self._geometry_level_preset() or "" if "DfH 0 K" in geometry_best: data["DfH 0K (geometry level)"] = geometry_best["DfH 0 K"] if "DfH 298.15 K" in geometry_best: data["DfH 298K (geometry level)"] = geometry_best["DfH 298.15 K"] data["DfH uncertainty (geometry level)"] = best["uncertainty"] geometry_spread, _ = rung_spread( geometry_results, [r for r in usable if r in geometry_results] ) if geometry_spread is not None: data["CBH rung spread (geometry level)"] = geometry_spread for rung, r in geometry_results.items(): results[rung]["geometry level"] = dict(r) data["ATcT version"] = tables.version if resolver is not None: best_refs = { can: entries[can] for can in reactions[best["rung"]].references } from seamm_thermochemistry.references import elements several = {} for can, e in best_refs.items(): if e.get("source") in ("ATcT", "laddered"): continue for symbol in elements(can): if not resolver.atct_has_element(symbol): several.setdefault(symbol, set()).add(e["source"]) several = {k: sorted(v) for k, v in several.items() if len(v) > 1} data["CBH references"] = resolver.label(best_refs, several) data["references"] = { **resolver.versions(), "library values": { can: { "id": e["id"], "source": e["source"], "citation": e["citation"], "converted": ( f"{converted[can][0]} via computed H298-H0" if can in converted else None ), } for can, e in best_refs.items() if e.get("source") not in ("ATcT", "laddered") }, } else: data["CBH references"] = "ATcT-only" if ladder is None else "laddered" if library_target is not None: data["library DfH 298K"] = library_target["dfH0_298K"] data["library DfH 0K"] = library_target["dfH0_0K"] data["library DfH uncertainty"] = library_target["uncertainty_298K"] data["library source"] = library_target["source"] if library_check is not None: data["CBH - library"] = library_check[0] data["library check"] = ( "differs by more than twice the library's uncertainty" if library_check[1] else "agrees" ) if target_entry is not None: data["ATcT DfH 0K"] = target_entry["dfH0_0K"] data["ATcT DfH 298K"] = target_entry["dfH0_298K"] data["ATcT DfH uncertainty"] = target_entry["uncertainty_298K"] data["CBH results"] = { str(rung): {k: v for k, v in r.items()} for rung, r in results.items() } # Citations self._cite_cbh(tables) return data
def _ensure_atct(self, base, version): """Download ATcT when no version is installed (e.g. an installation made before the installer set it up). Returns True if it downloaded. atct.anl.gov serves only its current version, so a specific version is never downloaded. Parallel jobs may race to download: the download goes to a per-process file and an existing version wins.""" from seamm_thermochemistry import atct if atct.ATcT.available(base): return False directory = atct.atct_directory(base) if version is not None: raise RuntimeError( f"ATcT version {version} is not installed in {directory}. Install " "it with 'seamm-thermochemistry-atct download --from FILE' from a " "copy of that version, or choose 'latest'." ) try: atct.download_all(base, exist_ok=True) except (atct.ATcTError, OSError) as e: raise RuntimeError( f"No ATcT data are installed in {directory}, and downloading them " f"failed: {e}. Install them with 'seamm-thermochemistry-installer " "install' (or 'seamm-thermochemistry-atct download --from FILE' " "from a copy saved in a browser) on this machine, or set the ATcT " "directory to an installed copy." ) from None return True def _reference_species_result(self, target, entry, tables, via=None): """The result for a target that is itself a reference species: its ATcT value, with nothing computed. `via`: the ATcT SMILES it matched, when ATcT writes another resonance form.""" dfh0, dfh298 = entry["dfH0_0K"], entry["dfH0_298K"] u = 0.0 if entry["exact"] else (entry["uncertainty_298K"] or 0.0) label = f"target is a reference species (ATcT {entry['id']})" text = ( f"\nNo rung gives a reaction for {target}, but the target is itself a " f"reference species, ATcT {entry['id']} (ver. {tables.version}" ) if via is not None: text += ( f", written {via}: the same species as {target}, another resonance " "form" ) text += "): " if dfh0 is not None: text += f"ΔfH°(0 K) = {dfh0:.2f} and " text += f"ΔfH°(298.15 K) = {dfh298:.2f} " text += "(exact)" if entry["exact"] else f"± {u:.2f}" text += " kJ/mol. That value is the result; no species were computed." printer.important(__(text, indent=4 * " ", wrap=True, dedent=False)) data = { "CBH reaction": label, "DfH 298K": dfh298, "DfH uncertainty": u, "DfH uncertainty method": "the target's own ATcT value", "ATcT version": tables.version, "ATcT DfH 298K": dfh298, "ATcT DfH uncertainty": entry["uncertainty_298K"], } if dfh0 is not None: data["DfH 0K"] = dfh0 data["ATcT DfH 0K"] = dfh0 self._cite_cbh(tables) return data def _library_species_result(self, target, entry, tables, resolver): """The result for a target that ATcT lacks but the reference library has: its library value, with nothing computed.""" dfh0, dfh298 = entry["dfH0_0K"], entry["dfH0_298K"] u = entry["uncertainty_298K"] or 0.0 text = ( f"\nNo rung gives a reaction for {target}, but the target is itself a " f"reference species in the reference library ({entry['source']}: " f"{entry['citation']}): " ) parts = [] if dfh0 is not None: parts.append(f"ΔfH°(0 K) = {dfh0:.2f}") if dfh298 is not None: parts.append(f"ΔfH°(298.15 K) = {dfh298:.2f}") text += " and ".join(parts) + f" ± {u:.2f} kJ/mol (95 %, with the source's " text += ( "offset). That value is the result; no species were computed, so it is " "not checked by a CBH reaction." ) if dfh298 is None: text += " The library gives it at 0 K only." printer.important(__(text, indent=4 * " ", wrap=True, dedent=False)) data = { "CBH reaction": f"target is a reference species ({entry['source']})", "DfH uncertainty": u, "DfH uncertainty method": "the target's own reference-library value", "ATcT version": tables.version, "CBH references": resolver.label({target: entry}), "references": { **resolver.versions(), "library values": { target: { "id": entry["id"], "source": entry["source"], "citation": entry["citation"], "converted": None, } }, }, } if dfh298 is not None: data["DfH 298K"] = dfh298 if dfh0 is not None: data["DfH 0K"] = dfh0 self._cite_cbh(tables) return data def _run_species_only(self, _P, smiles, target, system, configuration): """Compute only the target, exactly as a CBH species: optimized and its Hessian at the geometry level, then the energy level's single point, with the properties and fingerprints the fragment store's harvest reads. No reactions and no ATcT. Returns the data dictionary.""" preset = _P.get("preset", "custom") if _P["cbh energies"] == "existing results (run nothing)": raise RuntimeError( "'The species only' computes the species: choose " "'finite-difference harmonic' for the species' energies." ) text = "" if preset in PRESETS: levels = PRESETS[preset] text += ( f"The '{preset}' preset ({levels['recipe']}): the geometry and " "frequencies with " f"{levels['geometry']}, the energy with " f"{levels['energy'] or levels['geometry']}.\n" ) text += f"Only the species {smiles} is computed, for the fragment store" charge = configuration.charge multiplicity = configuration.spin_multiplicity or 1 if charge != 0 or multiplicity != 1: text += f" (charge {charge}, multiplicity {multiplicity})" text += ": no CBH reactions and no ATcT.\n" route = _P.get("hessian route", "automatic") or "automatic" if route == "automatic": route = "warm engine over MDI" self._hessian_route = route text += f"The Model Chemistry is driven as: {route}.\n" printer.important(__(text, indent=4 * " ", wrap=False, dedent=False)) store = self._fragment_store(_P) energy_mc = self._energy_level(_P) if energy_mc is not None and _P["thermal corrections"] != "harmonic": raise RuntimeError( "Energies at another level than the geometries need harmonic " "thermal corrections at the geometry level." ) energies = { target: self._compute_species( _P, target, smiles, system, configuration, embed=str(_P.get("target conformer", "as given")).startswith("lowest"), ) } sources = {target: "computed"} if energy_mc is not None: printer.important( __( f"\nThe energy is at {energy_mc['level']}, on the geometry and " f"with the frequencies of {self.model}.", indent=4 * " ", wrap=False, dedent=False, ) ) self._energy_level_energies( energy_mc, store, target, [target], {target: configuration}, energies, sources, ) self._report_store_notes(store) if self._storable(target, energies.get(target)): self._write_back(_P, {target: configuration}) system_db = self.get_variable("_system_db") system_db.system = system system.configuration = configuration self._working_configuration = configuration e = energies[target] row = [target, sources[target], f"{e['E']:.3f}" if "E" in e else ""] row.append(f"{e['E geometry level']:.3f}" if "E geometry level" in e else "") row.append(f"{e['ZPE']:.3f}" if "ZPE" in e else "") row.append(f"{e['H298'] - e['E']:.3f}" if "E" in e and "H298" in e else "") text = "\nThe species (energies in kJ/mol, ZPE unscaled):\n" text += tabulate( [row], headers=[ "Species", "Source", "E", "E (geometry level)", "ZPE", "H(298.15 K) - E", ], tablefmt="rounded_outline", disable_numparse=True, colalign=("left", "left", "decimal", "decimal", "decimal", "decimal"), ) if e.get("imaginary"): text += ( f"\nWarning: the structure has {e['imaginary']} imaginary mode(s): " "it is not a minimum, and the store's lookups leave it out." ) printer.important(__(text, indent=4 * " ", wrap=False, dedent=False)) data = { "CBH energy level": ( energy_mc["level"] if energy_mc is not None else self.model ), "CBH geometry level": self.model, **self._flags(energies), } composite = getattr(self, "_composite", None) if composite is not None: data["CBH composite recipe"] = composite["recipe"] data["CBH composite recipe version"] = composite["version"] data["CBH composite recipe fingerprint"] = composite["fingerprint"] return data # ----------------------------------------------------------------------- def _embed_lowest(self, configuration, smiles, n=20): """Rebuild the configuration's atoms and coordinates as the lowest MMFF94 conformer of `n` RDKit embeddings of `smiles`, keeping its charge and spin multiplicity, and say what was found.""" from seamm_thermochemistry import cbh mol, symbols, xyz, energies, best = cbh.lowest_conformer( smiles, n=n, return_mol=True ) charge = configuration.charge multiplicity = configuration.spin_multiplicity configuration.from_RDKMol(mol, properties="none") configuration.charge = charge configuration.spin_multiplicity = multiplicity spread = max(energies) - min(energies) printer.important( __( f" starting from the lowest of {len(energies)} MMFF94 conformers " f"(spread {spread:.1f} kcal/mol)" + self._chain_dihedrals(symbols, xyz), indent=4 * " ", wrap=False, dedent=False, ) ) @staticmethod def _chain_dihedrals(symbols, xyz): """', heavy-atom chain dihedrals ...' for an unbranched chain of 4+ heavy atoms, else ''.""" import numpy as np heavy = [i for i, s in enumerate(symbols) if s != "H"] if len(heavy) < 4: return "" p = np.asarray(xyz, dtype=float) text = [] for i in range(len(heavy) - 3): a, b, c, d = (p[heavy[i + k]] for k in range(4)) b0, b1, b2 = a - b, c - b, d - c b1n = b1 / np.linalg.norm(b1) v = b0 - np.dot(b0, b1n) * b1n w = b2 - np.dot(b2, b1n) * b1n angle = np.degrees(np.arctan2(np.dot(np.cross(b1n, v), w), np.dot(v, w))) text.append(f"{abs(angle):.0f}") return ", heavy-atom dihedrals (in SMILES order) " + " ".join(text) + "°" #: The "fragment store" choices that read the store ("use if available" is #: the name of "use only" in flowcharts from before 2026.11.12) _STORE_READ = ("use and add what is computed", "use only", "use if available") def _job_root(self): """The job's directory, or None outside a flowchart.""" try: return Path(self.flowchart.root_directory) except Exception: return None def _job_label(self): """The job as the fragment store records it: '<job id>:<directory>'.""" root = self._job_root() if root is None: return None job_id = None try: text = (root / "job_data.json").read_text() if text.startswith("!"): # SEAMM's header, "!MolSSI job_data 1.0" text = text.split("\n", 1)[1] if "\n" in text else "" job_id = json.loads(text).get("job id") except Exception: pass return f"{job_id}:{root}" if job_id is not None else str(root) def _write_back(self, _P, species): """Add the species computed here ({SMILES: configuration}) to the fragment store (write-back), with the guards of a running job's results: a structure whose bonds are not its species', or with an imaginary mode, is refused; a lower, distinct conformer is added as one. Only with "use and add what is computed", and only when the store is on a local disk of this machine; otherwise the job is marked to be harvested when it comes back to its JobServer (or by hand with ``seamm-thermochemistry-fragments harvest``). Never fails the step.""" if _P.get("fragment store", "ignore") != "use and add what is computed": return species = {k: v for k, v in species.items() if v is not None} if not species: return try: from seamm_thermochemistry import ( FragmentStore, harvest_configuration, hooks, store_is_writable, ) from seamm_thermochemistry.fragment_store import new_counts except ImportError: # pragma: no cover - an older seamm-thermochemistry return path = _P.get("fragment store file", "default") path = None if path in ("default", "") else os.path.expanduser(path) root = self._job_root() def not_written(why): if root is not None: hooks.write_marker(root, list(species), why) text = ( f"\nThe computed species were not added to the fragment store: {why}. " "The job will be harvested into the store when it returns to its " "JobServer, or harvest it with 'seamm-thermochemistry-fragments " "harvest'." ) printer.important(__(text, indent=4 * " ", wrap=True, dedent=False)) writable, why = store_is_writable(path) if not writable: not_written(f"the store is {why}") return counts = new_counts() job = self._job_label() # The species of which nothing was stored, with the harvest's reasons missed = {} try: with FragmentStore(path, create=True) as store: for can, configuration in species.items(): mine = new_counts() harvest_configuration( store, configuration, can, charge=cbh_charge(can), multiplicity=cbh_multiplicity(can), job=job, energy="total energy#Thermochemistry#*", zpe="ZPE#Thermochemistry#*", enthalpy="H(298.15 K)#Thermochemistry#*", fingerprint="settings fingerprint#Thermochemistry#*", frequency_fingerprint=( "frequency fingerprint#Thermochemistry#*" ), write_back=True, counts=mine, ) _merge_counts(counts, mine) if not _any_rows(mine): missed[can] = mine["skipped"] or ["nothing to store"] where = store.path except Exception as e: logger.warning(f"Writing the fragment store failed: {e}") not_written(f"writing it failed ({e})") return def tally(table): return ", ".join(f"{n} {k}" for k, n in sorted(counts[table].items())) fragments = ", ".join( f"{counts[k]} {k}" for k in ("inserted", "duplicate", "logged", "superseded") if counts[k] ) text = f"\nAdded to the fragment store ({where}): " text += f"species: {fragments or 'none'}; " text += f"geometries: {tally('geometry') or 'none'}; " text += f"energies: {tally('energy') or 'none'}." for can, reasons in missed.items(): for reason in reasons: if reason.startswith(f"{can}: "): reason = reason[len(can) + 2 :] text += f"\n {can} was not added: {reason}" for reason in counts["skipped"]: if any(reason in reasons for reasons in missed.values()): continue text += f"\n {reason}" printer.important(__(text, indent=4 * " ", wrap=False, dedent=False)) def _fragment_store(self, _P): """The fragment store to read, or None if ignored or unreadable.""" if _P.get("fragment store", "ignore") not in self._STORE_READ: return None path = _P.get("fragment store file", "default") path = None if path in ("default", "") else os.path.expanduser(path) self._store_layer = {} try: # The local store, then the official one (seamm-thermochemistry's # two layers); an older seamm-thermochemistry has only the local one try: from seamm_thermochemistry import LayeredStore except ImportError: # pragma: no cover from seamm_thermochemistry import FragmentStore return FragmentStore(path) return LayeredStore(path) except Exception as e: # pragma: no cover logger.warning(f"The fragment store could not be opened: {e}") return None @staticmethod def _store_source(store, row): """How a value from the store is labelled: "store", with the layer when it is the official one, and "unverified" when it has no fingerprint.""" layer = row.get("layer", "local") text = "store" details = [] if layer != "local" and hasattr(store, "layer_name"): details.append(store.layer_name(layer)) if not row.get("verified", True): details.append("unverified") return text + (f" ({', '.join(details)})" if details else "") def _report_store_notes(self, store): """Print what the two-layer store noticed (an official value superseding an old local one, local and official values that differ).""" notes = getattr(store, "notes", None) if not notes: return text = "\nFragment store: " + "\n ".join(notes) printer.important(__(text, indent=4 * " ", wrap=False, dedent=False)) notes.clear() def _energies_from_store(self, _P, store, can): """The energies of a species from the store at this model chemistry, as ({"E", "ZPE", "H0", "H298"}, source), or None. A record must have what the thermal-correction mode needs: E and H(298.15 K) with harmonic corrections (ZPE too for the 0 K result), E alone without.""" if store is None: return None from thermochemistry_step.cbh_approach import cbh_charge, cbh_multiplicity model = self.model or "unknown" # The fingerprints expected for this species, not the target's energy_fingerprints, _ = self._geometry_level_fingerprints(can) record = store.get( can, charge=cbh_charge(can), multiplicity=cbh_multiplicity(can), energy_fingerprint=energy_fingerprints, model_chemistry=model, ) if record is None or record.get("E") is None: return None result = {"E": record["E"]} if _P["thermal corrections"] != "harmonic": result["H298"] = record["E"] else: if record.get("H") is None: return None if record.get("T") not in (None, T_REF): return None result["H298"] = record["H"] if record.get("ZPE") is not None: result["ZPE"] = record["ZPE"] result["H0"] = record["E"] + record["ZPE"] return result, self._store_source(store, record) @staticmethod def _preset_rungs(_P): """The rungs a preset uses: up to CBH-2, or CBH-3 too.""" rungs = [0, 1, 2] if _yes(_P.get("include CBH-3", "no")): rungs.append(3) return rungs
[docs] def apply_preset(self, _P): """The parameters with the preset's settings in place: the rungs, harmonic thermal corrections, computed species and the energy level. 'custom' changes nothing. The geometry level is the preset's model chemistry (see :meth:`preset_model_chemistry`).""" preset = _P.get("preset", "custom") if preset not in PRESETS: return _P P = dict(_P) P["cbh rungs"] = ", ".join(str(r) for r in self._preset_rungs(_P)) P["thermal corrections"] = "harmonic" P["cbh energies"] = "finite-difference harmonic" energy = self.preset_levels(preset)["energy"] P["energy model chemistry"] = energy or "the geometry level" return P
[docs] def preset_levels(self, preset): """The geometry and energy levels of a preset: its recipe's, when the Composite step is installed, else the spellings in :data:`PRESETS`.""" levels = dict(PRESETS[preset]) if levels.get("recipe") is None: return levels try: import composite_step # noqa: F401 except ImportError: return levels recipe = recipe_levels(levels["recipe"], getattr(self, "data_path", None)) levels.update( geometry=recipe["geometry"], energy=recipe["energy"], composite=recipe ) return levels
[docs] def preset_model_chemistry(self, _P): """The ``_model_chemistry`` wrapper of the preset's geometry level, or None for 'custom'. A preset's recipe (or a composite recipe as the flowchart's model chemistry) is kept in ``self._composite``: its geometry level is the step's model, its energy level and ZPE factor follow.""" preset = _P.get("preset", "custom") self._composite = None if preset not in PRESETS: return self.composite_model_chemistry() levels = self.preset_levels(preset) self._composite = levels.get("composite") return self._resolve_model_chemistry(levels["geometry"])
[docs] def composite_model_chemistry(self): """For a composite recipe as the flowchart's model chemistry, the wrapper of its geometry level (kept in ``self._composite``); else None.""" import seamm if seamm.flowchart_variables is None: # no flowchart (the GUI, tests) return None if not self.variable_exists("_model_chemistry"): return None level = (self.get_variable("_model_chemistry") or {}).get("level") if not is_composite(level): return None self._composite = recipe_levels(level, getattr(self, "data_path", None)) return self._resolve_model_chemistry(self._composite["geometry"])
def _zpe_scale(self, _P): """(factor, source, caution) for the ZPEs: a number given, or the table's factor for the geometry level, or 1 with a caution if it has none.""" value = str(_P.get("ZPE scale factor", "from the table") or "").strip() tag = getattr(self, "_frequency_correction_tag", None) if tag: if value and value not in ("from the table", "1", "1.0"): raise ValueError( f"The frequencies of {self.model} are corrected one by one " f"({tag}), so the ZPE cannot also be scaled by {value}. Set the " "ZPE scale factor to 'from the table' or the frequency correction " "to 'none'." ) return 1.0, f"not scaled: the frequencies are corrected ({tag})", "" if value and value != "from the table": try: return float(value), "as given", "" except ValueError: raise ValueError( f"The ZPE scale factor must be 'from the table' or a number, " f"not '{value}'." ) from seamm_thermochemistry import zpe_scale_factor factor = zpe_scale_factor(self.model) composite = getattr(self, "_composite", None) if composite is not None and composite["geometry"] == self.model: # The recipe pins its factor; the same as the table's, it is reported # as the table's, so the results are those of the levels themselves scale, source = composite["zpe"] if factor is None or scale != factor.factor: return scale, f"the recipe {composite['name']}: {source}", "" if factor is None: return ( 1.0, "no factor in the table", f"the table has no ZPE scale factor for {self.model}, so the ZPEs " "are not scaled", ) return factor.factor, factor.source, factor.flag def _energy_level(self, _P): """The ``_model_chemistry`` wrapper of the energy level, or None when the energies are at the geometry level.""" text = str(_P.get("energy model chemistry", "") or "").strip() if text == HIGH_ENERGY: text = self._high_energy_level() composite = getattr(self, "_composite", None) if text in ("", "the geometry level") and composite is not None: # A composite as the flowchart's model chemistry: its energy level text = composite["energy"] or "" self._energy_recipe = None if is_composite(text): # A composite as the energy level: its energy, on this step's # geometries recipe = recipe_levels(text, getattr(self, "data_path", None)) if recipe["geometry"] != self.model: printer.important( __( f"Note: the composite {text} is defined with geometries at " f"{recipe['geometry']}; its energy, {recipe['energy']}, is " f"used here on the geometries of {self.model}.", indent=4 * " ", ) ) if recipe["energy"] == recipe["recipe"]: # Several calculations, combined: the composite runs as itself self._energy_recipe = recipe["object"] return self._resolve_model_chemistry( recipe["recipe"], getattr(self, "data_path", None) ) text = recipe["energy"] or recipe["geometry"] if text in ("", "the geometry level") or text == self.model: return None return self._resolve_model_chemistry(text) def _high_energy_level(self): """The 'high' preset's energy level for the target (high_level.py); the default preset's when there is no 'high' result, with the reason printed and kept for the results.""" from .high_level import high_level smiles, multiplicity, atomic_numbers = self._high_target max_bf = int(getattr(self, "_high_max_bf", None) or CANONICAL_MAX_BF_DEFAULT) level, reason = high_level(smiles, multiplicity, atomic_numbers, max_bf=max_bf) if level is None: level = PRESETS["default"]["energy"] note = ( f"No 'high' result for {smiles}: {reason}. The energies are at the " f"default preset's level, {level}." ) else: note = f"The 'high' energy level for {smiles}: {level}, since {reason}." self._high_choice = {"level": level, "reason": note} printer.important(__(note, indent=4 * " ")) return level @staticmethod def _resolve_model_chemistry(text, data_path=None): """The ``_model_chemistry`` wrapper for a model chemistry string. ``data_path`` (the node's) lets discovery see a user's or a job's composite recipes.""" import seamm try: from model_chemistry_step.model_chemistry import ( availability_problem, discover_model_chemistries, match_model_chemistry, resolve_level, ) except ImportError: # pragma: no cover raise RuntimeError( "The presets and energies at another model chemistry need the " "Model Chemistry step (model-chemistry-step) installed." ) text = resolve_level(text, dict(seamm.flowchart_variables._data)) available = discover_model_chemistries(periodic_only=False, data_path=data_path) mc = available.get(text) or match_model_chemistry(text, available) if mc is None: raise ValueError(availability_problem(text, available, periodic=False)) return mc def _target_from_store(self, _P, store, target, smiles, configuration, embed): """The target's energies from the fragment store's geometry table, when it holds the very structure this target starts from (see :func:`match_structure`), as ({"E", "ZPE", "H0", "H298"}, source); the target's coordinates become the stored ones. None to compute it.""" if store is None or configuration is None: return None if _P.get("reuse stored target", "if the structure matches") == "never": return None if _P.get("thermal corrections", "harmonic") != "harmonic": return None if _P.get("cbh energies", "").startswith("existing"): return None if configuration.n_atoms < 2: return None if embed: self._embed_lowest( configuration, smiles, int(_P.get("conformer embeddings", 20)) ) # Before anything is computed neither the Hessian's route nor the # program's version is known: predict the fingerprints of either route # (analytic, or finite differences of the energy's) with this run's # version, or else with each version the store has the target at, so a # stored row is taken only with exactly the settings this run would use mc = self._geometry_mc() versions = [getattr(self, "_fingerprint_version", None)] if not versions[0]: versions = sorted(_stored_versions(store, target)) frequency_fingerprints = [] for version in versions: for kind in ("hessian", "energy"): got = self._expected_fingerprints( configuration, mc, kind, version=version ) frequency_fingerprints += [ self._corrected_fingerprint(fp) for fp in (got or []) ] if not frequency_fingerprints: return None # the settings cannot be checked: compute it try: rows = store.geometries( target, charge=cbh_charge(target), multiplicity=cbh_multiplicity(target), frequency_fingerprint=frequency_fingerprints, model_chemistry=self.model, ) except Exception as e: # never fail the target over the lookup logger.warning(f"Looking up the target {target} in the store failed: {e}") return None symbols = list(configuration.atoms.symbols) start = np.asarray(configuration.atoms.get_coordinates(fractionals=False)) best = None reasons = [] for row in rows: if row["E"] is None or row["ZPE"] is None or row["H"] is None: continue if row.get("T") not in (None, T_REF): continue ok, rmsd, why = match_structure( symbols, start, list(row["symbols"]), row["coordinates"] ) if not ok: reasons.append(why) continue if best is None or rmsd < best[1]: best = (row, rmsd) if best is None: if reasons: printer.important( __( f"\nThe fragment store has the target {target} at this level, " "but not this structure (" + "; ".join(sorted(set(reasons))) + "), so it is computed.", indent=4 * " ", wrap=True, dedent=False, ) ) return None row, rmsd = best if not getattr(self, "_fingerprint_version", None) and row.get("version"): # This run's calculations must be of the same version (checked in # _store_fingerprints) self._fingerprint_version = row["version"] self._assumed_version = row["version"] configuration.atoms.set_coordinates( np.asarray(row["coordinates"], dtype=float), fractionals=False ) if not hasattr(self, "_store_layer"): self._store_layer = {} self._store_layer[target] = row.get("layer", "local") result = { "E": row["E"], "ZPE": row["ZPE"], "H0": row["E"] + row["ZPE"], "H298": row["H"], } source = ( f"{self._store_source(store, row)}; the input structure " f"(heavy-atom RMSD {rmsd:.3f} Å)" ) printer.important( __( f"\nThe target {target}: geometry and frequencies from the fragment " f"store, whose structure is this target's (the same atoms, order and " f"bonds; heavy-atom RMSD {rmsd:.3f} Å to the input structure).", indent=4 * " ", wrap=True, dedent=False, ) ) return result, source def _geometry_from_store(self, store, can): """A species' geometry, ZPE and H(298.15 K) at the geometry level from the fragment store's geometry table (the lowest conformer at that level), as ({"E", "ZPE", "H0", "H298"}, geometry row, source), or None.""" if store is None: return None # The fingerprints expected for this species, not the target's _, frequency_fingerprints = self._geometry_level_fingerprints(can) rows = store.geometries( can, charge=cbh_charge(can), multiplicity=cbh_multiplicity(can), frequency_fingerprint=frequency_fingerprints, model_chemistry=self.model, ) rows = [ r for r in rows if r["E"] is not None and r["ZPE"] is not None and r["H"] is not None and r.get("T") in (None, T_REF) ] if not rows: return None # The lowest conformer of both layers, a tie going to the local one lowest = getattr(store, "lowest", None) g = lowest(rows) if lowest else min(rows, key=lambda r: r["E"] + r["ZPE"]) result = {"E": g["E"], "ZPE": g["ZPE"], "H0": g["E"] + g["ZPE"], "H298": g["H"]} if not hasattr(self, "_store_layer"): self._store_layer = {} self._store_layer[can] = g.get("layer", "local") return result, g, self._store_source(store, g) def _configuration_from_geometry(self, system_db, can, geometry): """The system CBH:<can> with the stored geometry as a new configuration.""" system = None for candidate in system_db.get_systems(): if candidate.name == f"CBH:{can}": system = candidate break if system is None: system = system_db.create_system(name=f"CBH:{can}", make_current=True) configuration = system.create_configuration( name="from the fragment store", make_current=True ) xyz = np.asarray(geometry["coordinates"], dtype=float) configuration.atoms.append( symbol=geometry["symbols"], x=xyz[:, 0], y=xyz[:, 1], z=xyz[:, 2] ) configuration.charge = cbh_charge(can) configuration.spin_multiplicity = cbh_multiplicity(can) return configuration def _energy_level_energies( self, mc, store, target, order, configurations, energies, sources ): """The energy level's single points on the species' geometries. The target's runs first, which gives the energy level's settings fingerprint; then the fragments' energies come from the fragment store's energy table where it has them on the same geometry and level, and the rest run as one batch. Each species' energies become E(energy level) and [H - E](geometry level); the energy, its settings fingerprint and the calculation record are stored as properties on the configuration for the store's harvest.""" import seamm_exec from seamm_thermochemistry.geometry_store import hash_geometry level = mc["level"] if is_composite(level): return self._composite_energies( mc, store, order, configurations, energies, sources ) directory = self._species_directory() directory = directory / "energy_level" if directory is not None else None keys = {can: f"species_{i}" for i, can in enumerate(order)} by_key = {v: k for k, v in keys.items()} def run(cans): if not cans: return {} out = {} with seamm_exec.Evaluator( self, mc, properties=("energy",), directory=directory, name="Thermochemistry_energies", ) as evaluator: for can in cans: evaluator.submit(configurations[can], key=keys[can]) for result in evaluator.results(): out[by_key[result.key]] = result for can, result in out.items(): if not result.ok or result.energy is None: raise RuntimeError( f"The {level} energy of {can} failed: {result.reason}" ) return out def record_of(result): record = (result.data or {}).get("calculation") return record if isinstance(record, dict) else None high = {} computed_parts = {} fingerprint = version = None for can, result in run([target]).items(): high[can] = (result.energy, record_of(result), "computed") computed_parts[can] = (result.data or {}).get("energy parts") record = record_of(result) fingerprint = record.get("fingerprint") if record else None version = record.get("version") if record else None def expected(can): """The energy level's fingerprints expected for this species (the program's prediction; the target's if it cannot predict).""" got = self._expected_fingerprints( configurations[can], mc, "energy", version=version ) if got is None: got = [fingerprint] if fingerprint else [] return got to_run = [] for can in order: if can in high: continue fingerprints = expected(can) configuration = configurations[can] # Already on this configuration in the job (an earlier iteration)? E = self._energy_level_from_job(configuration, level, fingerprints) if E is not None: high[can] = (E, None, "job") continue row = None if store is not None: geometry_layer = getattr(self, "_store_layer", {}).get(can, "local") kwargs = {"prefer": geometry_layer} if hasattr(store, "lowest") else {} row = store.energy( hash_geometry( configuration.atoms.symbols, configuration.atoms.get_coordinates(fractionals=False), configuration.atoms.atomic_numbers, ), energy_fingerprint=fingerprints, model_chemistry=level, **kwargs, ) if row is not None: how = self._store_source(store, row) if row.get("layer", "local") != geometry_layer and hasattr( store, "layer_name" ): # R5: the same geometry, energies from the other layer how += ( f"; geometry {store.layer_name(geometry_layer)}, energy " f"{store.layer_name(row['layer'])}" ) high[can] = (row["E"], None, how) else: to_run.append(can) for can, result in run(to_run).items(): high[can] = (result.energy, record_of(result), "computed") computed_parts[can] = (result.data or {}).get("energy parts") # The self-check of the prediction, on what was computed for can, (_, record, how) in high.items(): if how == "computed": self._check_prediction( can, configurations[can], "energy", record, mc=mc ) for can in order: E, record, how = high[can] low = energies[can] combined = {"E": E, "E geometry level": low["E"]} if "ZPE" in low: combined["ZPE"] = low["ZPE"] combined["H0"] = E + low["ZPE"] if "H298" in low: combined["H298"] = E + (low["H298"] - low["E"]) for flag in ("imaginary", "converged", "broken"): if flag in low: combined[flag] = low[flag] energies[can] = combined sources[can] = f"{sources.get(can, '')}; energy {how}".lstrip("; ") if how == "computed": self._store_energy_level( configurations[can], level, E, record, computed_parts.get(can) ) printer.important( __( f"\n{level} energies: " + ", ".join(f"{can} {high[can][2]}" for can in order) + ".", indent=4 * " ", wrap=True, dedent=False, ) ) def _composite_energies(self, mc, store, order, configurations, energies, sources): """The energies of a composite that combines several calculations, on the species' geometries: one CompositeEvaluator over all the species. Each component is looked up first, never the composite: in the job (an earlier iteration's property at the component's level) and in the fragment store's energy table by the component's own fingerprint. The rest run, each component as one batch of its program. Each computed component is stored as its own energy property (so the store's harvest keeps components only), and the composite's record, E0 and fingerprint under ``composite ...`` names, which the harvest does not read.""" from composite_step.evaluate import CompositeEvaluator from seamm_thermochemistry.geometry_store import hash_geometry level = mc["level"] recipe = getattr(self, "_energy_recipe", None) directory = self._species_directory() directory = directory / "composite" if directory is not None else None keys = {can: f"species_{i}" for i, can in enumerate(order)} by_key = {v: k for k, v in keys.items()} def lookup(source, component_mc, key, configuration): can = by_key[key] component = component_mc["level"] fingerprints = ( self._expected_fingerprints(configuration, component_mc, "energy") or [] ) E = self._energy_level_from_job(configuration, component, fingerprints) if E is not None: record = {} for key, base in ( ("fingerprint", "settings fingerprint"), ("energy parts", "energy parts"), ): name = f"{base}#Thermochemistry#{component}" if configuration.properties.exists(name): record[key] = configuration.properties.get(name)[name]["value"] return E, "job", record if store is None: return None geometry_layer = getattr(self, "_store_layer", {}).get(can, "local") kwargs = {"prefer": geometry_layer} if hasattr(store, "lowest") else {} row = store.energy( hash_geometry( configuration.atoms.symbols, configuration.atoms.get_coordinates(fractionals=False), configuration.atoms.atomic_numbers, ), energy_fingerprint=fingerprints, model_chemistry=component, **kwargs, ) if row is None: return None record = { "fingerprint": row.get("energy_fingerprint"), "program": row.get("program"), "version": row.get("version"), "energy parts": row.get("byproducts") or None, } return row["E"], self._store_source(store, row), record results = {} with CompositeEvaluator( self, mc, recipe, properties=("energy",), directory=directory, name="Thermochemistry_composite", component_lookup=lookup, ) as evaluator: for can in order: evaluator.submit(configurations[can], key=keys[can]) for result in evaluator.results(): results[by_key[result.key]] = result for can in order: result = results.get(can) if result is None or not result.ok or result.energy is None: reason = result.reason if result is not None else "no result" raise RuntimeError(f"The {level} energy of {can} failed: {reason}") for can in order: result = results[can] composite = result.data["composite"] record = result.data["calculation"] E = result.energy low = energies[can] combined = {"E": E, "E geometry level": low["E"]} if "ZPE" in low: combined["ZPE"] = low["ZPE"] combined["H0"] = E + low["ZPE"] if "H298" in low: combined["H298"] = E + (low["H298"] - low["E"]) for flag in ("imaginary", "converged", "broken"): if flag in low: combined[flag] = low[flag] energies[can] = combined hows = ", ".join( f"{source} {c['how']}" for source, c in composite["components"].items() ) sources[can] = f"{sources.get(can, '')}; energy {hows}".lstrip("; ") for source, c in composite["components"].items(): if str(c["how"]).startswith("computed"): self._store_energy_level( configurations[can], c["model chemistry"], c["energy"], record["components"][source]["record"], c.get("parts"), ) self._store_composite(configurations[can], level, E, record) printer.important( __( f"\n{level} energies: " + "; ".join( f"{can} ({sources[can].split('energy ', 1)[-1]})" for can in order ) + ".", indent=4 * " ", wrap=True, dedent=False, ) ) @staticmethod def _store_composite(configuration, level, E, record): """A composite's E0 (E_h), fingerprint and record as properties ``composite energy|fingerprint|calculation#Thermochemistry#<level>``: with the results, never as an energy row of the fragment store (whose harvest reads ``total energy`` and ``settings fingerprint`` only).""" properties = configuration.properties values = [ ("composite energy", Q_(E, "kJ/mol").m_as("E_h"), "float", "E_h"), ("composite fingerprint", record.get("fingerprint"), "str", None), ("composite calculation", record, "json", None), ] for base, value, kind, units in values: name = f"{base}#Thermochemistry#{level}" if not properties.exists(name): properties.add(name, kind, units=units, description=base) properties.put(name, value) @staticmethod def _store_energy_level(configuration, level, E, record, parts=None): """The energy-level energy (E_h), its settings fingerprint, the calculation record and the parts of the energy (as the program reported them) as properties ``...#Thermochemistry#<level>``, for the fragment store's harvest.""" properties = configuration.properties values = [ ("total energy", Q_(E, "kJ/mol").m_as("E_h"), "float", "E_h"), ] if record: if record.get("fingerprint"): values.append( ("settings fingerprint", record["fingerprint"], "str", None) ) values.append(("calculation", record, "json", None)) if parts: values.append(("energy parts", parts, "json", None)) for base, value, kind, units in values: name = f"{base}#Thermochemistry#{level}" if not properties.exists(name): properties.add(name, kind, units=units, description=base) properties.put(name, value) @staticmethod def _target_smiles(configuration, charge, multiplicity): """The SMILES of the target, checked against the configuration's charge and spin multiplicity, which the CBH reactions and the ATcT lookups rely on. A mismatch is an error: molsystem renders a radical centre as an anion (``[CH2-]C`` for the ethyl radical), which would silently give the reactions of the wrong species.""" from seamm_thermochemistry import cbh smiles = configuration.to_smiles(canonical=True, isomeric=True) _, q, radicals, _ = cbh._composition(smiles) unpaired = multiplicity - 1 # Fewer unpaired electrons than the SMILES shows is allowed: the structure's # multiplicity wins (SiCl2 and BH are singlets, written as diradicals) if q != charge or (unpaired - radicals) % 2 != 0: raise RuntimeError( f"The SMILES of the structure, {smiles}, implies charge {q} and " f"{radicals} unpaired electron(s), but the configuration has charge " f"{charge} and spin multiplicity {multiplicity}. Check the charge " "and multiplicity of the system. (A radical centre rendered as an " "anion, e.g. [CH2-]C for the ethyl radical, is a known limitation " "of the SMILES from a structure; build the system from the " "radical's SMILES.)" ) return smiles def _find_fragment(self, system_db, can): """The configuration of the system named CBH:<can>, or None.""" name = f"CBH:{can}" for system in system_db.get_systems(): if system.name == name: return system.configuration return None def _compute_species( self, _P, can, generated, system=None, configuration=None, embed=False ): """Create (if needed) and compute one species with the subflowchart. `embed`: start a given configuration from its lowest MMFF conformer.""" printer.important( __(f"\nSpecies {can}:", indent=4 * " ", wrap=False, dedent=False) ) system_db = self.get_variable("_system_db") if configuration is not None and embed: self._embed_lowest( configuration, generated, int(_P.get("conformer embeddings", 20)) ) if configuration is None: configuration = self._find_fragment(system_db, can) if configuration is None: system = system_db.create_system(name=f"CBH:{can}", make_current=True) configuration = system.create_configuration( name="initial", make_current=True ) configuration.from_smiles(generated, name=f"CBH:{can}") configuration.charge = cbh_charge(generated) configuration.spin_multiplicity = cbh_multiplicity(generated) if str(_P.get("initial conformer", "lowest")).startswith("lowest"): self._embed_lowest( configuration, generated, int(_P.get("conformer embeddings", 20)), ) else: system = configuration.system system_db.system = system system.configuration = configuration self._working_configuration = configuration label = can.replace("/", "_").replace("\\", "_") self._subdirectory = f"species_{label}" try: if getattr(self, "_engine_mode", "subflowchart") == "Model Chemistry": result = self._compute_species_mc(_P, can, configuration) elif _P["thermal corrections"] != "harmonic": atoms = self.ase_atoms(configuration) E = Q_(atoms.get_potential_energy(), "eV").m_as("kJ/mol") printer.important( __( f" enthalpy of formation (from the engine) = {E:.3f} kJ/mol", indent=4 * " ", wrap=False, dedent=False, ) ) result = {"H298": E, "E": E} self._store_species_energy(configuration, E) else: converged = None if _yes(_P["optimize fragments"]) and configuration.n_atoms > 1: converged = self._optimize(configuration, _P, can) self._XYZ0 = np.array( configuration.atoms.get_coordinates(fractionals=False) ) self._E0 = None XYZ_save = configuration.atoms.coordinates self._vibrations = self.run_ase_Hessian( step_size=_P["step size"].m_as("Å"), on_error=_P["on error"], on_success=_P["on success"], ) configuration.atoms.coordinates = XYZ_save data = self.harmonic_thermochemistry( _P, [T_REF], "K", [1.0], "bar", print_frequencies=False ) if "total_energy" not in data: raise RuntimeError( f"No energy for the undisplaced structure of {can}" ) E = Q_(data["total_energy"], "E_h").m_as("kJ/mol") result = { "E": E, "ZPE": data["ZPE"], "H0": E + data["ZPE"], "H298": E + data["H thermal"][0][0], } result["imaginary"] = data.get("N saddle modes", 0) if converged is not None: result["converged"] = converged # Keep them on the configuration for "existing results" runs self._store_species_properties(configuration, data) finally: self._subdirectory = None try: self._write_species_sdf(configuration, can, result) except Exception as e: # Diagnostic output: it must never fail the species logger.warning(f"Could not write the structure of {can}: {e}") # After the structure is written, so a broken one can be looked at self._check_species(can, configuration, result) return result def _species_directory(self): """The step's directory, or None when it has none (outside a flowchart).""" try: return Path(self.directory) except Exception: return None def _write_species_sdf(self, configuration, can, result): """Write the species' structure, as computed, to ``<SMILES>.sdf`` in the step's directory and append it to ``species.sdf``, with its energies as SDF data fields, so the conformations can be checked; and print the heavy-atom dihedrals of the final structure next to the starting ones.""" directory = self._species_directory() if directory is None: return label = can.replace("/", "_").replace("\\", "-") symbols = configuration.atoms.symbols xyz = configuration.atoms.get_coordinates(fractionals=False) dihedrals = self._chain_dihedrals(symbols, xyz) try: text = configuration.to_sdf_text() except Exception as e: # pragma: no cover - needs Open Babel logger.warning(f"Could not write the structure of {can}: {e}") return name = configuration.system.name if configuration.system else "" fields = { "SMILES": can, "role": "fragment" if str(name).startswith("CBH:") else "target", "model chemistry": self.model or "", } for key, title in ( ("E", "E (kJ/mol)"), ("ZPE", "ZPE (kJ/mol)"), ("H0", "H(0 K) (kJ/mol)"), ("H298", "H(298.15 K) (kJ/mol)"), ): value = result.get(key) if isinstance(value, (int, float)) and not isinstance(value, bool): fields[title] = f"{value:.3f}" if "imaginary" in result: fields["imaginary modes"] = str(result["imaginary"]) if "converged" in result: fields["optimization converged"] = "yes" if result["converged"] else "no" body = text.rstrip() if body.endswith("$$$$"): body = body[: -len("$$$$")].rstrip() # A data item ends with a blank line. Stripping removed the one after the # last of SEAMM's own items (its stored properties), so put it back, or a # reader joins that value with the next field. body += "\n" if body.endswith("M END") else "\n\n" body += "".join(f"> <{k}>\n{v}\n\n" for k, v in fields.items()) body += "$$$$\n" try: directory.mkdir(parents=True, exist_ok=True) (directory / f"{label}.sdf").write_text(body) with open(directory / "species.sdf", "a") as fd: fd.write(body) except OSError as e: # pragma: no cover logger.warning(f"Could not write the structure of {can}: {e}") return printer.important( __( f" structure: {label}.sdf" + (f"; final{dihedrals[1:]}" if dihedrals else ""), indent=4 * " ", wrap=False, dedent=False, ) ) def _compute_species_mc(self, _P, can, configuration): """One species through the Model Chemistry: energy only, or optimize + Hessian + harmonic thermochemistry.""" optimize = _yes(_P["optimize fragments"]) steps = int(_P["fragment max steps"]) fmax = _P["fragment convergence"].m_as("eV/Å") if _P["thermal corrections"] != "harmonic": E, converged = self._run_optimization( can, lambda: self.optimize_and_energy( configuration, optimize=optimize, fmax=fmax, max_steps=steps ), optimize, steps, ) printer.important( __( f" enthalpy of formation (from the engine) = {E:.3f} kJ/mol", indent=4 * " ", wrap=False, dedent=False, ) ) self._store_species_energy(configuration, E) self._store_fingerprints(configuration, None, can) result = {"H298": E, "E": E} if converged is not None: result["converged"] = converged return result delta_bohr = Q_(_P["step size"].m_as("Å"), "Å").m_as("bohr") (E0, hessian, how), converged = self._run_optimization( can, lambda: self.optimize_and_hessian( configuration, delta_bohr, optimize=optimize, fmax=fmax, max_steps=steps ), optimize, steps, ) from ase.vibrations import VibrationsData atoms = self.ase_atoms(configuration) n = len(atoms) hessian = Q_(np.asarray(hessian), "hartree/bohr**2").m_as("eV/Å**2") self._vibrations = VibrationsData(atoms, hessian.reshape(n, 3, n, 3)) self._E0 = Q_(E0, "kJ/mol").m_as("eV") printer.important( __(f" Hessian: {how}", indent=4 * " ", wrap=False, dedent=False) ) data = self.harmonic_thermochemistry( _P, [T_REF], "K", [1.0], "bar", print_frequencies=False ) E = Q_(data["total_energy"], "E_h").m_as("kJ/mol") result = { "E": E, "ZPE": data["ZPE"], "H0": E + data["ZPE"], "H298": E + data["H thermal"][0][0], "imaginary": data.get("N saddle modes", 0), } if converged is not None: result["converged"] = converged self._store_species_properties(configuration, data) self._store_fingerprints(configuration, how, can) return result # -- fingerprints of each species (P5a of the fragment store) --------------- # # A program's settings can depend on the species, not only on the model # chemistry: ORCA uses exact exchange (NoCOSX) for lone atoms and for Na, Mg, # Zn, B and P (orca_step#44), so a boron species' revDSD energy has another # fingerprint than methane's. Each species is therefore looked up -- in the # job's database and in the fragment store -- with the fingerprints the # program predicts for IT (seamm_exec.expected_fingerprints). The target's # fingerprints are the fallback only for programs that cannot predict. def _set_frequency_correction(self, _P): """The frequency correction for the geometry level, if it has one: its tag goes into the frequency fingerprints. Without thermal corrections the frequencies are not used, so a correction does nothing; say so.""" self._frequency_correction_tag = None correction, fc_range, _ = self.frequency_correction(_P) if correction is None: # Each species' harmonic analysis says so return if _P.get("thermal corrections", "harmonic") != "harmonic": printer.important( __( f"The frequency correction for {correction.method} " f"({correction.citation}) does nothing here: there are no thermal " "corrections, so the frequencies are not used.", indent=4 * " ", wrap=True, dedent=False, ) ) return self._frequency_correction_tag = correction.tag(fc_range) def _reset_fingerprints(self): """Forget the previous run's fingerprints (a Loop runs this node again).""" self._settings_fingerprint = None self._frequency_fingerprint = None self._fingerprint_version = None self._assumed_version = None self._hessian_how = None def _geometry_mc(self): """The geometry level's ``_model_chemistry`` wrapper, or None (e.g. the subflowchart route, or no flowchart variables).""" try: return self.model_chemistry() except Exception: return None def _expected_fingerprints(self, structure, mc, kind, version=None): """The program's predicted fingerprints of a structure at a model chemistry ([str]), or None when it cannot predict them.""" if mc is None or structure is None: return None # The program's version is hashed: without one from a calculation of # this run (e.g. "existing results" computes nothing) a prediction # would be wrong, so make none version = version or getattr(self, "_fingerprint_version", None) if not version: return None try: from seamm_exec import expected_fingerprints except ImportError: # pragma: no cover - an older seamm-exec return None try: provider = self.flowchart.plugin_manager.get(mc["step"]) except Exception: return None if provider is None: return None return expected_fingerprints( provider, mc, structure, kind=kind, properties=("energy",), version=version, ) @staticmethod def _species_structure(can): """A stand-in structure for a species not yet built: its elements (with the hydrogens), charge and multiplicity, which are all a program's settings depend on; the coordinates are not settings.""" from rdkit import Chem from seamm_exec import Geometry mol = Chem.MolFromSmiles(can) if mol is None: return None mol = Chem.AddHs(mol) numbers = [a.GetAtomicNum() for a in mol.GetAtoms()] return Geometry( numbers, [[0.0, 0.0, 0.0]] * len(numbers), charge=cbh_charge(can), multiplicity=cbh_multiplicity(can), ) def _geometry_level_fingerprints(self, can, configuration=None): """(energy fingerprints, frequency fingerprints) expected for a species at the geometry level, each a list or None. The frequency ones are the analytic Hessian's when the target's Hessian was analytic, else the energy's (finite differences of gradients).""" mc = self._geometry_mc() structure = configuration if structure is None: try: structure = self._species_structure(can) except Exception: # pragma: no cover - RDKit missing or odd SMILES structure = None energy = self._expected_fingerprints(structure, mc, "energy") if getattr(self, "_hessian_how", None) == "analytic": frequency = self._expected_fingerprints(structure, mc, "hessian") else: frequency = energy if energy is None and getattr(self, "_settings_fingerprint", None): energy = [self._settings_fingerprint] if frequency is None and getattr(self, "_frequency_fingerprint", None): frequency = [self._frequency_fingerprint] if frequency is not None: frequency = [self._corrected_fingerprint(fp) for fp in frequency] return energy, frequency def _corrected_fingerprint(self, fingerprint): """A frequency fingerprint with the frequency correction's tag when one is applied, so corrected and uncorrected thermal data never share a key in the fragment store.""" tag = getattr(self, "_frequency_correction_tag", None) if not tag or not fingerprint: return fingerprint return f"{fingerprint}+{tag}" def _check_prediction(self, can, configuration, kind, record, mc=None): """The self-check: warn when a computed species' fingerprint is not one the program predicted, i.e. the prediction has drifted from the program's rules. The species is still used and stored with its own fingerprint; a drift costs reuse, never correctness.""" if not record or not record.get("fingerprint"): return if mc is None: mc = self._geometry_mc() expected = self._expected_fingerprints( configuration, mc, kind, version=record.get("version") ) if expected is None or record["fingerprint"] in expected: return text = ( f" Warning: the {kind} fingerprint of {can} " f"({record['fingerprint'][:15]}...) is not the one predicted for it " f"({', '.join(f[:15] + '...' for f in expected)}), so it will not be " "found in the fragment store by later jobs. Please report this." ) logger.warning(text.strip()) printer.important(__(text, indent=4 * " ", wrap=True, dedent=False)) @staticmethod def _property(configuration, name, units=None): """A property's value (in `units` if given), or None.""" properties = configuration.properties try: if not properties.exists(name): return None values = properties.get(pattern=name, match="exact") except Exception: return None if not values or name not in values: return None value = values[name] if isinstance(value, dict) and "value" in value: value = value["value"] if units is not None and value is not None: stored = properties.units(name) value = Q_(float(value), stored if stored else units).m_as(units) return value def _from_job(self, _P, system_db, can, split): """A species already computed in this job (a system named CBH:<can>, e.g. by an earlier iteration of a Loop), with the fingerprints expected for it at the geometry level, as ({"E", "ZPE", "H0", "H298"}, configuration), or None. `split`: whether the energies are at another level, so that the frequency fingerprint is the key; otherwise the settings fingerprint. A configuration with an imaginary mode above 20i is not reused. """ from seamm_thermochemistry.geometry_store import IMAGINARY_TOLERANCE energy_fps, frequency_fps = self._geometry_level_fingerprints(can) expected = frequency_fps if split else energy_fps if not expected: return None harmonic = _P["thermal corrections"] == "harmonic" model = self.model or "unknown" key = "frequency fingerprint" if split else "settings fingerprint" for system in system_db.get_systems(): if system.name != f"CBH:{can}": continue current = system.configuration candidates = [current] + [ c for c in system.configurations if c.id != current.id ] for configuration in candidates: fingerprint = self._property( configuration, f"{key}#Thermochemistry#{model}" ) if fingerprint not in expected: continue E = self._property( configuration, f"total energy#Thermochemistry#{model}", "kJ/mol" ) if E is None: continue result = {"E": E} if harmonic: ZPE = self._property( configuration, f"ZPE#Thermochemistry#{model}", "kJ/mol" ) H = self._property( configuration, f"H(298.15 K)#Thermochemistry#{model}", "kJ/mol" ) if ZPE is None or H is None: continue applied = self._property( configuration, f"frequency correction#Thermochemistry#{model}" ) if (applied or None) != ( getattr(self, "_frequency_correction_tag", None) or None ): continue frequencies = self._property( configuration, f"frequencies#Thermochemistry#{model}" ) if frequencies and any( -float(f) > IMAGINARY_TOLERANCE for f in frequencies ): continue result.update(ZPE=ZPE, H0=E + ZPE, H298=H) else: result["H298"] = E return result, configuration return None def _energy_level_from_job(self, configuration, level, expected): """The energy level's energy (kJ/mol) already on this configuration with one of the expected fingerprints, or None. The calculation must have run on the configuration's current geometry (its record's geometry fingerprint): a species computed again in place has new coordinates but keeps the old energy-level properties.""" from seamm_thermochemistry.geometry_store import hash_geometry if not expected: return None fingerprint = self._property( configuration, f"settings fingerprint#Thermochemistry#{level}" ) if fingerprint not in expected: return None record = self._property(configuration, f"calculation#Thermochemistry#{level}") if isinstance(record, str): try: record = json.loads(record) except ValueError: record = None if not isinstance(record, dict) or not record.get("geometry fingerprint"): return None here = hash_geometry( configuration.atoms.symbols, configuration.atoms.get_coordinates(fractionals=False), configuration.atoms.atomic_numbers, ) if record["geometry fingerprint"] != here: return None return self._property( configuration, f"total energy#Thermochemistry#{level}", "kJ/mol" ) def _store_fingerprints(self, configuration, how, can=None): """Store the engine's settings fingerprints as the properties ``settings fingerprint``, ``element fingerprints`` and ``calculation`` (and the ``frequency ...`` three for a Hessian: the analytic-Hessian record, or the energy record for finite differences) under ``#Thermochemistry#<model>``, the names the fragment store's harvest reads. Nothing is stored when the engine gives no fingerprints. The first species of a run (the target) also sets the fallback fingerprints, the program's version and the Hessian route; each species' fingerprints are checked against the predicted ones.""" records = getattr(self, "_fingerprints", None) if not records: return energy = records.get("energy") assumed = getattr(self, "_assumed_version", None) if assumed and energy and energy.get("version"): if _major_minor(energy["version"]) != _major_minor(assumed): raise RuntimeError( f"The target was taken from the fragment store, computed with " f"version {assumed} of the program, but this run's calculations " f"are with version {energy['version']}. Set 'Reuse a stored " "target' to 'never' to compute it with this version." ) if energy and energy.get("fingerprint"): if not getattr(self, "_settings_fingerprint", None): self._settings_fingerprint = energy["fingerprint"] if not getattr(self, "_fingerprint_version", None): self._fingerprint_version = energy.get("version") if how is not None and not getattr(self, "_hessian_how", None): self._hessian_how = how label = can or "the species" self._check_prediction(label, configuration, "energy", energy) kinds = [("", energy)] if how is not None: frequency = records.get("hessian") if how == "analytic" else energy if frequency and frequency.get("fingerprint"): if not getattr(self, "_frequency_fingerprint", None): self._frequency_fingerprint = frequency["fingerprint"] if how == "analytic": self._check_prediction(label, configuration, "hessian", frequency) kinds.append( ("frequency ", records.get("hessian") if how == "analytic" else energy) ) model = self.model or "unknown" properties = configuration.properties for prefix, record in kinds: if not record or not record.get("fingerprint"): continue for name, value, kind in ( (f"{prefix}settings fingerprint", record["fingerprint"], "str"), ( f"{prefix}element fingerprints", record.get("element fingerprints", {}), "json", ), (f"{prefix}calculation", record, "json"), ): if prefix and name.startswith("frequency settings"): name = "frequency fingerprint" value = self._corrected_fingerprint(value) full = f"{name}#Thermochemistry#{model}" if not properties.exists(full): properties.add(full, kind, units=None, description=name) properties.put(full, value) def _optimize(self, configuration, _P, can="the species"): """Optimize the structure with ASE's BFGS through the subflowchart, continuing once for as many steps again if it has not converged. Returns whether it converged.""" from ase.optimize import BFGS atoms = self.ase_atoms(configuration) fmax = _P["fragment convergence"].m_as("eV/Å") steps = int(_P["fragment max steps"]) converged = BFGS(atoms, logfile=None).run(fmax=fmax, steps=steps) if not converged: self._say_continuing(can, steps) converged = BFGS(atoms, logfile=None).run(fmax=fmax, steps=steps) configuration.atoms.set_coordinates(atoms.positions, fractionals=False) return bool(converged) def _say_continuing(self, can, steps): printer.important( __( f" The optimization of {can} did not converge in {steps} steps, " f"so it continues for {steps} more.", indent=4 * " ", wrap=False, dedent=False, ) ) def _run_optimization(self, can, run, optimize, steps): """Run an optimization over the Model Chemistry (`run`, e.g. optimize_and_energy) and, if it did not converge, once more from where it stopped. Returns (run's result, converged), converged None when the driver does not say (nothing optimized, or an older seamm-exec).""" self._last_optimization = None result = run() status = self._last_optimization if optimize and status is not None and not status.get("converged", True): self._say_continuing(can, steps) self._last_optimization = None result = run() status = self._last_optimization if not optimize or status is None: return result, None return result, bool(status.get("converged", True)) @staticmethod def _geometry_level_results(usable, reactions, energies, ref_value): """{rung: {"DrH 0 K", "DfH 0 K", "DrH 298.15 K", "DfH 298.15 K"}} at the geometry level, or {} when the energies are at the geometry level. Each species' enthalpy is its geometry-level energy plus the same thermal part (ZPE, H - E, scaled as at the energy level), and the reference values are the same, so this is what a run at the geometry level alone gives.""" if not any("E geometry level" in e for e in energies.values()): return {} results = {} for rung in usable: reaction = reactions[rung] species = reaction.coefficients if not all("E geometry level" in energies[s] for s in species): continue result = {} for key, label in (("H0", "0 K"), ("H298", "298.15 K")): if not all(key in energies[s] and "E" in energies[s] for s in species): continue drH = sum( c * ( energies[s]["E geometry level"] + energies[s][key] - energies[s]["E"] ) for s, c in species.items() ) column = "dfH0_0K" if key == "H0" else "dfH0_298K" values = {s: ref_value(s, column) for s in reaction.references} if any(v is None for v in values.values()): continue ref = sum(c * values[s] for s, c in reaction.references.items()) result[f"DrH {label}"] = drH result[f"DfH {label}"] = ref - drH if result: results[rung] = result return results def _geometry_level_preset(self): """The preset whose result the geometry level's is ('quick' for r2SCAN-3c geometries), or None.""" for name, levels in PRESETS.items(): if levels.get("energy") is None and levels.get("geometry") == self.model: return name return None def _geometry_level_text(self, geometry_results, results, usable, target_entry): """The table of the results at the geometry level.""" rows = [] for rung in usable: r = geometry_results.get(rung) if r is None: continue row = [f"CBH-{rung}"] for key in ("DrH 0 K", "DfH 0 K", "DrH 298.15 K", "DfH 298.15 K"): row.append(f"{r[key]:.2f}" if key in r else "") row.append(f"{results[rung]['uncertainty']:.2f}") if target_entry is not None: for key, column in ( ("DfH 0 K", "dfH0_0K"), ("DfH 298.15 K", "dfH0_298K"), ): if key in r and target_entry[column] is not None: row.append(f"{r[key] - target_entry[column]:+.2f}") else: row.append("") rows.append(row) headers = [ "Rung", "ΔrH(0 K)", "ΔfH°(0 K)", "ΔrH(298.15 K)", "ΔfH°(298.15 K)", "± ref (95%)", ] if target_entry is not None: headers += ["Δ vs ATcT (0 K)", "Δ vs ATcT (298.15 K)"] preset = self._geometry_level_preset() same = f", the result of the '{preset}' preset" if preset else "" text = ( f"\nThe same reactions at the geometry level alone ({self.model}" f"{same}; kJ/mol):\n" ) text += tabulate( rows, headers=headers, tablefmt="rounded_outline", disable_numparse=True, colalign=("left",) + ("decimal",) * (len(headers) - 1), ) return text + "\n" @staticmethod def _storable(can, result): """Whether a computed species may go into the fragment store: not one whose optimization did not converge, nor one whose structure changed.""" if not isinstance(result, dict): return True if result.get("converged") is False or result.get("broken"): why = ( "its optimization did not converge" if result.get("converged") is False else "its structure changed" ) printer.important( __( f" {can} is not added to the fragment store: {why}.", indent=4 * " ", wrap=False, dedent=False, ) ) return False return True @staticmethod def _flags(energies): """The results that flag the species: those whose optimization did not converge and those whose structure changed (fragments).""" unconverged = [ can for can, e in energies.items() if isinstance(e, dict) and e.get("converged") is False ] broken = [ can for can, e in energies.items() if isinstance(e, dict) and e.get("broken") ] return { "CBH unconverged species": ", ".join(unconverged), "CBH changed fragments": ", ".join(broken), } def _check_species(self, can, configuration, result): """After a species is computed: a target whose bonds are no longer its SMILES' has become another molecule, and fails; a fragment that did so, or that did not converge, is flagged ("broken", "converged") and kept out of the fragment store.""" problem = None try: from seamm_thermochemistry.fragment_store import check_structure except ImportError: # pragma: no cover - an older seamm-thermochemistry check_structure = None if check_structure is not None and configuration.n_atoms > 1: try: problem = check_structure( configuration.atoms.symbols, configuration.atoms.get_coordinates(fractionals=False), can, cbh_charge(can), cbh_multiplicity(can), ) except Exception as e: # the check must not fail a good species logger.warning(f"Could not check the structure of {can}: {e}") name = configuration.system.name if configuration.system else "" target = not str(name).startswith("CBH:") if problem: if target: raise RuntimeError( f"The structure of {can} changed while it was optimized " f"({problem}), so its energy is that of another molecule." ) result["broken"] = problem printer.important( __( f" Warning: the structure of the fragment {can} changed while " f"it was optimized ({problem}). Its energy is used, but it is " "not added to the fragment store.", indent=4 * " ", wrap=True, dedent=False, ) ) if result.get("converged") is False: printer.important( __( f" Warning: the optimization of {can} did not converge, even " "after continuing. Its energy is used and flagged in the " "results, and it is not added to the fragment store.", indent=4 * " ", wrap=True, dedent=False, ) ) def _store_species_energy(self, configuration, E): """Store the engine's energy (kJ/mol) of a species computed without thermal corrections as ``total energy#Thermochemistry#<model>``, the property the fragment store's harvest reads, so that write-back keeps it and later species and runs reuse it. With MOPAC it is the enthalpy of formation, which such a run takes as the enthalpy at 298.15 K; it is stored with no ZPE or H, so no harmonic run takes it for one.""" if not self.model: return # no level to store it under name = f"total energy#Thermochemistry#{self.model}" properties = configuration.properties if not properties.exists(name): properties.add(name, "float", units="E_h", description="E") properties.put(name, Q_(E, "kJ/mol").m_as("E_h")) def _store_species_properties(self, configuration, data): """Store E, ZPE and H(298.15 K) on the configuration.""" model = self.model or "unknown" properties = configuration.properties for name, value, units, description in ( (f"total energy#Thermochemistry#{model}", data["total_energy"], "E_h", "E"), (f"ZPE#Thermochemistry#{model}", data["ZPE"], "kJ/mol", "ZPE"), ( f"H(0 K)#Thermochemistry#{model}", Q_(data["total_energy"], "E_h").m_as("kJ/mol") + data["ZPE"], "kJ/mol", "H(0 K)", ), ( f"H(298.15 K)#Thermochemistry#{model}", Q_(data["total_energy"], "E_h").m_as("kJ/mol") + data["H thermal"][0][0], "kJ/mol", "H(298.15 K)", ), ): if not properties.exists(name): properties.add(name, "float", units=units, description=description) properties.put(name, value) # For the fragment store's geometry rows: the frequencies (imaginary ones # negative), the rotational symmetry number and the moments of inertia extras = [ ("frequencies", data.get("vibrational frequencies"), "json", "cm^-1"), ("symmetry number", data.get("symmetry number"), "int", None), # A frequency correction (e.g. PM7's): the frequencies the ZPE and # H(298.15 K) above were computed from, and which correction ( "corrected frequencies", data.get("corrected vibrational frequencies"), "json", "cm^-1", ), ("frequency correction", data.get("frequency correction"), "str", None), ( "frequency correction status", data.get("frequency correction status"), "str", None, ), ("ZPE uncorrected", data.get("ZPE uncorrected"), "float", "kJ/mol"), ] try: moments = self.ase_atoms(configuration).get_moments_of_inertia().tolist() extras.append(("moments of inertia", moments, "json", "amu*Å^2")) except Exception: # pragma: no cover pass for base, value, kind, units in extras: if value is None: continue name = f"{base}#Thermochemistry#{model}" if kind == "json": value = [float(v) for v in value] if not properties.exists(name): properties.add(name, kind, units=units, description=base) properties.put(name, value) def _energies_from_properties(self, _P, configuration, can): """E, ZPE and H(298.15 K) from the configuration's properties.""" properties = configuration.properties result = {} def get(name): if name in ("", "none"): return None values = properties.get(pattern=name, match="glob") if not values: return None if len(values) > 1: raise RuntimeError( f"Several properties of {can} match '{name}': {sorted(values)}" ) (key, value), *_ = values.items() if isinstance(value, dict): # {"value": ..., "sid": ..., "cid": ...} value = value["value"] units = properties.units(key) return Q_(float(value), units if units else "kJ/mol").m_as("kJ/mol") E = get(_P["energy property"]) if E is None: raise RuntimeError( f"No property matching '{_P['energy property']}' on the system for " f"{can}." ) result["E"] = E if _P["thermal corrections"] != "harmonic": result["H298"] = E return result ZPE = get(_P["ZPE property"]) H = get(_P["enthalpy property"]) E_low = get(_P["low-level energy property"]) if E_low is None: E_low = E if ZPE is not None: result["ZPE"] = ZPE result["H0"] = E + ZPE if H is not None: result["H298"] = E + (H - E_low) return result def _cite_cbh(self, tables): for key, alias, note in ( ("Ruscic2004", "ATcT", "The Active Thermochemical Tables."), ("Ruscic2005", "ATcT2005", "The Active Thermochemical Tables."), ("Ramabhadran2011", "CBH", "The connectivity-based hierarchy."), ( "Raghavachari2023", "CBH2023", "The graph formulation of the connectivity-based hierarchy.", ), ): if key in self._bibliography: self.references.cite( raw=self._bibliography[key], alias=alias, module="thermochemistry_step", level=1, note=note, ) version = tables.version raw = ( "@misc{ATcTvalues,\n" " author = {Ruscic, Branko and Bross, David H.},\n" " title = {Active Thermochemical Tables (ATcT) values based on ver. " f"{version} of the Thermochemical Network}},\n" " publisher = {Argonne National Laboratory},\n" " address = {Lemont, Illinois, USA},\n" " howpublished = {available at ATcT.anl.gov},\n" "}" ) self.references.cite( raw=raw, alias=f"ATcT-{version}", module="thermochemistry_step", level=1, note="The ATcT values used as references.", )
[docs] def rung_spread(results, usable): """(spread, rungs): the range of ΔfH°(298.15 K) over the usable rungs from CBH-1 up, and those rungs; (None, rungs) with fewer than two.""" rungs = [r for r in usable if r >= 1 and "DfH 298.15 K" in results[r]] if len(rungs) < 2: return None, rungs values = [results[r]["DfH 298.15 K"] for r in rungs] return max(values) - min(values), rungs
[docs] def species_only(P): """Whether the CBH approach computes only the target, for the store.""" return str(P.get("cbh calculation", "enthalpy of formation")).startswith( "the species only" )
#: The largest heavy-atom RMSD (Å) between a target's starting structure and a #: stored geometry for the stored one to be taken as this target's (the same #: conformer optimized: typically 0.01-0.1 Å from another level's structure) TARGET_MATCH_RMSD = 0.3
[docs] def match_structure(symbols, start, stored_symbols, stored, max_rmsd=TARGET_MATCH_RMSD): """Whether a stored geometry is the given structure optimized: (ok, heavy-atom RMSD in Å, reason when not). Strict on purpose: the same atoms in the same order, the same bonds (perceived from each geometry, atom by atom), and the heavy atoms within `max_rmsd` of each other after the best rotation and translation, with no reflection, so a different conformer, diastereomer or arrangement of the enantiomer is never taken.""" if list(symbols) != list(stored_symbols): return False, None, "other atoms or atom order" a = np.asarray(start, dtype=float).reshape(-1, 3) b = np.asarray(stored, dtype=float).reshape(-1, 3) try: from seamm_thermochemistry.fragment_store import _perceive bonds = [] for xyz in (a, b): mol = _perceive(symbols, xyz) if mol is None: return False, None, "bonds could not be perceived" bonds.append( { tuple(sorted((bd.GetBeginAtomIdx(), bd.GetEndAtomIdx()))) for bd in mol.GetBonds() } ) except ImportError: # pragma: no cover - RDKit missing: no reuse return False, None, "no RDKit to compare the bonds" if bonds[0] != bonds[1]: return False, None, "other bonds" heavy = [i for i, s in enumerate(symbols) if s != "H"] or list(range(len(a))) rmsd = kabsch_rmsd(a[heavy], b[heavy]) if rmsd > max_rmsd: return False, rmsd, f"heavy-atom RMSD {rmsd:.2f} Å, another conformer" return True, rmsd, None
[docs] def kabsch_rmsd(a, b): """The RMSD of two sets of points after the best proper rotation and translation (no reflection).""" a = np.asarray(a, dtype=float) - np.mean(a, axis=0) b = np.asarray(b, dtype=float) - np.mean(b, axis=0) if len(a) < 2: return 0.0 h = a.T @ b u, _, vt = np.linalg.svd(h) d = np.sign(np.linalg.det(vt.T @ u.T)) or 1.0 rotation = vt.T @ np.diag([1.0, 1.0, d]) @ u.T diff = (rotation @ a.T).T - b return float(np.sqrt((diff**2).sum() / len(a)))
def _stored_versions(store, species): """The program versions of the verified geometries of a species in the store's layers.""" layers = ( [layer for _, layer in store._layers()] if hasattr(store, "_layers") else [store] ) versions = set() for layer in layers: try: db = layer._connect() for (version,) in db.execute( "SELECT DISTINCT version FROM geometry WHERE species = ? AND " "verified = 1", (species,), ): if version: versions.add(version) except Exception: # a layer without the file or the table continue return versions def _major_minor(version): return ".".join(str(version or "").split(".")[:2]) def _merge_counts(total, part): """Add one harvest's tallies (seamm_thermochemistry's ``new_counts``) to another's.""" for key, value in part.items(): if isinstance(value, list): total[key].extend(value) elif isinstance(value, dict): for outcome, n in value.items(): total[key][outcome] = total[key].get(outcome, 0) + n else: total[key] += value def _any_rows(counts): """Whether a harvest touched any row (inserted, found or logged one).""" return any( counts[k] for k in ("inserted", "duplicate", "logged", "superseded") ) or any(counts[t] for t in ("geometry", "energy")) def _yes(value): """A yes/no parameter, which arrives as a bool or a string.""" if isinstance(value, bool): return value return str(value).strip().lower() in ("yes", "true", "1", "on")
[docs] def cbh_charge(smiles): from seamm_thermochemistry.cbh import _composition return _composition(smiles)[1]
[docs] def cbh_multiplicity(smiles): """The spin multiplicity of a CBH species: its ground state, from the tables of singlets and triplets that a SMILES does not show (SiCl2 and BH are singlets, O2 and OCCO triplets), else its unpaired electrons + 1.""" from seamm_thermochemistry import cbh return cbh.ground_state_multiplicity(smiles)
def _unused(): # pragma: no cover return Counter, math def _reference_cell(entry, conversion, temperature, column): """A reference value for the species table: blank if none, starred if converted from the other temperature with the computed H298 - H0.""" if not entry: return "" if conversion is not None and conversion[0] == temperature: return f"{conversion[1]:.3f}*" value = entry.get(column) return "" if value is None else f"{value:.3f}"