Source code for autojob.tasks.raman

"""Store the results of a Raman calculation.

This module defines the :class:`~autojob.tasks.raman.Raman`,
:class:`~autojob.tasks.raman.RamanInputs`, and
:class:`~autojob.tasks.raman.RamanOutputs` classes. Instances
of these classes represent the results of a Raman calculation, its inputs,
and its outputs, respectively.

For building the respective documents from a folder, the
:class:`~autojob.tasks.raman.RamanInputs` and
:class:`~autojob.tasks.raman.RamanOutputs` classes are
:class:`PathLoadable`.

Examples:
    Create a Raman calculation

    .. code-block:: python

        from ase.build import add_adsorbate, fcc100, molecule

        from autojob.tasks.task import TaskInputs
        from autojob.tasks.raman import Raman

        slab = fcc100("Cu")
        co = molecule("CO")
        # All atoms with this tag will NOT be frozen
        tag = -99
        co.set_tags([tag] * len(co))
        add_adsorbate(slab, co, 1.8)

        # Freeze the atoms that should not be displaced during the IR
        # analysis
        freeze_atoms(slab, [tag])

        # Create the infrared calculation
        tinputs = TaskInputs(atoms=slab)
        raman = Raman(task_inputs=tinputs)

    Load the results of Raman calculation

    .. code-block:: python

        from autojob.tasks.raman import Raman

        src = "path/to/calculation/directory"
        results = Raman.from_directory(src)
"""

import json
import logging
from pathlib import Path
import sys
from typing import Any
from typing import ClassVar
from typing import Self

if sys.version_info < (3, 12):
    pass
else:
    pass

import ase.io
from ase.vibrations.placzek import PlaczekStatic as Raman_ASE
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import Field

from autojob import SETTINGS
from autojob.tasks.vibration import Vibration

logger = logging.getLogger(__name__)


