Source code for icet.core.structure_container

"""
This module provides the StructureContainer class.
"""
import tarfile
import tempfile

from typing import Any, BinaryIO, TextIO

import numpy as np
import ase.db
from ase import Atoms

from icet import ClusterSpace
from icet.core.base_structure_container import BaseStructureContainer
from icet.input_output.logging_tools import logger
from icet.input_output.repr_tools import (MAXIMUM_TABLE_ROWS,
                                          html_representation,
                                          html_table,
                                          text_representation,
                                          truncated_indices)
logger = logger.getChild('structure_container')


[docs] class StructureContainer(BaseStructureContainer): """This class serves as a container for structure objects as well as their properties and cluster vectors. Parameters ---------- cluster_space Cluster space used for evaluating the cluster vectors. Example ------- The following snippet illustrates the initialization and usage of a :class:`StructureContainer` object. A structure container provides convenient means for compiling the data needed to train a cluster expansion, i.e., a sensing matrix and target property values:: >>> from ase.build import bulk >>> from icet import ClusterSpace, StructureContainer >>> from icet.tools import enumerate_structures >>> from random import random >>> # create cluster space >>> prim = bulk('Au') >>> cs = ClusterSpace(prim, cutoffs=[7.0, 5.0], ... chemical_symbols=[['Au', 'Pd']]) >>> # build structure container >>> sc = StructureContainer(cs) >>> for structure in enumerate_structures(prim, range(5), ['Au', 'Pd']): >>> sc.add_structure(structure, ... properties={'my_random_energy': random()}) >>> print(sc) >>> # fetch sensing matrix and target energies >>> A, y = sc.get_fit_data(key='my_random_energy') """ def __init__(self, cluster_space: ClusterSpace) -> None: if not isinstance(cluster_space, ClusterSpace): raise TypeError('cluster_space must be a ClusterSpace object.') super().__init__() self._cluster_space = cluster_space
[docs] def get_structure_indices(self, user_tag: str | None = None) -> list[int]: """ Returns indices of structures with the given user tag. This method provides a simple means for filtering structures. The :attr:`user_tag` is assigned when adding structures via the :func:`add_structure` method. Parameters ---------- user_tag The indices of structures with this user tag are returned. Returns ------- List of structure indices. """ return [i for i, s in enumerate(self) if user_tag is None or s.user_tag == user_tag]
[docs] def add_structure(self, structure: Atoms, user_tag: str | None = None, properties: dict | None = None, allow_duplicate: bool = True, sanity_check: bool = True) -> None: """ Adds a structure to the structure container. Parameters ---------- structure Atomic structure to be added. user_tag User tag for labeling structure. properties Scalar properties. If properties are not specified the structure object will be checked for an attached ASE calculator object with a calculated potential energy. allow_duplicate Whether or not to add the structure if there already exists a structure with identical cluster vector. By default such a structure is added. sanity_check Whether or not to carry out a sanity check before adding the structure. This includes checking occupations and volume. By default the sanity check is carried out. """ # structure must have a proper format and label if not isinstance(structure, Atoms): raise TypeError(f'structure must be an ASE Atoms object not {type(structure)}') if user_tag is not None: if not isinstance(user_tag, str): raise TypeError(f'user_tag must be a string not {type(user_tag)}.') if sanity_check: self._cluster_space.assert_structure_compatibility(structure) # check for properties in attached calculator if properties is None: properties = {} if structure.calc is not None: if not structure.calc.calculation_required(structure, ['energy']): energy = structure.get_potential_energy() properties['energy'] = energy / len(structure) # check if there exist structures with identical cluster vectors structure_copy = structure.copy() cv = self._cluster_space.get_cluster_vector(structure_copy) if not allow_duplicate: for i, fs in enumerate(self): if np.allclose(cv, fs.cluster_vector): msg = '{} and {} have identical cluster vectors'.format( user_tag if user_tag is not None else 'Input structure', fs.user_tag if fs.user_tag != 'None' else 'structure') msg += ' at index {}'.format(i) raise ValueError(msg) # add structure structure = FitStructure(structure_copy, user_tag, cv, properties) self._rows.append(structure)
@property def cluster_space(self) -> ClusterSpace: """ Cluster space used to calculate the cluster vectors. """ return self._cluster_space
[docs] def write(self, outfile: str | BinaryIO | TextIO) -> None: """ Writes structure container to a file. Parameters ---------- outfile Output file name or file object. """ # Write cluster space to tempfile temp_cs_file = tempfile.NamedTemporaryFile(delete=False) self.cluster_space.write(temp_cs_file.name) # Write fit structures as an ASE db in tempfile temp_db_file = tempfile.NamedTemporaryFile(delete=False) temp_db_file.close() if self._rows: db = ase.db.connect(temp_db_file.name, type='db', append=False) for fit_structure in self._rows: data_dict = {'user_tag': fit_structure.user_tag, 'properties': fit_structure.properties, 'cluster_vector': fit_structure.cluster_vector} db.write(fit_structure.structure, data=data_dict) with tarfile.open(outfile, mode='w') as handle: handle.add(temp_db_file.name, arcname='database') handle.add(temp_cs_file.name, arcname='cluster_space')
[docs] @staticmethod def read(infile: str | BinaryIO | TextIO) -> 'StructureContainer': """ Reads :class:`StructureContainer` object from file. Parameters ---------- infile File from which to read. """ if isinstance(infile, str): filename = infile else: filename = infile.name if not tarfile.is_tarfile(filename): raise TypeError('{} is not a tar file'.format(filename)) temp_db_file = tempfile.NamedTemporaryFile(delete=False) with tarfile.open(mode='r', name=filename) as tar_file: cs_file = tar_file.extractfile('cluster_space') temp_db_file.write(tar_file.extractfile('database').read()) temp_db_file.seek(0) cluster_space = ClusterSpace.read(cs_file) database = ase.db.connect(temp_db_file.name, type='db') structure_container = StructureContainer(cluster_space) fit_structures = [] for row in database.select(): data = row.data fit_structure = FitStructure(row.toatoms(), user_tag=data['user_tag'], cluster_vector=data['cluster_vector'], properties=data['properties']) fit_structures.append(fit_structure) structure_container._rows = fit_structures return structure_container
class FitStructure: """ This class holds a supercell along with its properties and cluster vector. Attributes ---------- structure Supercell structure. user_tag Custom user tag. cluster_vector Cluster vector. properties Dictionary comprising name and value of properties. """ def __init__(self, structure: Atoms, user_tag: str, cluster_vector: np.ndarray, properties: dict = {}) -> None: self._structure = structure self._user_tag = user_tag self._cluster_vector = cluster_vector self.properties = properties @property def cluster_vector(self) -> np.ndarray: """ Cluster vector. """ return self._cluster_vector @property def feature_vector(self) -> np.ndarray: """ Feature vector of the row, which is the cluster vector. """ return self._cluster_vector @property def structure(self) -> Atoms: """ Atomic structure. """ return self._structure @property def user_tag(self) -> str: """ Structure label. """ return str(self._user_tag) def __getattr__(self, key): """ Accesses properties if possible and returns value. """ if key not in self.properties.keys(): return super().__getattribute__(key) return self.properties[key] def __len__(self) -> int: """ Number of sites in the structure. """ return len(self._structure) def _get_rows(self) -> list[tuple[str, Any]]: """Label and value pairs describing the tag and the properties. The properties are in alphabetical order, as the columns of the table of a structure container are, since a structure need not carry the properties of the one beside it. """ rows = [('user tag', self.user_tag)] rows += sorted(self.properties.items()) return rows def _get_cell_rows(self) -> list[tuple[str, str, str, str]]: """Returns one row per cell vector, holding the name of the vector and its three components in units of Angstrom. The components are padded to a common width, which lines up the columns of the string representation and is not visible in the HTML representation, since a browser collapses a run of spaces into one. """ rows = [] for index, vector in enumerate(self.structure.cell[:]): components = tuple(f'{component:>10.6f}' for component in vector) rows += [(f'a{index + 1}',) + components] return rows def _get_site_rows(self) -> list[tuple[str, str, str]]: """Returns one row per site, holding the index of the site, the chemical symbol and the position in units of Angstrom. The components of a position are padded as those of a cell vector are. """ rows = [] for site in self.structure: coordinates = ' '.join(f'{component:>10.6f}' for component in site.position) rows += [(f'{site.index}', f'{site.symbol}', coordinates)] return rows def __str__(self) -> str: """String representation. The summary is followed by the cell metric and by the sites, which the HTML representation carries as well. A structure with more sites than :data:`MAXIMUM_TABLE_ROWS <icet.input_output.repr_tools.MAXIMUM_TABLE_ROWS>` has the middle of its list of sites left out. """ rows = self._get_rows() label = 'cell metric' for name, *components in self._get_cell_rows(): rows += [(label, f'{name:2} ' + ' '.join(components))] label = '' site_rows = self._get_site_rows() label = 'sites' for index in truncated_indices(len(site_rows), MAXIMUM_TABLE_ROWS): if index is None: rows += [(label, '...')] else: site_index, symbol, coordinates = site_rows[index] rows += [(label, f'{site_index:>4} {symbol:2} {coordinates}')] label = '' return text_representation(title='Fit Structure', rows=rows, width=50, label_width=22) def __repr__(self) -> str: """ Representation. """ s = type(self).__name__ + '(' s += f'user_tag={self.user_tag!r}' s += f', number_of_sites={len(self)}' s += ')' return s def _repr_html_(self) -> str: """HTML representation. Used, e.g., in jupyter notebooks. The summary is followed by the cell metric and by the sites, mirroring the string representation. """ site_rows = self._get_site_rows() s = [html_representation(title='FitStructure', rows=self._get_rows())] s += [html_table(self._get_cell_rows(), header=('Cell metric', 'x', 'y', 'z'))] s += [html_table([('...', '...', '...') if index is None else site_rows[index] for index in truncated_indices(len(site_rows), MAXIMUM_TABLE_ROWS)], header=('Index', 'Species', 'Position'))] return ''.join(s)