Source code for icet.core.local_structure_container

"""
This module provides the LocalStructureContainer class.
"""

import gzip
import json

import numpy as np

from ase import Atoms

from icet.core.base_structure_container import BaseStructureContainer
from icet.input_output.repr_tools import (html_representation,
                                          text_representation)
from icet.core.local_cluster_expansion import encode_numpy
from icet.core.local_cluster_space import LocalClusterSpace


def _structure_to_dict(structure: Atoms) -> dict:
    """Returns the plain-array representation of a structure."""
    return {'cell': np.array(structure.cell).tolist(),
            'positions': structure.positions.tolist(),
            'numbers': structure.get_atomic_numbers().tolist(),
            'pbc': [bool(p) for p in structure.pbc]}


def _structure_from_dict(dct: dict) -> Atoms:
    """Reconstructs a structure from its plain-array representation."""
    return Atoms(cell=dct['cell'],
                 positions=dct['positions'],
                 numbers=dct['numbers'],
                 pbc=dct['pbc'])


class FitEvent:
    """A single observation for the fitting of a local cluster expansion.

    One supercell commonly hosts several observations (i.e. possible hop events).
    A row therefore represents a structure together with the sites of the event
    instance it belongs to.

    Parameters
    ----------
    structure
        Supercell containing the event instance.
    event_sites
        Indices of the anchor sites in the supercell.
    local_cluster_vector
        Local cluster vector of the event instance.
    properties
        Scalar properties of the observation, for example the kinetically
        resolved activation (KRA) energy under the key ``E_kra``.
    """

    def __init__(self,
                 structure: Atoms,
                 event_sites: tuple,
                 local_cluster_vector: np.ndarray,
                 properties: dict) -> None:
        self.structure = structure
        self.event_sites = tuple(int(site) for site in event_sites)
        self.local_cluster_vector = np.array(local_cluster_vector)
        self.properties = dict(properties)

    @property
    def feature_vector(self) -> np.ndarray:
        """Feature vector of the row, which is the local cluster vector."""
        return self.local_cluster_vector

    def __len__(self) -> int:
        """ Number of sites in the structure. """
        return len(self.structure)

    def _get_rows(self) -> list:
        """Label and value pairs describing the event and its properties.
        The properties are in alphabetical order, as the columns of the table
        of a container are, since an event need not carry the properties of
        the one beside it.
        """
        rows = [('structure', f'{self.structure.get_chemical_formula()}'
                              f' ({len(self)} sites)'),
                ('event sites', self.event_sites),
                ('total number of parameters', len(self.local_cluster_vector))]
        rows += sorted(self.properties.items())
        return rows

    def __str__(self) -> str:
        """ String representation. """
        return text_representation(title='Fit Event', rows=self._get_rows(),
                                   width=60, label_width=26)

    def __repr__(self) -> str:
        """ Representation. """
        return (f'{type(self).__name__}(event_sites={self.event_sites!r},'
                f' number_of_sites={len(self)})')

    def _repr_html_(self) -> str:
        """ HTML representation. Used, e.g., in jupyter notebooks. """
        return html_representation(title='Fit Event', rows=self._get_rows())