[docs] class RamanInputs(BaseModel): """The inputs of an Raman calculation.""" exext: str = Field( default=".alpha", description="The extension for excitation filenames.", ) exobj: str | None = Field( default=None, description="The fully qualified name of the callable to be passed to " "the ase.vibrations.raman.StaticRamanCalculator constructor.", ) txt: str = Field( default="-", description="The output stream. Use '-' for standard output.", ) verbose: bool = Field( default=False, description="The verbosity level of the Raman output.", ) exkwargs: dict[str, Any] | None = Field( default=None, description="Keyword arguments for the callable specified by exobj.", ) intensities_file: str = Field( default="raman_intensities.json", description="The file in which to save the intensities.", ) ir: bool = Field( default=False, description="Whether or not to save dipole information during the vibrational calculation", )
def _construct_raman_data( dir_name: Path, name: str, intensities_file: str ) -> None: atoms = ase.io.read(Path(dir_name, SETTINGS.OUTPUT_ATOMS_FILE)) atoms = atoms[-1] if isinstance(atoms, list) else atoms cache = Path(dir_name, name) # ASE names forces caches cache-.iXN.json where i is an atom index, # X is direction (x/y/z) and N is number of steps nfree = 4 if list(cache.glob("cache.*--.json")) else 2 raman = Raman_ASE(atoms, name=cache, nfree=nfree) raman_energies = raman.get_energies().real raman_intensities = raman.get_absolute_intensities() raman_intensities = raman_intensities[raman_energies.argsort()] raman_energies.sort() raman_json = Path(dir_name, intensities_file) data = { "raman_energies": raman_energies.tolist(), "raman_intensities": raman_intensities.tolist(), } with raman_json.open(mode="w", encoding="utf-8") as file: _ = json.dump(data, file, indent=4)
[docs] class RamanOutputs(BaseModel): """The outputs of an infrared calculation.""" raman_energies: list[float] | None = Field( default=None, description="The Raman spectrum frequencies in sqrt(amu) * Angstrom.", ) raman_intensities: list[float] | None = Field( default=None, description="The absolute intensities of the Raman spectrum.", ) model_config: ClassVar[ConfigDict] = ConfigDict(populate_by_name=True)
[docs] @classmethod def from_directory( cls, src: str | Path, *, name: str = "ir", intensities_file: str = "intensities.json", strict_mode: bool | None = None, ) -> "RamanOutputs": """Extract Raman outputs from a task directory. Args: src: The directory of the Raman calculation. name: The name used to write vibration calculation results. This should either be the name of the directory containing the cache files or the filename stem for a JSON containing the output of a vibration calculation such as that produced by :meth:`~VibrationData.write`. intensities_file: The name of the JSON file used to write Raman intensities. strict_mode: Whether or not to require all outputs. If True, errors will be thrown on missing outputs. Returns: An instance of :class:`.RamanOutputs`. """ if strict_mode is None: strict_mode = SETTINGS.STRICT_MODE logger.info("Loading Raman outputs from directory: %s", src) logger.debug("Strict mode: %sabled", "en" if strict_mode else "dis") raman_outputs = {} try: logger.info("Successfully Raman outputs from directory: %s", src) raman_file = Path(src, f"{intensities_file}") if not raman_file.exists(): _construct_raman_data(src, name, intensities_file) with raman_file.open(mode="r", encoding="utf-8") as file: raman_outputs = json.load(file) except (FileNotFoundError, KeyError, RuntimeError, TypeError): logger.exception( "Unable to load Raman outputs from directory: %s", src ) if strict_mode: raise return cls(**raman_outputs)
[docs] class Raman(Vibration): """A record representing a Raman calculation.""" raman_inputs: RamanInputs = Field( default_factory=RamanInputs, description="The inputs of the Raman calculation", ) raman_outputs: RamanOutputs | None = Field( default=None, description="The Raman calculation outputs" )
[docs] @classmethod def from_directory(cls, src: str | Path, **kwargs) -> Self: """Generate a ``Raman`` document from a calculation directory. Args: src: The directory of a calculation. kwargs: Additional keyword arguments: - strict_mode : Whether or not to fail on any error. Defaults to `SETTINGS.STRICT_MODE`. - magic_mode : Whether or not to instantiate subclasses. If True, the task returned must be an instance determined by metadata in the directory. Defaults to False. Returns: A :class:`Raman` or a subclass of a :class:`Raman`. .. seealso:: :meth:`.vibration.Vibration.from_directory` """ strict_mode = kwargs.get("strict_mode", SETTINGS.STRICT_MODE) magic_mode = kwargs.get("magic_mode", False) logger.debug("Loading Raman calculation from directory: %s", src) logger.debug("Magic mode: %sabled", "en" if magic_mode else "dis") logger.debug("Strict mode: %sabled", "en" if strict_mode else "dis") if magic_mode: return cls.load_magic(src, strict_mode=strict_mode) vibration = Vibration.from_directory( src=src, strict_mode=strict_mode, magic_mode=False ) data = vibration.task_inputs.model_extra.pop("raman_inputs", {}) raman_inputs = RamanInputs(**data) raman_outputs = RamanOutputs.from_directory( src=src, name=vibration.vibration_inputs.name, intensities_file=raman_inputs.intensities_file, strict_mode=strict_mode, ) logger.debug( "Successfully loaded Raman calculation from directory: %s", src ) return cls( **vibration.model_dump(), raman_inputs=raman_inputs, raman_outputs=raman_outputs, )
[docs] def write_inputs_json( self, dest: str | Path, *, additional_data: dict[str, Any] | None = None, ) -> Path: """Write the inputs JSON to a file. Args: dest: The directory in which to write the inputs JSON. additional_data: A dictionary mapping strings to JSON-serializable values to be merged with the Raman inputs that will be written to the inputs JSON. Defaults to an empty dictionary. Returns: The filename in which the inputs JSON written. """ additional_data = additional_data or {} additional_data = { "raman_inputs": self.raman_inputs.model_dump(mode="json"), **additional_data, } return super().write_inputs_json(dest, additional_data=additional_data)