Source code for mchammer.configuration_manager

import hashlib
import json
import random
import numpy as np
from ase import Atoms
from icet.core.sublattices import Sublattices
from icet.tools.geometry import atomic_number_to_chemical_symbol


class SwapNotPossibleError(Exception):
    """ Raised when no swap trial move is possible on a sublattice. """


# Atomic number that ASE assigns to the vacancy species 'X'. A site is
# considered occupied when its occupation differs from this value.
VACANCY_ATOMIC_NUMBER = 0


def get_constraint_hash(neighbor_sites_to_avoid: dict[int, list[int]]) -> str:
    """Returns a short digest that identifies a neighbor constraint.

    The digest lets a simulation that is restarted from a data container be
    checked against the constraint the data container was written with, without
    storing a mapping whose size grows with the system.
    It is computed from a canonical form, so that a mapping written in a
    different order gives the same digest.
    Sites without neighbors are dropped, so that two mappings which impose the
    same rules agree even when one of them lists unconstrained sites
    explicitly.

    Parameters
    ----------
    neighbor_sites_to_avoid
        Sites that must not be occupied simultaneously, keyed by site index.
    """
    canonical = sorted((int(site), sorted(int(n) for n in neighbors))
                       for site, neighbors in neighbor_sites_to_avoid.items() if neighbors)
    return hashlib.sha1(json.dumps(canonical).encode()).hexdigest()[:16]


