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):
    pass


# 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 ConfigurationManager owns and handles information pertaining to a configuration being sampled in a Monte Carlo simulation. Note ---- As a user you will usually not interact directly with objects of this type. Parameters ---------- structure : Atoms Configuration to be handled. sublattices : :class:`Sublattices <icet.core.sublattices.Sublattices>` Sublattices used to define allowed occupations and handle related information. """ 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 that are occupied for each species. Each dictionary represents one sublattice where the key is the species (by atomic number) and the value is the list of sites occupied by said species in the respective sublattice. """ 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. Parameters --------- sublattice_index Sublattice by index for which the occupations should be returned. """ 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: """ Checks if a swap trial move is possible on a specific sublattice. Parameters ---------- sublattice_index Index of sublattice to be checked. allowed_species List of atomic numbers for allowed species. """ 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]]: """Returns two random sites (first element of tuple) and their occupation after a swap (second element of tuple). The new configuration will obey the occupation constraints associated with the :class:`ConfigurationManager` object. Parameters ---------- sublattice_index Sublattice by index from which to pick sites. allowed_species List of atomic numbers for allowed species. allowed_sites List of indices for allowed sites. """ # 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]: """ Returns a site index and a new species for the site. Parameters ---------- sublattice_index Index of sublattice from which to pick a site. allowed_species List of atomic numbers for allowed species. allowed_sites List of indices for allowed sites. """ 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: """ Updates the occupation vector of the configuration being sampled. This will change the state in both the configuration in the calculator and the configuration manager. Parameters ---------- sites Indices of sites of the configuration to change. species New occupations by atomic number. """ # 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 rather than 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.')