from math import inf
import numpy as np
from ase import Atoms
from ase.data import chemical_symbols as periodic_table
from .. import ClusterExpansion
from ..core.local_orbit_list_generator import LocalOrbitListGenerator
from ..core.structure import Structure
from .variable_transformation import transform_parameters
from ..input_output.logging_tools import logger
try:
import highspy
from highspy.highs import highs_cons
highspy_loaded = True
except ImportError:
from types import SimpleNamespace
from unittest.mock import MagicMock
# For typing purposes
highspy = SimpleNamespace(Highs=None, HighsModelStatus=None)
highs_cons = MagicMock()
highspy_loaded = False
[docs]
class GroundStateFinder:
"""
This class provides functionality for determining the ground states
using a binary cluster expansion. This is efficiently achieved through the
use of mixed integer programming (MIP) as developed by Larsen *et al.* in
`Phys. Rev. Lett. 120, 256101 (2018)
<https://doi.org/10.1103/PhysRevLett.120.256101>`_.
This class relies on the `HiGHS package <https://www.highs.dev/>`_.
Note
----
The current implementation only works for binary systems.
Parameters
----------
cluster_expansion
Cluster expansion for which to find ground states.
structure
Atomic configuration.
Example
-------
The following snippet illustrates how to determine the ground state for a
Au-Ag alloy. Here, the parameters of the cluster
expansion are set to emulate a simple Ising model in order to obtain an
example that can be run without modification. In practice, one should of
course use a proper cluster expansion::
>>> from ase.build import bulk
>>> from icet import ClusterExpansion, ClusterSpace
>>> # prepare cluster expansion
>>> # the setup emulates a second nearest-neighbor (NN) Ising model
>>> # (zerolet and singlet parameters are zero; only first and second
>>> # neighbor pairs are included)
>>> prim = bulk('Au')
>>> chemical_symbols = ['Ag', 'Au']
>>> cs = ClusterSpace(prim, cutoffs=[4.3], chemical_symbols=chemical_symbols)
>>> ce = ClusterExpansion(cs, [0, 0, 0.1, -0.02])
>>> # prepare initial configuration
>>> structure = prim.repeat(3)
>>> # set up the ground state finder and calculate the ground state energy
>>> gsf = GroundStateFinder(ce, structure)
>>> ground_state = gsf.get_ground_state({'Ag': 5})
>>> print('Ground state energy:', ce.predict(ground_state))
"""
def __init__(self,
cluster_expansion: ClusterExpansion,
structure: Atoms) -> None:
if not highspy_loaded:
raise ImportError('HiGHS (https://www.highs.dev/) is required in order to use the'
' ground state finder.')
# Check that there is only one active sublattice
self._cluster_expansion = cluster_expansion
self._fractional_position_tolerance = cluster_expansion.fractional_position_tolerance
self.structure = structure
cluster_space = self._cluster_expansion.get_cluster_space_copy()
primitive_structure = cluster_space.primitive_structure
self._active_sublattices = cluster_space.get_sublattices(structure).active_sublattices
# Check that there are no more than two allowed species
active_species = [set(subl.chemical_symbols) for subl in self._active_sublattices]
if any(len(species) > 2 for species in active_species):
raise NotImplementedError('Currently, systems with more than two allowed species on '
'any sublattice are not supported.')
# Check that there are no merged orbits
if any(['merge' in elem for elem in cluster_space._pruning_history]):
raise NotImplementedError('Currently, systems with merged orbits are not supported.')
self._active_species = active_species
# Define cluster functions for elements
self._reverse_id_maps = []
for species in active_species:
for species_map in cluster_space.species_maps:
symbols = [periodic_table[n] for n in species_map]
if set(symbols) == species:
reverse_id_map = {1 - species_map[n]: periodic_table[n] for n in species_map}
self._reverse_id_maps.append(reverse_id_map)
break
self._count_symbols = [reverse_id_map[1] for reverse_id_map in self._reverse_id_maps]
# Generate full orbit list
self._orbit_list = cluster_space.orbit_list
lolg = LocalOrbitListGenerator(
orbit_list=self._orbit_list,
structure=Structure.from_atoms(primitive_structure),
fractional_position_tolerance=self._fractional_position_tolerance)
self._full_orbit_list = lolg.generate_full_orbit_list()
# Transform the parameters
binary_parameters = transform_parameters(primitive_structure,
self._full_orbit_list,
self._cluster_expansion.parameters)
self._transformed_parameters = binary_parameters
# Build model
self._model = self._build_model(structure)
# Properties that are defined when searching for a ground state
self._optimization_status = None
def _build_model(self,
structure: Atoms) -> highspy.Highs:
"""
Build a HiGHS model based on the provided structure.
Parameters
----------
structure
Atomic configuration.
"""
# Create cluster maps
self._create_cluster_maps(structure)
# Initiate MIP model
model = highspy.Highs()
# Spin variables (remapped) for all atoms in the structure
xs = {i: model.addBinary(name=f'atom_{i}')
for subl in self._active_sublattices for i in subl.indices}
ys = [model.addBinary(name=f'cluster_{i}')
for i in range(len(self._cluster_to_orbit_map))]
# Connect cluster variables to spin variables with cluster constraints
# TODO: don't create cluster constraints for singlets
constraint_count = 0
for i, cluster in enumerate(self._cluster_to_sites_map):
orbit = self._cluster_to_orbit_map[i]
parameter = self._transformed_parameters[orbit + 1]
assert parameter != 0
if len(cluster) < 2 or parameter < 0: # no "downwards" pressure
for atom in cluster:
model.addConstr(ys[i] <= xs[atom], f'Decoration -> cluster {constraint_count}')
constraint_count = constraint_count + 1
if len(cluster) < 2 or parameter > 0: # no "upwards" pressure
model.addConstr(
ys[i] >= 1 - len(cluster) + highspy.highs.qsum(xs[atom] for atom in cluster),
'Decoration -> cluster {}'.format(constraint_count))
constraint_count = constraint_count + 1
for sym, subl in zip(self._count_symbols, self._active_sublattices):
# Create slack variable
slack = model.addIntegral(lb=0, ub=len(subl.indices), name=f'slackvar_{sym}')
# Add slack constraint
model.addConstr(slack <= -1, f'{sym} slack')
# Set species constraint
model.addConstr(
highspy.highs.qsum([xs[i] for i in subl.indices]) + slack == -1, f'{sym} count')
# Add an objective function to the 'model'
model.setObjective(highspy.highs.qsum(self._get_total_energy(ys)),
highspy.ObjSense.kMinimize)
# Update the model so that variables and constraints can be queried
return model
def _create_cluster_maps(self, structure: Atoms) -> None:
"""
Create maps that include information regarding which sites and orbits
are associated with each cluster as well as the number of clusters per
orbit.
Parameters
----------
structure
Atomic configuration.
"""
# Generate full orbit list
lolg = LocalOrbitListGenerator(
orbit_list=self._orbit_list,
structure=Structure.from_atoms(structure),
fractional_position_tolerance=self._fractional_position_tolerance)
full_orbit_list = lolg.generate_full_orbit_list()
# Create maps of site indices and orbits for all clusters
cluster_to_sites_map = []
cluster_to_orbit_map = []
for orb_index in range(len(full_orbit_list)):
clusters = full_orbit_list.get_orbit(orb_index).clusters
# Determine the sites and the orbit associated with each cluster
for cluster in clusters:
# Do not include clusters for which the parameter is 0
parameter = self._transformed_parameters[orb_index + 1]
if parameter == 0:
continue
# Add the the list of sites and the orbit to the respective cluster maps
cluster_sites = [site.index for site in cluster.lattice_sites]
cluster_to_sites_map.append(cluster_sites)
cluster_to_orbit_map.append(orb_index)
# calculate the number of clusters per orbit
nclusters_per_orbit = [cluster_to_orbit_map.count(i) for i in
range(cluster_to_orbit_map[-1] + 1)]
nclusters_per_orbit = [1] + nclusters_per_orbit
self._cluster_to_sites_map = cluster_to_sites_map
self._cluster_to_orbit_map = cluster_to_orbit_map
self._nclusters_per_orbit = nclusters_per_orbit
def _get_total_energy(self, cluster_instance_activities: list[int]) -> list[float]:
r"""
Calculates the total energy using the expression based on binary variables.
.. math::
H({\boldsymbol x}, {\boldsymbol E})=E_0+
\sum\limits_j\sum\limits_{{\boldsymbol c}
\in{\boldsymbol C}_j}E_jy_{{\boldsymbol c}},
where (:math:`y_{{\boldsymbol c}}=
\prod\limits_{i\in{\boldsymbol c}}x_i`).
Parameters
----------
cluster_instance_activities
list of cluster instance activities, (:math:`y_{{\boldsymbol c}}`)
"""
E = [0.0 for _ in self._transformed_parameters]
for i in range(len(cluster_instance_activities)):
orbit = self._cluster_to_orbit_map[i]
E[orbit + 1] = E[orbit + 1] + cluster_instance_activities[i]
E[0] = 1
E = [0.0 if np.isclose(self._transformed_parameters[orbit], 0.0) else
E[orbit] * (self._transformed_parameters[orbit] / self._nclusters_per_orbit[orbit])
for orbit in range(len(self._transformed_parameters))]
return E
def _change_constraint_bounds(self, name, lower_bound=None, upper_bound=None) -> None:
r"""
Changes the lower (:math:`b_l`) and upper bound (:math:`b_u`) of the specified
constraint (:math:`b_l\leq\sum\limits_ic_ix_i\leq b_u`). Note that both should be set to
the same value to represent an equality (:math:`\leq\sum\limits_ic_ix_i=b_l=b_u`).
Parameters
----------
name
Name of constraint (key in the :attr:`self.constraints` dictionary).
lower_bound
New lower bound for the constraint.
upper_bound
New upper bound for the constraint.
"""
if lower_bound is None and upper_bound is None:
raise ValueError('Specify at least one bound.')
cons = self.constraints[name]
expr = cons.expr()
if lower_bound is None:
lower_bound = expr.bounds[0]
elif np.isneginf(lower_bound):
lower_bound = -self._model.inf
if upper_bound is None:
upper_bound = expr.bounds[1]
elif np.isposinf(upper_bound):
upper_bound = self._model.inf
expr.bounds = (lower_bound, upper_bound)
self._model.removeConstr(cons)
self._model.addConstr(expr, name)
[docs]
def get_ground_state(self,
species_count: dict[str, int] = None,
max_seconds: float = inf,
threads: int = 0) -> Atoms:
"""Finds the ground state for a given structure and species count. If
:attr:`species_count` is not provided when initializing the
instance of this class the first species in the list of
chemical symbols for the active sublattice will be used.
Parameters
----------
species_count
Dictionary with count for one of the species on each active
sublattice. If no count is provided for a sublattice, the
concentration is allowed to vary.
max_seconds
Maximum runtime in seconds.
threads
Number of threads to be used when solving the problem, given that a
positive integer has been provided. If set to :math:`0` the solver default
configuration is used while :math:`-1` corresponds to all available
processing cores.
"""
if species_count is None:
species_count = {}
# Check that the species_count is consistent with the cluster space
all_active_species = set.union(*self._active_species)
for symbol in species_count:
if symbol not in all_active_species:
raise ValueError('The species {} is not present on any of the active sublattices'
' ({})'.format(symbol, self._active_species))
# Solve model using the HiGHS MIP solver
model = self._model
# Update the species counts
for i, species in enumerate(self._active_species):
count_symbol = self._count_symbols[i]
max_count = len(self._active_sublattices[i].indices)
symbols_to_add = set.intersection(set(species_count), set(species))
if len(symbols_to_add) > 1:
raise ValueError('Provide counts for at most one of the species on each active '
'sublattice ({}), not {}!'.format(self._active_species,
list(species_count)))
elif len(symbols_to_add) == 1:
sym = symbols_to_add.pop()
count = species_count[sym]
if count < 0 or count > max_count:
raise ValueError('The count for species {} ({}) must be a positive integer and'
' cannot exceed the number of sites on the active sublattice '
'({})'.format(sym, count, max_count))
if sym == count_symbol:
xcount = count
else:
xcount = max_count - count
max_slack = 0
else:
xcount = max_slack = max_count
self._change_constraint_bounds(f'{count_symbol} count', xcount, xcount)
self._change_constraint_bounds(f'{count_symbol} slack', upper_bound=max_slack)
# Set the time limit and number of threads
model.setOptionValue('threads', threads)
model.setOptionValue('time_limit', max_seconds)
# Optimize the model
model.run()
self._optimization_status = model.getModelStatus()
# The status of the solution is printed to the screen
if model.modelStatusToString(self._optimization_status) != 'Optimal':
if model.modelStatusToString(self._optimization_status) == 'ObjectiveTarget':
logger.info('Target value for the model objective has been reached.')
else:
raise Exception('Optimization failed ({0})'.format(
model.modelStatusToString(self._optimization_status)))
# Each of the variables is printed with it's resolved optimum value
values = list(model.getSolution().col_value)
gs = self.structure.copy()
active_index_to_sublattice_map = {i: j for j, subl in enumerate(self._active_sublattices)
for i in subl.indices}
for v in model.getVariables():
if 'atom' in v.name:
index = int(v.name.split('_')[-1])
sublattice_index = active_index_to_sublattice_map[index]
gs[index].symbol = self._reverse_id_maps[sublattice_index][np.rint(values[v.index])]
# Assert that the solution agrees with the prediction
prediction = self._cluster_expansion.predict(gs)
assert abs(model.getInfo().objective_function_value - prediction) < 1e-6
return gs
@property
def optimization_status(self) -> highspy.HighsModelStatus:
""" Optimization status. """
return self._optimization_status
@property
def model(self) -> highspy.Highs:
""" HiGHS model. """
return self._model
@property
def constraints(self) -> dict[str, highs_cons]:
""" Dictionary with highs_cons objects and the names as keys. """
return {cons.name: cons for cons in self._model.getConstrs()}