[docs] class ConfigurationManager(object): """ The configuration manager owns the configuration a Monte Carlo simulation samples. It holds the occupation vector together with the sites each species occupies on each sublattice, which lets it propose swap and flip trial moves in constant time through :func:`get_swapped_state` and :func:`get_flip_state`. Ensembles apply accepted moves through :func:`update_occupations` and restore a saved configuration through :func:`set_occupations`. The manager also enforces the neighbor constraint set through :func:`set_neighbor_sites_to_avoid`. Note ---- As a user you will usually not interact directly with objects of this type. Parameters ---------- structure Configuration to be handled. sublattices Sublattices that define the allowed occupations of the sites. """ def __init__(self, structure: Atoms, sublattices: Sublattices) -> None: self._structure = structure.copy() self._occupations = self._structure.numbers self._sublattices = sublattices self._sites_by_species = self._get_sites_by_species() self._neighbor_sites_to_avoid: dict[int, list[int]] | None = None def _get_sites_by_species(self) -> list[dict[int, list[int]]]: """ Returns the sites each species occupies, as one dictionary per sublattice that maps an atomic number to the list of sites on that sublattice which carry it. """ sites_by_species = [] for sl in self._sublattices: species_dict = {key: [] for key in sl.atomic_numbers} for site in sl.indices: species_dict[self._occupations[site]].append(site) sites_by_species.append(species_dict) return sites_by_species @property def occupations(self) -> np.ndarray: """ Occupation vector of the configuration (copy). """ return self._occupations.copy() @property def sublattices(self) -> Sublattices: """ Sublattices of the configuration. """ return self._sublattices @property def structure(self) -> Atoms: """ Atomic structure associated with configuration (copy). """ structure = self._structure.copy() structure.set_atomic_numbers(self.occupations) return structure
[docs] def get_occupations_on_sublattice(self, sublattice_index: int) -> list[int]: """ Returns the occupations on one sublattice by atomic number. Parameters ---------- sublattice_index Index of the sublattice. """ sl = self.sublattices[sublattice_index] return list(self.occupations[sl.indices])
[docs] def is_swap_possible(self, sublattice_index: int, allowed_species: list[int] | None = None) -> bool: """ Returns whether a swap trial move is possible on a sublattice, which requires at least two different species on it. Parameters ---------- sublattice_index Index of the sublattice. allowed_species Atomic numbers of the species that may take part in the swap. By default all species on the sublattice. """ sl = self.sublattices[sublattice_index] if allowed_species is None: swap_symbols = set(self.occupations[sl.indices]) else: swap_symbols = set([o for o in self.occupations[sl.indices] if o in allowed_species]) return len(swap_symbols) > 1
[docs] def get_swapped_state(self, sublattice_index: int, allowed_species: list[int] | None = None, allowed_sites: list[int] | None = None ) -> tuple[list[int], list[int]]: """ Proposes a swap of two randomly chosen sites with different species. The two sites are drawn from one sublattice, so the proposed configuration respects the allowed occupations of the sites. The configuration itself is not changed. Parameters ---------- sublattice_index Index of the sublattice from which to pick the sites. allowed_species Atomic numbers of the species that may take part in the swap. By default all species on the sublattice. allowed_sites Indices of the sites that may take part in the swap. By default all sites on the sublattice. Returns ------- tuple[list[int], list[int]] The indices of the two sites and their occupations after the swap. Raises ------ SwapNotPossibleError If the sublattice holds no site to swap or only one species. """ # pick the first site if allowed_species is None: available_sites = self.sublattices[sublattice_index].indices else: available_sites = [ s for Z in allowed_species for s in self._get_sites_by_species()[sublattice_index][Z]] # only include allowed sites if allowed_sites is not None: available_sites = list(set(available_sites).intersection(allowed_sites)) try: site1 = random.choice(available_sites) except IndexError: raise SwapNotPossibleError(f'Sublattice {sublattice_index} is empty.') # pick the second site if allowed_species is None: possible_swap_species = \ set(self._sublattices.get_allowed_numbers_on_site(site1)) - \ set([self._occupations[site1]]) else: possible_swap_species = \ set(allowed_species) - set([self._occupations[site1]]) possible_swap_sites = [] for Z in possible_swap_species: possible_swap_sites.extend(self._sites_by_species[sublattice_index][Z]) # only include allowed sites if allowed_sites is not None: possible_swap_sites = list(set(possible_swap_sites).intersection(allowed_sites)) possible_swap_sites = np.array(possible_swap_sites) try: site2 = random.choice(possible_swap_sites) except IndexError: raise SwapNotPossibleError( 'Cannot swap on sublattice {} since it is full of {} species .' .format(sublattice_index, atomic_number_to_chemical_symbol([self._occupations[site1]])[0])) return ([site1, site2], [self._occupations[site2], self._occupations[site1]])
[docs] def get_flip_state( self, sublattice_index: int, allowed_species: list[int] | None = None, allowed_sites: list[int] | None = None ) -> tuple[int, int]: """ Proposes a flip of a randomly chosen site to another species allowed on it. The configuration itself is not changed. Parameters ---------- sublattice_index Index of the sublattice from which to pick the site. allowed_species Atomic numbers of the species that may take part in the flip. By default all species on the sublattice. allowed_sites Indices of the sites that may take part in the flip. By default all sites on the sublattice. Returns ------- tuple[int, int] The index of the site and its occupation after the flip. """ if allowed_species is None: available_sites = self._sublattices[sublattice_index].indices else: available_sites = [s for Z in allowed_species for s in self._get_sites_by_species()[sublattice_index][Z]] # only include allowed sites if allowed_sites is not None: available_sites = list(set(available_sites).intersection(allowed_sites)) site = random.choice(available_sites) if allowed_species is not None: species = random.choice(list( set(allowed_species) - set([self._occupations[site]]))) else: species = random.choice(list( set(self._sublattices[sublattice_index].atomic_numbers) - set([self._occupations[site]]))) return site, species
[docs] def update_occupations(self, sites: list[int], species: list[int]) -> None: """ Changes the occupations of the given sites. The whole update is checked before any of it is applied, so a rejected update leaves the configuration as it was. Parameters ---------- sites Indices of the sites to change. species New occupations of those sites by atomic number. Raises ------ ValueError If the two lists differ in length, if a site appears twice, if a site index is out of range, or if a species is not allowed on the site it is given for. """ # The whole update is checked before any of it is applied, so a # rejected update leaves the occupations and the sites by species in # the state they were in. Applying as we go would leave the two # disagreeing about every site the loop had already reached. if len(sites) != len(species): raise ValueError('sites and species must have the same length.') if len(set(sites)) != len(sites): raise ValueError('The same site must not appear more than once' ' in an update: {}'.format(sites)) for site, new_Z in zip(sites, species): if site < 0 or site >= len(self._occupations): raise ValueError('Site {} is not a valid site index'.format(site)) sublattice_index = self.sublattices.get_sublattice_index_from_site_index(site) if new_Z not in self.sublattices[sublattice_index].atomic_numbers: raise ValueError('Invalid new species {} on site {}'.format(new_Z, site)) for site, new_Z in zip(sites, species): old_Z = self._occupations[site] sublattice_index = self.sublattices.get_sublattice_index_from_site_index(site) # Move the site from the list of sites for the old species to the # list for the new one. self._sites_by_species[sublattice_index][old_Z].remove(site) self._sites_by_species[sublattice_index][new_Z].append(site) # Update occupation vector itself self._occupations[sites] = species
[docs] def set_occupations(self, occupations: list[int]) -> None: """ Replaces the occupations of the whole configuration. This is the absolute counterpart of :func:`update_occupations`, which expresses a change relative to the current configuration. It is what a restart needs, since restoring a saved configuration is not the acceptance of a move. The occupations are checked before anything changes, so a rejected input leaves the configuration as it was. Parameters ---------- occupations New occupations by atomic number, one per site of the configuration. Raises ------ ValueError If the length does not match the configuration, or if a species is not allowed on the site it is given for. """ if len(occupations) != len(self._occupations): raise ValueError('occupations must have length {}, not {}' .format(len(self._occupations), len(occupations))) for site, new_Z in enumerate(occupations): sublattice_index = self.sublattices.get_sublattice_index_from_site_index(site) if new_Z not in self.sublattices[sublattice_index].atomic_numbers: raise ValueError('Invalid new species {} on site {}'.format(new_Z, site)) # Assign in place, since the occupations are the atomic numbers of the # structure this manager was built from. self._occupations[:] = occupations self._sites_by_species = self._get_sites_by_species()
@property def neighbor_sites_to_avoid(self) -> dict[int, list[int]] | None: """ Sites that must not be occupied simultaneously, keyed by site index (copy). """ if self._neighbor_sites_to_avoid is None: return None return {site: list(neighbors) for site, neighbors in self._neighbor_sites_to_avoid.items()}
[docs] def set_neighbor_sites_to_avoid(self, neighbor_sites_to_avoid: dict[int, list[int]] | None ) -> None: """Sets the neighbor constraint that trial moves have to respect. The constraint belongs to the configuration instead of to an individual trial move, so that it cannot apply to some moves and not to others. It governs the trial steps of :class:`ThermodynamicBaseEnsemble <mchammer.ensembles.ThermodynamicBaseEnsemble>` that carry out a swap, an SGC flip or a VCSGC flip. Thermodynamic integration, Wang-Landau sampling and target cluster vector annealing generate their trial moves differently and are not subject to it. Rejecting a trial move leaves the proposal unchanged and therefore symmetric, so the acceptance criterion stays exact and the constraint introduces no bias of its own. That alone does not guarantee that the whole constrained space is sampled: a restrictive mapping can leave the allowed configurations disconnected under the available trial moves, in which case the simulation only reaches the part it starts in. Parameters ---------- neighbor_sites_to_avoid Sites that must not be occupied simultaneously, keyed by site index. Sites that are absent from the mapping are unconstrained. ``None`` removes the constraint. The mapping is copied, so changing it afterwards does not change the constraint. Raises ------ ValueError If the constraint is not usable, see :func:`validate_constraint`. """ if neighbor_sites_to_avoid is None: self._neighbor_sites_to_avoid = None return # a copy, so that mutating the mapping afterwards cannot get around the # validation below; the indices are normalized on the way in, which also # accepts the numpy integers that a neighbor list produces constraint = {int(site): [int(n) for n in neighbors] for site, neighbors in neighbor_sites_to_avoid.items()} self.validate_constraint(constraint) self._neighbor_sites_to_avoid = constraint
[docs] def is_constraint_violated(self, sites: list[int], species: list[int]) -> bool: """Checks whether a trial move would violate the neighbor constraint. The constraint forbids two sites that appear in each other's avoid list from being occupied at the same time, where a site counts as occupied when it is not held by a vacancy. Provided that the current configuration satisfies the constraint, only the sites touched by the trial move can introduce a violation, which is what allows this check to be local. Parameters ---------- sites Indices of the sites that the trial move would change. species Occupations by atomic number that the trial move would assign to :attr:`sites`. Returns ------- ``True`` if applying the trial move would place occupants on two sites that appear in each other's avoid list, ``False`` otherwise. Always ``False`` when no constraint is set. """ if self._neighbor_sites_to_avoid is None: return False trial_occupations = dict(zip(sites, species)) for site, new_species in trial_occupations.items(): if new_species == VACANCY_ATOMIC_NUMBER: continue for neighbor in self._neighbor_sites_to_avoid.get(site, ()): # a neighbor that the trial move also touches must be evaluated # in its trial state rather than its current one occupant = trial_occupations.get(neighbor, self._occupations[neighbor]) if occupant != VACANCY_ATOMIC_NUMBER: return True return False
[docs] def validate_constraint(self, neighbor_sites_to_avoid: dict[int, list[int]]) -> None: """Checks that a neighbor constraint is usable for the current configuration. The configuration has to satisfy the constraint already. Each trial move is only checked against the sites it touches, which assumes that the rest of the configuration satisfies the constraint. A violating configuration is not stuck, since emptying an offending site is never rejected, but the sampling is biased until the violations happen to be cleared. Parameters ---------- neighbor_sites_to_avoid Sites that must not be occupied simultaneously, keyed by site index. Sites that are absent from the mapping are unconstrained. Raises ------ ValueError If the constraint refers to a site that does not exist, if a site is listed against itself, if it is not symmetric, or if the current configuration already violates it. """ n_sites = len(self._occupations) for site, neighbors in neighbor_sites_to_avoid.items(): for index in (site, *neighbors): if not 0 <= index < n_sites: raise ValueError( f'neighbor_sites_to_avoid refers to site {index}, which does not' f' exist in a configuration with {n_sites} sites.') if site in neighbors: raise ValueError( f'neighbor_sites_to_avoid lists site {site} against itself, which' ' would leave it permanently unoccupiable.') for neighbor in neighbors: if site not in neighbor_sites_to_avoid.get(neighbor, ()): raise ValueError( 'neighbor_sites_to_avoid must be symmetric: site' f' {site} lists {neighbor}, but {neighbor} does not list {site}.') for site, neighbors in neighbor_sites_to_avoid.items(): if self._occupations[site] == VACANCY_ATOMIC_NUMBER: continue for neighbor in neighbors: if self._occupations[neighbor] != VACANCY_ATOMIC_NUMBER: raise ValueError( 'The configuration already violates neighbor_sites_to_avoid:' f' sites {site} and {neighbor} are both occupied. Each trial' ' move is only checked against the sites it touches, which' ' assumes that the rest of the configuration satisfies the' ' constraint, so starting here would sample a biased transient.')