"""
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