Source code for rxn_network.entries.experimental

"""Implements an Entry that looks up NIST pre-tabulated Gibbs free energies."""

from __future__ import annotations

import hashlib
import math
from typing import TYPE_CHECKING

from monty.json import MontyDecoder
from pymatgen.analysis.phase_diagram import GrandPotPDEntry
from pymatgen.entries.computed_entries import ComputedEntry, EnergyAdjustment
from scipy.interpolate import interp1d

from rxn_network.core import Composition
from rxn_network.utils.funcs import get_logger

logger = get_logger(__name__)


if TYPE_CHECKING:
    from pymatgen.core.periodic_table import Element


[docs] class ExperimentalReferenceEntry(ComputedEntry): """An Entry class for experimental reference data, to be sub-classed for specific data sources. Given a composition, automatically finds the Gibbs free energy of formation, dGf(T) from tabulated reference values. """ REFERENCES: dict = {} DEPRECATED: list = [] def __init__( self, composition: Composition, temperature: float, energy_adjustments: list[EnergyAdjustment] | None = None, data: dict | None = None, ): """ Args: composition: Composition object temperature: Temperature in Kelvin. If temperature is not selected within the range of the reference data (see self._validate_temperature), then this will raise an error. energy_adjustments: A list of EnergyAdjustments to apply to the entry. data: Optional dictionary containing entry data. """ self.is_deprecated = composition.reduced_formula in self.DEPRECATED formula = composition.reduced_formula entry_id = f"{self.__class__.__name__}-{formula}_{temperature}" self._validate_temperature(formula, temperature) self._temperature = temperature energy = self._get_energy(formula, temperature) super().__init__( composition, energy, energy_adjustments=energy_adjustments, data=data, entry_id=entry_id, ) self._composition = composition self.name = formula if self.is_deprecated: self.name += " (deprecated)"
[docs] def get_new_temperature(self, new_temperature: float) -> ExperimentalReferenceEntry: """Return a copy of the NISTReferenceEntry at the new specified temperature. Args: new_temperature: The new temperature to use [K] Returns: A copy of the NISTReferenceEntry at the new specified temperature. """ new_entry_dict = self.as_dict() new_entry_dict["temperature"] = new_temperature return self.from_dict(new_entry_dict)
[docs] def to_grand_entry(self, chempots: dict[Element, float]): """Convert an ExperimentalReferenceEntry to a GrandComputedEntry. Args: chempots: A dictionary of {element: chempot} pairs. Returns: A GrandComputedEntry. """ return GrandPotPDEntry(self, chempots)
def _validate_temperature(self, formula: str, temperature: float) -> None: """Ensure that the temperature is from a valid range.""" if self.is_deprecated: logger.warning(f"{formula} is deprecated! Using a formation energy of 0.0 eV.") return # skip validation if formula not in self.REFERENCES: raise ValueError(f"{formula} not in reference data!") g = self.REFERENCES[formula] if temperature < min(g) or temperature > max(g): raise ValueError(f"Temperature must be selected from range: {min(g)} K to {max(g)} K") def _get_energy(self, formula: str, temperature: float) -> float: """Convenience method for accessing and interpolating experimental data. Args: formula: Chemical formula by which to search experimental data. temperature: Absolute temperature [K]. Returns: Gibbs free energy of formation of formula at specified temperature [eV] """ if self.is_deprecated: return 0.0 data = self.REFERENCES[formula] if temperature % 100 > 0: g_interp = interp1d(list(data.keys()), list(data.values())) return g_interp(temperature)[()] return data[temperature] @property def temperature(self) -> float: """Returns temperature used to calculate entry's energy.""" return self._temperature @property def is_experimental(self) -> bool: """Returns True by default.""" return True @property def is_element(self) -> bool: """Returns True if the entry is an element.""" return self.composition.is_element @property def unique_id(self) -> str: """Returns a unique ID for the entry based on its entry-id and temperature. This is useful because the same entry-id can be used for multiple entries at different temperatures. """ return self.entry_id
[docs] def as_dict(self) -> dict: """Returns: A dict representation of the Entry. """ d = super().as_dict() d["temperature"] = self.temperature del d["energy"] del d["entry_id"] del d["parameters"] del d["correction"] return d
[docs] @classmethod def from_dict(cls, d: dict) -> ExperimentalReferenceEntry: """Generate an ExperimentalReferenceEntry object from a dictionary. Args: d: dictionary representation of the entry Returns: ExperimentalReferenceEntry object initialized with data from the dictionary. """ dec = MontyDecoder() return cls( composition=Composition(d["composition"]), temperature=d["temperature"], energy_adjustments=dec.process_decoded(d["energy_adjustments"]), data=d["data"], )
def __repr__(self) -> str: output = [] if self.is_deprecated: output.append("DEPRECATED") output.extend( [ f"{self.__class__.__name__} | {self.composition.reduced_formula}", f"Gibbs Energy ({self.temperature} K) = {self.energy:.4f}", ] ) return "\n".join(output) def __eq__(self, other) -> bool: """Note: the value of the energy correction must be compared rather than the object due to equality checking in EnergyAdjustment. """ if isinstance(other, self.__class__): return ( (self.composition.reduced_formula == other.composition.reduced_formula) and (self.temperature == other.temperature) and math.isclose(self.correction_per_atom, other.correction_per_atom) ) return False def __hash__(self) -> int: data_md5 = hashlib.md5( # nosec f"{self.__class__.__name__}{self.composition}_{self.temperature}".encode() ).hexdigest() return int(data_md5, 16)