[docs] class LocalStructureContainer(BaseStructureContainer): """A container for the training data of a local cluster expansion. Each row of the container is an event observation, meaning a structure, the sites of the event instance within it, and a dict of scalar properties. The properties are locally defined energy differences in which supercell size and reference energies cancel, for example the kinetically resolved activation energy of a hop. Usually absolute or per-atom energies should not be kept in this kind of container. Parameters ---------- local_cluster_space Local cluster space in which the observations are expressed. Example ------- The following snippet illustrates the intended workflow. One observation is added per event instance, and the container is then queried for the sensing matrix and the target vector of one property:: >>> from ase.build import bulk >>> from icet import (HopEvent, LocalClusterSpace, ... LocalStructureContainer) >>> primitive_structure = bulk('Al', a=4.0) >>> event = HopEvent(site_indices=(0, 0), ... offsets=((0, 0, 0), (1, 0, 0)), ... species='Al') >>> local_cluster_space = LocalClusterSpace( ... primitive_structure, event, [['Al', 'X']], ... environment_cutoff=3.0) >>> container = LocalStructureContainer(local_cluster_space) >>> supercell = bulk('Al', a=4.0, cubic=True).repeat(2) >>> supercell[0].symbol = 'X' >>> container.add_event(supercell, (1, 2), ... properties={'E_kra': 0.42}) >>> container.add_event(supercell, (4, 5), ... properties={'E_kra': 0.51}) >>> matrix, targets = container.get_fit_data(key='E_kra') >>> print(matrix.shape, targets) (2, 5) [0.42 0.51] The target of each observation comes from an external calculation, for example a nudged elastic band run on the supercell. """ _title = 'Local Structure Container' _tag_attribute = 'event_sites' _tag_title = 'event sites' def __init__(self, local_cluster_space: LocalClusterSpace) -> None: super().__init__() self._local_cluster_space = local_cluster_space @property def local_cluster_space(self) -> LocalClusterSpace: """Local cluster space in which the observations are expressed.""" return self._local_cluster_space
[docs] def add_event(self, structure: Atoms, event_sites: tuple, properties: dict) -> None: """Adds an event observation to the container. The local cluster vector of the event instance is computed and stored when the observation is added. A write of the container keeps the cell, the positions, the atomic numbers and the periodic boundary conditions of the structure. Parameters ---------- structure Supercell containing the event instance. event_sites Indices of the anchor sites in the supercell. properties Scalar properties of the observation. Every value must be a finite real number. The values are fit targets, and a single non-finite one turns the whole parameter vector of the fit into NaN without an error anywhere. """ if not isinstance(properties, dict) or not properties: raise ValueError('properties must be a non-empty dict') for key, value in properties.items(): if isinstance(value, bool) or not isinstance( value, (int, float, np.integer, np.floating)): raise ValueError(f'Property {key} must be a real number' f' ({value!r})') if not np.isfinite(value): raise ValueError(f'Property {key} must be finite ({value})') vector = self._local_cluster_space.get_local_cluster_vector( structure, event_sites) self._rows.append(FitEvent(structure.copy(), event_sites, vector, properties))
def _get_rows(self) -> list: """Returns the label and value pairs that summarize the container.""" return [('total number of events', len(self)), ('total number of parameters', len(self._local_cluster_space))]
[docs] def write(self, filename: str) -> None: """Writes the container to a gzip-compressed JSON file. Parameters ---------- filename Name of the file to which to write. """ events = [] for event in self: row = _structure_to_dict(event.structure) row.update({ 'event_sites': list(event.event_sites), 'local_cluster_vector': event.local_cluster_vector.tolist(), 'properties': event.properties}) events.append(row) data = {'type': type(self).__name__, 'local_cluster_space': self._local_cluster_space.to_dict(), 'events': events} # Serialization happens before the file is opened, with numpy # property values converted to plain numbers, so a failure raises # without truncating a previously saved container. payload = json.dumps(data, ensure_ascii=False, default=encode_numpy) with gzip.open(filename, 'wt', encoding='utf-8') as handle: handle.write(payload)
[docs] @classmethod def read(cls, filename: str) -> 'LocalStructureContainer': """Reads a container from a gzip-compressed JSON file. Parameters ---------- filename Name of the file from which to read. """ with gzip.open(filename, 'rt', encoding='utf-8') as handle: data = json.load(handle) if data.get('type') != cls.__name__: raise ValueError(f'{filename} does not contain a {cls.__name__}') container = cls(LocalClusterSpace.from_dict(data['local_cluster_space'])) for row in data['events']: if len(row['local_cluster_vector']) != len(container.local_cluster_space): raise ValueError( f'{filename} carries a local cluster vector of length' f" {len(row['local_cluster_vector'])} while the local" f' cluster space it stores has' f' {len(container.local_cluster_space)} elements, so the' ' two do not describe one model') container._rows.append(FitEvent( _structure_from_dict(row), tuple(row['event_sites']), np.array(row['local_cluster_vector']), row['properties'])) return container