"""
This module provides the LocalClusterSpace class, the anchored basis in
which local properties such as kinetically resolved activation barriers
are expanded.
"""
import json
from dataclasses import dataclass
from itertools import combinations, product
from typing import Any
from math import floor, log10
import numpy as np
from _icet import _LocalClusterExpansionEvaluator, evaluate_point_function
from ase import Atoms
from ase.data import atomic_numbers as ase_atomic_numbers
from icet.input_output.repr_tools import (html_representation,
text_representation)
from .event import (HopEvent,
canonical_key,
find_sites_within_cutoff,
get_allowed_species,
get_symmetry_operations,
map_sites)
from .event_mapping import EventImages, build_event_images
__all__ = ['LocalClusterSpace']
@dataclass(frozen=True)
class ConcentrationAugmentation:
"""Augmentation of a local cluster space by global features.
The augmentation appends one point function per sublattice group,
averaged over all sites of the group, which captures the dependence of
the barrier on the global composition, for example through the
composition dependence of the lattice parameter.
Such a feature is invariant during a kinetic Monte Carlo run whose
hops keep both anchors inside one group, so it costs nothing per step.
Fitting it requires training backgrounds that vary the composition of
every augmented group, since within one background a feature is affine
in the local singlets of its group and the fit is rank deficient.
Parameters
----------
cutoffs
Cutoffs of an internal cluster space whose orbit correlations of
order two and higher are appended as further global features,
restricted to sublattice groups whose occupations no hop changes,
which for a frozen host makes the full host short-range order
available to the barrier.
Empty for the concentration features alone.
sublattices
Explicit selection of the sublattice groups, as tuples of sorted
chemical symbols, whose orbits may enter the features.
The default derives the selection from the event, excluding the
groups the anchors sit on.
"""
cutoffs: tuple = ()
sublattices: tuple | None = None
def __post_init__(self) -> None:
object.__setattr__(self, 'cutoffs',
tuple(float(cutoff) for cutoff in self.cutoffs))
for cutoff in self.cutoffs:
if not (np.isfinite(cutoff) and cutoff > 0):
raise ValueError('Every cutoff must be a positive finite'
f' number ({cutoff})')
if self.sublattices is None:
return
object.__setattr__(self, 'sublattices',
tuple(tuple(sorted(group))
for group in self.sublattices))
if not self.cutoffs:
raise ValueError('sublattices restricts the orbit features and'
' therefore requires cutoffs')
def to_dict(self) -> dict:
"""Returns a dict representation, suitable for serialization."""
return {'cutoffs': list(self.cutoffs),
'sublattices': None if self.sublattices is None
else [list(group) for group in self.sublattices]}
@classmethod
def from_dict(cls, dct: dict) -> 'ConcentrationAugmentation':
"""Reconstructs an augmentation from its dict representation."""
return cls(cutoffs=tuple(dct['cutoffs']),
sublattices=None if dct['sublattices'] is None
else tuple(tuple(group) for group in dct['sublattices']))
[docs]
class LocalClusterSpace:
"""An anchored local cluster space.
The space is anchored on an event prototype, a cluster of one or two
sites of the primitive structure, one for the site of an interstitial
and two for the endpoints of a hop.
A model of a one-site event serves fitting and prediction, while
:class:`~mchammer.ensembles.KineticMonteCarloEnsemble`
requires an event of two sites.
Basis functions are built from the occupations of the environment
sites, meaning sites within :attr:`environment_cutoff` of the nearest
anchor site that allow more than one species.
The anchor sites themselves carry no basis functions, since their
decoration is fixed by the event.
Clusters that are equivalent under the point group that leaves both
the crystal and the anchor invariant share one parameter.
Parameters
----------
primitive_structure
Primitive structure in which the event is defined.
event
Event prototype that anchors the expansion.
chemical_symbols
Species allowed on the sites of the primitive structure, either
as a flat list valid for all sites or as one list per site.
environment_cutoff
Maximum distance of an environment site from the nearest anchor
site, which also bounds the singlets.
cluster_cutoffs
Maximum intra-cluster distances per order, starting with pairs,
following the convention of :class:`ClusterSpace`.
augmentation
Optional global features appended to the local cluster vector,
given as a :class:`ConcentrationAugmentation`, which documents
what they are and what fitting them requires.
symprec
Tolerance in Å passed to spglib, which also widens the cutoffs.
"""
def __init__(self,
primitive_structure: Atoms,
event: HopEvent,
chemical_symbols: list,
environment_cutoff: float,
cluster_cutoffs: list = None,
augmentation: 'ConcentrationAugmentation' = None,
symprec: float = 1e-5) -> None:
self._primitive_structure = primitive_structure.copy()
self._event = event
self._chemical_symbols = get_allowed_species(primitive_structure,
chemical_symbols)
self._environment_cutoff = float(environment_cutoff)
self._cluster_cutoffs = [float(c) for c in (cluster_cutoffs or [])]
if augmentation is not None \
and not isinstance(augmentation, ConcentrationAugmentation):
raise ValueError('augmentation must be a'
' ConcentrationAugmentation'
f' ({augmentation!r})')
self._augmentation = augmentation
self._symprec = symprec
if len(event) > 2:
raise ValueError(
f'An event of {len(event)} sites is not supported. The'
' mapping of an event onto a supercell handles one anchor'
' site, for an interstitial, and two, for a hop')
for index in event.site_indices:
if not 0 <= index < len(primitive_structure):
raise ValueError(f'Event site index {index} is not a site of'
' the primitive structure')
self._allowed_numbers = [
sorted(ase_atomic_numbers[symbol] for symbol in symbols)
for symbols in self._chemical_symbols]
self._scaled_positions = self._primitive_structure.get_scaled_positions(wrap=False)
self._cell = np.array(self._primitive_structure.cell)
self._space_group = get_symmetry_operations(
self._primitive_structure, self._chemical_symbols, symprec)
self._anchor_operations = self._find_anchor_operations()
self._environment_sites = self._find_environment_sites()
self._orbits = self._build_orbits()
self._augmentation_features = self._build_augmentation_features()
# Tolerance applied when the sites of a supercell are located in the
# primitive frame, following the convention of ClusterSpace.
tolerance = min(symprec / abs(np.linalg.det(self._cell)) ** (1 / 3),
symprec / 5)
self._fractional_position_tolerance = round(
tolerance, -int(floor(log10(abs(tolerance)))))
self._event_images = EventImages(
build_event_images(self._primitive_structure, self._event,
self._space_group, self._environment_sites,
self._orbits, self._allowed_numbers, symprec),
self._primitive_structure,
self._fractional_position_tolerance)
def _build_augmentation_features(self) -> list:
"""Builds the metadata of the augmentation features.
A concentration feature is one point function averaged over all
supercell sites of one sublattice group, where a group collects
the basis sites that share one set of allowed species, keyed by
the sorted symbols exactly as the sublattices of a cluster space
are keyed.
Groups with a single allowed species carry no point function and
contribute no feature.
Concentration features are invariant during a kinetic Monte Carlo
run whenever every hop keeps both of its anchors inside or outside
each group, since the occupation multiset of the group is then
conserved, which is what makes them usable as global composition
descriptors of the barrier at zero per-step cost.
"""
self._augmentation_cluster_space = None
self._augmentation_selected_indices = np.empty(0, dtype=int)
self._n_concentration_features = 0
if self._augmentation is None:
return []
groups: dict = {}
for basis_index, symbols in enumerate(self._chemical_symbols):
groups.setdefault(tuple(sorted(symbols)), []).append(basis_index)
features = []
for species, basis_indices in sorted(groups.items()):
numbers = sorted(ase_atomic_numbers[symbol] for symbol in species)
for function_index in range(len(species) - 1):
values = [evaluate_point_function(len(numbers),
function_index,
species_index)
for species_index in range(len(numbers))]
features.append({'sublattices': species,
'groups': (species,),
'order': 1,
'maximum_distance': 0.0,
'function_index': function_index,
'basis_indices': tuple(basis_indices),
'numbers': numbers,
'values': values})
self._n_concentration_features = len(features)
if not self._augmentation.cutoffs:
return features
# Orbits of an internal cluster space provide the features beyond
# the singlets, restricted to sublattice groups whose occupations
# no hop of the event changes.
# Singlet elements are excluded, since the concentration features
# above carry them already.
from icet.core.cluster_space import ClusterSpace
cluster_space = ClusterSpace(self._primitive_structure,
list(self._augmentation.cutoffs),
self.chemical_symbols,
symprec=self._symprec)
sublattices = cluster_space.get_sublattices(
cluster_space.primitive_structure)
group_of_letter = {sublattice.symbol:
tuple(sorted(sublattice.chemical_symbols))
for sublattice in sublattices}
anchor_groups = {tuple(sorted(self._chemical_symbols[index]))
for index in self._event.site_indices}
if self._augmentation.sublattices is not None:
allowed = set(self._augmentation.sublattices)
unknown = allowed - set(group_of_letter.values())
if unknown:
raise ValueError(f'Unknown sublattice groups {sorted(unknown)}'
' in the sublattices of the augmentation')
# An orbit feature on a group an anchor sits on is not
# invariant under the hops of the event, which is what the
# features have to be to enter the rate as a constant.
on_anchor = allowed & anchor_groups
if on_anchor:
raise ValueError(
f'The sublattice groups {sorted(on_anchor)} carry an'
' anchor site of the event, so the occupations of a'
' hop change them and an orbit feature on them is not'
' invariant during a simulation')
else:
allowed = set(group_of_letter.values()) - anchor_groups
selected = []
for row in cluster_space.as_list:
if row['order'] < 2:
continue
row_groups = tuple(sorted({group_of_letter[letter]
for letter in row['sublattices']}))
if all(group in allowed for group in row_groups):
selected.append(row['index'])
cluster = cluster_space.orbit_list.get_orbit(
row['orbit_index']).representative_cluster
features.append({'sublattices': row_groups,
'groups': row_groups,
'order': row['order'],
'maximum_distance': max(cluster.distances)})
self._augmentation_cluster_space = cluster_space
self._augmentation_selected_indices = np.array(selected, dtype=int)
return features
# ------------------------------------------------------------------
# construction helpers
# ------------------------------------------------------------------
[docs]
def get_site_position(self, site) -> np.ndarray:
"""Returns the Cartesian position of a lattice site of the
primitive structure.
Parameters
----------
site
Lattice site as a (basis index, offset) pair.
"""
index, offset = site
return np.dot(self._scaled_positions[index] + np.asarray(offset),
self._cell)
def _find_anchor_operations(self) -> list:
"""Finds the operations that leave crystal and anchor invariant.
Each returned operation is a pair (rotation, translation) in
fractional coordinates whose combined action maps the anchor site
set onto itself.
"""
anchor = self._event.sites
anchor_key, anchor_offset = canonical_key(anchor, unordered=True)
operations = []
for rotation, translation in zip(self._space_group['rotations'],
self._space_group['translations']):
images = map_sites(anchor, rotation, translation,
self._scaled_positions, self._cell,
self._symprec)
key, offset = canonical_key(images, unordered=True)
if key == anchor_key:
# The lattice translation that carries the images onto the
# anchor sites themselves.
shift = np.array(anchor_offset) - np.array(offset)
operations.append((np.array(rotation),
np.array(translation) + shift))
return operations
def _find_environment_sites(self) -> list:
"""Finds the active sites within the environment cutoff of the anchor.
Symmetry-equivalent sites at the cutoff differ in distance by
rounding, and widening the cutoff by symprec keeps them together.
"""
active = [index for index, numbers in enumerate(self._allowed_numbers)
if len(numbers) > 1]
return sorted(find_sites_within_cutoff(
self._scaled_positions, self._cell, self._event.sites, active,
self._environment_cutoff + self._symprec))
[docs]
def get_site_permutations(self, sites: list) -> list:
"""Returns the permutations of the given sites under the point group
that leaves the crystal and the anchor invariant.
Each permutation maps the position of a site to the position of its
image, and an operation that carries a site outside the given set
contributes none.
Parameters
----------
sites
Lattice sites of the primitive structure, as (basis index,
offset) pairs.
"""
index_of = {site: index for index, site in enumerate(sites)}
permutations = set()
for operation in self._anchor_operations:
images = map_sites(sites, *operation, self._scaled_positions,
self._cell, self._symprec)
if all(image in index_of for image in images):
permutations.add(tuple(index_of[image] for image in images))
return sorted(permutations)
def _get_maximum_distance(self, sites) -> float:
"""Returns the largest distance between two sites of a cluster."""
positions = [self.get_site_position(site) for site in sites]
return max((float(np.linalg.norm(first - second))
for first, second in combinations(positions, 2)),
default=0.0)
def _enumerate_clusters(self) -> list:
"""Enumerates the candidate clusters of environment sites."""
clusters = [(site,) for site in self._environment_sites]
for order, cutoff in enumerate(self._cluster_cutoffs, start=2):
clusters += [sites for sites
in combinations(self._environment_sites, order)
if self._get_maximum_distance(sites)
<= cutoff + self._symprec]
return clusters
def _decorations(self, sites: tuple):
"""Yields all decorations (point function indices) of a cluster."""
ranges = [range(len(self._allowed_numbers[index]) - 1)
for index, _ in sites]
yield from product(*ranges)
@staticmethod
def _decorated_key(sites: tuple, decoration: tuple) -> tuple:
"""Returns a canonical key of a decorated cluster."""
return tuple(sorted(zip(sites, decoration)))
def _build_orbits(self) -> list:
"""Groups decorated clusters into orbits under the anchor operations.
Each orbit is a list of members, and each member is a tuple of
(site, point function index) pairs.
"""
environment = set(self._environment_sites)
seen = set()
orbits = []
for sites in self._enumerate_clusters():
for decoration in self._decorations(sites):
key = self._decorated_key(sites, decoration)
if key in seen:
continue
members = set()
for operation in self._anchor_operations:
images = map_sites([site for site, _ in key], *operation,
self._scaled_positions, self._cell,
self._symprec)
if not all(image in environment for image in images):
raise RuntimeError(
'An anchor operation mapped an environment site'
' outside the environment, which indicates an'
' inconsistent cutoff or tolerance')
members.add(self._decorated_key(
tuple(images),
tuple(function_index for _, function_index in key)))
seen.update(members)
orbits.append(sorted(members))
return orbits
# ------------------------------------------------------------------
# public interface
# ------------------------------------------------------------------
@property
def primitive_structure(self) -> Atoms:
"""Primitive structure in which the event is defined (copy)."""
return self._primitive_structure.copy()
@property
def event(self) -> HopEvent:
"""Event prototype that anchors the expansion."""
return self._event
@property
def chemical_symbols(self) -> list:
"""Species allowed on each site of the primitive structure."""
return [list(symbols) for symbols in self._chemical_symbols]
@property
def environment_cutoff(self) -> float:
"""Maximum distance of an environment site from the anchor."""
return self._environment_cutoff
@property
def cluster_cutoffs(self) -> list:
"""Maximum intra-cluster distances per order, starting with pairs."""
return list(self._cluster_cutoffs)
@property
def environment_sites(self) -> list:
"""Environment sites as (basis index, offset) pairs."""
return list(self._environment_sites)
@property
def orbits(self) -> list:
"""Orbits of decorated clusters under the anchor point group."""
return [list(orbit) for orbit in self._orbits]
@property
def augmentation(self) -> 'ConcentrationAugmentation':
"""Augmentation of the space, None when it carries none."""
return self._augmentation
@property
def augmentation_features(self) -> list:
"""Metadata of the augmentation features.
One dict per feature with the keys ``sublattices`` (the sorted
species tuple of the group for a concentration feature, the tuple
of involved groups for an orbit feature), ``groups`` (always the
tuple of involved groups), and ``order``.
"""
return [{'sublattices': feature['sublattices'],
'groups': feature['groups'],
'order': feature['order']}
for feature in self._augmentation_features]
def __len__(self) -> int:
"""Number of elements of the local cluster vector, including the zerolet."""
return 1 + len(self._orbits) + len(self._augmentation_features)
@property
def as_list(self) -> list:
"""Human-readable summary of the elements of the space."""
rows = [{'index': 0, 'order': 0, 'multiplicity': 1,
'maximum_distance': 0.0}]
for orbit_index, orbit in enumerate(self._orbits):
sites = [site for site, _ in orbit[0]]
rows.append({'index': orbit_index + 1,
'order': len(sites),
'multiplicity': len(orbit),
'maximum_distance': self._get_maximum_distance(sites)})
for feature_index, feature in enumerate(self._augmentation_features):
rows.append({'index': 1 + len(self._orbits) + feature_index,
'order': feature['order'],
'multiplicity': 1,
'maximum_distance': feature['maximum_distance'],
'sublattices': feature['sublattices']})
return rows
def _get_rows(self) -> list[tuple[str, Any]]:
"""Returns the label and value pairs that summarize this space."""
rows = [('event sites', self._event.sites),
('migrating species', self._event.species),
('environment cutoff', self._environment_cutoff),
('cluster cutoffs', self._cluster_cutoffs),
('environment sites', len(self._environment_sites))]
if self._augmentation is not None:
rows += [('augmentation', f'concentrations'
f' ({len(self._augmentation_features)} features)')]
rows += [('total number of parameters', len(self)),
('symprec', self._symprec)]
return rows
def __str__(self) -> str:
""" String representation. """
return text_representation(title='Local Cluster Space',
rows=self._get_rows(), width=60,
label_width=26)
def _repr_html_(self) -> str:
""" HTML representation. Used, e.g., in jupyter notebooks. """
return html_representation(title='Local Cluster Space',
rows=self._get_rows())
# ------------------------------------------------------------------
# evaluation
# ------------------------------------------------------------------
def _species_index(self, basis_index: int, atomic_number: int) -> int:
"""Returns the species index of an occupant on a given basis site."""
allowed = self._allowed_numbers[basis_index]
try:
return allowed.index(atomic_number)
except ValueError:
raise ValueError(f'Species with atomic number {atomic_number} is'
f' not allowed on basis site {basis_index}')
[docs]
def find_event_instances(self, structure: Atoms) -> list:
"""Returns every instance of the event prototype in a supercell.
Each instance is the tuple of its anchor site indices in ascending
order, and the instances are ordered by their first site.
Parameters
----------
structure
Supercell in which to find the instances.
Raises
------
ValueError
If the environment of an instance wraps onto itself or onto the
anchor, if the supercell is not an integer repetition of the
primitive structure, or if it is too small for the hop itself.
"""
return self._event_images.find_instances(structure)[0]
def _build_evaluator(self, structure: Atoms,
parameters: np.ndarray) -> tuple:
"""Returns the compiled evaluator of the zerolet and the orbits on a
supercell, together with the event instances it evaluates.
Parameters
----------
structure
Supercell on which to evaluate.
parameters
Parameters of the zerolet and the orbits.
"""
instances, image_indices, cell_indices = \
self._event_images.find_instances(structure)
images = self._event_images.images
# The slots of the images reference a modest number of lattice
# sites, which the evaluator turns into supercell site indices once
# per cell, so they are collected into one list here.
referenced: dict = {}
slot_sites = []
for image in images:
for basis, offset in zip(image.slot_basis, image.slot_offset):
key = (int(basis), tuple(int(x) for x in offset))
slot_sites.append(referenced.setdefault(key, len(referenced)))
evaluator = _LocalClusterExpansionEvaluator(
parameters=parameters,
allowed_atomic_numbers=self._allowed_numbers,
lattice_map=self._event_images.get_lattice_map(structure),
referenced_basis_indices=[basis for basis, _ in referenced],
referenced_offsets=np.array([offset for _, offset in referenced],
dtype=int).reshape(-1, 3),
number_of_images=len(images),
slot_sites=np.array(slot_sites, dtype=int),
slot_function_indices=np.concatenate(
[image.slot_function for image in images]),
instance_images=list(image_indices),
instance_cells=list(cell_indices),
member_counts=[len(orbit) for orbit in self._orbits],
orbit_orders=[len(orbit[0]) for orbit in self._orbits])
return evaluator, instances
def _build_augmentation_evaluator(self, structure: Atoms):
"""Returns the function that evaluates the augmentation features of
a supercell for an occupation vector by atomic number.
Parameters
----------
structure
Supercell on which to evaluate.
"""
basis_of_site = np.array(
self._event_images.get_lattice_map(structure).basis_indices)
calculator = None
if self._augmentation_cluster_space is not None:
calculator = self._augmentation_cluster_space \
._build_temporary_calculator(structure)
def evaluate(occupations: np.ndarray) -> np.ndarray:
parts = [self._get_concentration_vector(occupations, basis_of_site)]
if calculator is not None:
cluster_vector = np.array(calculator.get_cluster_vector(occupations))
parts.append(cluster_vector[self._augmentation_selected_indices])
return np.concatenate(parts)
return evaluate
def _map_event_instance(self, structure: Atoms, event_sites: tuple) -> list:
"""Maps the prototype onto an event instance in a supercell.
Locates the image of the prototype that carries the anchor onto the
sites given by :attr:`event_sites` and returns, for each orbit, the
list of members with sites resolved to supercell indices.
Parameters
----------
structure
Supercell containing the event instance.
event_sites
Indices of the anchor sites in the supercell, in either order.
Returns
-------
list
One list per orbit.
Each member is a tuple of
(supercell index, basis index, point function index) triples.
"""
image_index, cell_offset, image = self._locate(structure, event_sites)
sites = self._event_images.resolve(structure, image_index, cell_offset)
resolved = []
position = 0
for orbit in self._orbits:
order = len(orbit[0])
members = []
for _ in orbit:
members.append(tuple(
(int(sites[position + index]),
int(image.slot_basis[position + index]),
int(image.slot_function[position + index]))
for index in range(order)))
position += order
resolved.append(members)
return resolved
[docs]
def get_environment_site_mapping(self, structure: Atoms,
event_sites: tuple) -> list:
"""Maps the environment sites of the prototype onto supercell indices.
The mapping is ordered like :attr:`environment_sites`.
A mapping in which two environment sites land on the same supercell
index, or on an anchor site, indicates a cell too small for the
environment, which makes basis columns of the expansion
proportional and any fit on such cells rank deficient.
This method raises in that case.
Parameters
----------
structure
Supercell containing the event instance.
event_sites
Indices of the anchor sites in the supercell.
"""
_, cell_offset, image = self._locate(structure, event_sites)
lattice_map = self._event_images.get_lattice_map(structure)
return [int(index) for index in lattice_map.site_indices(
[int(basis) for basis in image.environment_basis],
image.environment_offset + cell_offset)]
def _locate(self, structure: Atoms, event_sites: tuple) -> tuple:
"""Returns the (image index, cell offset, image) of an instance."""
if len(event_sites) != len(self._event):
raise ValueError('event_sites must name one site per anchor site'
f' ({len(event_sites)} != {len(self._event)})')
lattice_map = self._event_images.get_lattice_map(structure)
image_index, cell_offset = self._event_images.locate(
lattice_map, tuple(event_sites))
return image_index, cell_offset, self._event_images.images[image_index]
[docs]
def get_local_cluster_vector(self,
structure: Atoms,
event_sites: tuple) -> np.ndarray:
"""Returns the local cluster vector of an event instance.
Parameters
----------
structure
Supercell whose occupations define the environment.
event_sites
Indices of the anchor sites in the supercell.
The orbit part of the vector ignores the occupations of the
anchor sites, since the event decoration is fixed by the
prototype.
The augmentation features average over all sites of their
group, anchors included, which preserves the hop-direction
symmetry, since swapping the two anchor decorations leaves
the occupation multiset unchanged.
"""
resolved = self._map_event_instance(structure, event_sites)
occupations = structure.get_atomic_numbers()
vector = np.empty(len(self))
vector[0] = 1.0
for orbit_index, members in enumerate(resolved):
total = 0.0
for member in members:
value = 1.0
for (site_index, basis_index, function_index) in member:
species_index = self._species_index(
basis_index, occupations[site_index])
value *= evaluate_point_function(
len(self._allowed_numbers[basis_index]),
function_index, species_index)
total += value
vector[orbit_index + 1] = total / len(members)
if self._augmentation_features:
vector[1 + len(self._orbits):] = \
self._build_augmentation_evaluator(structure)(occupations)
return vector
def _get_concentration_vector(self, occupations: np.ndarray,
basis_of_site: np.ndarray) -> np.ndarray:
"""Returns the concentration features for one occupation vector.
A feature is computed from the number of sites of its group that
each species occupies, which makes it exactly invariant under any
permutation of the occupations within the group.
The orbit features of the augmentation are not included, since
they require a cluster-vector evaluation.
Parameters
----------
occupations
Occupation vector of the supercell by atomic number.
basis_of_site
Primitive basis site of every supercell site.
"""
values = np.empty(self._n_concentration_features)
for index in range(self._n_concentration_features):
feature = self._augmentation_features[index]
group = occupations[np.isin(basis_of_site, feature['basis_indices'])]
counts = [int(np.count_nonzero(group == number))
for number in feature['numbers']]
if sum(counts) != len(group):
raise ValueError('A site carries a species that is not'
' allowed on its sublattice')
values[index] = sum(count * value for count, value
in zip(counts, feature['values'])) / len(group)
return values
# ------------------------------------------------------------------
# serialization
# ------------------------------------------------------------------
[docs]
def to_dict(self) -> dict:
"""Returns a dict representation, suitable for serialization."""
return {'type': 'LocalClusterSpace',
'cell': np.array(self._primitive_structure.cell).tolist(),
'scaled_positions': self._scaled_positions.tolist(),
'numbers': self._primitive_structure.get_atomic_numbers().tolist(),
'pbc': [bool(p) for p in self._primitive_structure.pbc],
'event': self._event.to_dict(),
'chemical_symbols': self._chemical_symbols,
'environment_cutoff': self._environment_cutoff,
'cluster_cutoffs': self._cluster_cutoffs,
'augmentation': None if self._augmentation is None
else self._augmentation.to_dict(),
'symprec': self._symprec}
[docs]
@classmethod
def from_dict(cls, dct: dict) -> 'LocalClusterSpace':
"""Reconstructs a local cluster space from its dict representation."""
structure = Atoms(cell=dct['cell'],
scaled_positions=dct['scaled_positions'],
numbers=dct['numbers'],
pbc=dct['pbc'])
return cls(primitive_structure=structure,
event=HopEvent.from_dict(dct['event']),
chemical_symbols=dct['chemical_symbols'],
environment_cutoff=dct['environment_cutoff'],
cluster_cutoffs=dct['cluster_cutoffs'],
augmentation=None if dct.get('augmentation') is None
else ConcentrationAugmentation.from_dict(dct['augmentation']),
symprec=dct['symprec'])
[docs]
def write(self, filename: str) -> None:
"""Saves the local cluster space to a file.
Parameters
----------
filename
Name of the file to which to write.
"""
with open(filename, 'w', encoding='utf-8') as handle:
json.dump(self.to_dict(), handle, ensure_ascii=False)
[docs]
@classmethod
def read(cls, filename: str) -> 'LocalClusterSpace':
"""Reads a local cluster space from a file.
Parameters
----------
filename
Name of the file from which to read.
"""
with open(filename, encoding='utf-8') as handle:
return cls.from_dict(json.load(handle))