Source code for idaes.apps.matopt.opt.mat_modeling

#################################################################################
# The Institute for the Design of Advanced Energy Systems Integrated Platform
# Framework (IDAES IP) was produced under the DOE Institute for the
# Design of Advanced Energy Systems (IDAES).
#
# Copyright (c) 2018-2023 by the software owners: The Regents of the
# University of California, through Lawrence Berkeley National Laboratory,
# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon
# University, West Virginia University Research Corporation, et al.
# All rights reserved.  Please see the files COPYRIGHT.md and LICENSE.md
# for full copyright and license information.
#################################################################################
from abc import abstractmethod
from itertools import product

from pyomo.core.base.param import SimpleParam
from pyomo.opt.results import SolutionStatus

from .pyomo_modeling import *
from ..materials.design import Design


class IndexedElem(object):
    """Base class for indexed MatOpt objects.

    Should not be necessary for users to instantiate, but developers will
    utilize the constructors (especially fromComb) and mask method when
    creating new classes of Expression and Rule objects.

    Attributes:
        sites (list<int>): The sites that the object is indexed over
        bonds (list<int>): The bonds that the object is indexed over
        site_types (list<int>): The site types that the object is indexed over
        bond_types (list<int>): The bond types that the object is indexed over
        confs (list<int>): The conformations that the object is indexed over
    """

    # === STANDARD CONSTRUCTOR
    def __init__(
        self, sites=None, bonds=None, site_types=None, bond_types=None, confs=None
    ):
        """Standard constructor of IndexedElem.

        Args:
            sites (list<int>): The sites that the object is indexed over
            bonds (list<tuple<int,int>>): The bonds that the object is indexed
                over
            site_types (list<BBlock>): The site types that the object is indexed
                over
            bond_types (list<tuple<BBlock,BBlock>>): The bond types that the
                object is indexed over
            confs (list<int>): The conformations that the object is indexed over
        """

        self._sites = sites
        self._bonds = bonds
        self._site_types = site_types
        self._bond_types = bond_types
        self._confs = confs

    # === CONSTRUCTOR - From combinations of other IndexedElem objects
    @classmethod
    def fromComb(cls, *args):
        """
        Constructor of IndexedElem from other IndexedElem objects.

        This constructor creates a new indexing object by combining two
        other indexed objects. The various attributes are mixed according
        to three simple cases for each type of index:

        Case #1: Both objects are indexed over that index.
            The intersection of the two lists of indices is used.
        Case #2: Only one object has that index.
            The resulting object gains the index.
        Case #3: Both objects are not indexed over that index.
            The resulting object is also not indexed over that index.

        This is simply meant to reproduce the way we use set notation for
        writing products of expressions and variables in constraints.

        Args:
            *args (list<IndexedElem>): Objects to combine indices for.

        Returns:
            (IndexedElem) Index object from combination of indices.
        """
        LHS, RHS, *args = args
        Comb = cls._fromComb2(LHS, RHS)
        if len(args) > 0:
            return cls.fromComb(Comb, *args)
        else:
            return Comb

    # === AUXILIARY METHODS
    @classmethod
    def _fromComb2(cls, LHS, RHS):
        """Constructor of an IndexedElem from two other IndexedElem objects.

        Utilized in the recursive fromComb method, above.

        Args:
            LHS (IndexedElem): Object to combine indices for.
            RHS (IndexedElem): Object to combine indices for.

        Returns:
            (IndexedElem) Index object from combination of indices.
        """

        if LHS.sites is not None and RHS.sites is not None:
            sites = list(set(LHS.sites) & set(RHS.sites))
        else:
            sites = LHS.sites if LHS.sites is not None else RHS.sites
        if LHS.bonds is not None and RHS.bonds is not None:
            bonds = list(set(LHS.bonds) & set(RHS.bonds))
        else:
            bonds = LHS.bonds if LHS.bonds is not None else RHS.bonds
        if LHS.site_types is not None and RHS.site_types is not None:
            site_types = list(set(LHS.site_types) & set(RHS.site_types))
        else:
            site_types = (
                LHS.site_types if LHS.site_types is not None else RHS.site_types
            )
        if LHS.bond_types is not None and RHS.bond_types is not None:
            bond_types = list(set(LHS.bond_types) & set(RHS.bond_types))
        else:
            bond_types = (
                LHS.bond_types if LHS.bond_types is not None else RHS.bond_types
            )
        if LHS.confs is not None and RHS.confs is not None:
            confs = list(set(LHS.confs) & set(RHS.confs))
        else:
            confs = LHS.confs if LHS.confs is not None else RHS.confs
        return cls(
            sites=sites,
            bonds=bonds,
            site_types=site_types,
            bond_types=bond_types,
            confs=confs,
        )

    def mask(self, index, Comb):
        """
        Method to identify the indexes relevant to this object.

        Given an instance of index that was generated by another IndexedElem
        object (Comb), we identify which parts of that index were relevant
        to this object.

        Example::

            VarIndexes = IndexedElem(sites=[1,2])
            CoefIndexes = IndexedElem(site_types=['A','B'])
            Comb = IndexedElem.fromComb(VarIndexes,CoefIndexes)
            for k in Comb.keys():
                site = VarIndexes.mask(k,Comb)
                site_type = CoefIndexes.mask(k,Comb)

        Args:
            index (tuple<int/BBlock>): index from which to identify relevant parts
            Comb (IndexedElem): object from which the index was generated

        Returns:
            (tuple<int/BBlock>) index with indices relevant to this object remaining
        """
        if Comb.sites is not None:
            i, *index = index
        if Comb.bonds is not None:
            i, j, *index = index
        if Comb.site_types is not None:
            k, *index = index
        if Comb.bond_types is not None:
            k, l, *index = index
        if Comb.confs is not None:
            c, *index = index
        result = []
        if self.sites is not None:
            result.append(i)
        if self.bonds is not None:
            result.append(i)
            result.append(j)
        if self.site_types is not None:
            result.append(k)
        if self.bond_types is not None:
            result.append(k)
            result.append(l)
        if self.confs is not None:
            result.append(c)
        if not result:
            result = [None]
        return tuple(result)

    @property
    def dims(self):
        """Relevant dimensions of indices.

        Returns:
            (list<bool>) flags to indicate which index sets are relevant.
        """

        return (
            self.sites is not None,
            self.bonds is not None,
            self.site_types is not None,
            self.bond_types is not None,
            self.confs is not None,
        )

    @property
    def index_sets(self):
        """Sets (actually lists) of indices.

        Note that in the cases that there are no relevant indices, a
        dummy list [[None]] is returned to allow the [None] key to be
        included.

        Returns:
            (list<list<int/BBlock>>) lists of indices of each relevant type.
        """

        result = [
            s
            for s in (
                self.sites,
                self.bonds,
                self.site_types,
                self.bond_types,
                self.confs,
            )
            if s is not None
        ]
        if not result:
            result = [[None]]
        return result

    @property
    def index_dict(self):
        """Dictionary of relevant attributes.

        Returns:
            (dict<string:list<int/BBlock>>) attributes of this IndexedElem.
        """

        return {
            "sites": self.sites,
            "bonds": self.bonds,
            "site_types": self.site_types,
            "bond_types": self.bond_types,
            "confs": self.confs,
        }

    @property
    def sites(self):
        """List of sites relevant to this object."""
        return self._sites

    @property
    def bonds(self):
        """List of bonds relevant to this object."""
        return self._bonds

    @property
    def site_types(self):
        """List of site types relevant to this object."""
        return self._site_types

    @property
    def bond_types(self):
        """List of bond types relevant to this object."""
        return self._bond_types

    @property
    def confs(self):
        """List of conformation types relevant to this object."""
        return self._confs

    def keys(self):
        """Method creating a generator for the keys relevant to this object.

        Note that the [None] key is returned in the case of an object
        not indexed by any of the possible index types. In other words,
        [None] is the key for a scalar variable/expression/coefficient.

        Returns:
            (generator<tuple<int/BBlock>>) keys generator (similar to dict.keys()).
        """

        index_sets = self.index_sets
        if len(index_sets) > 1:
            return product(*self.index_sets)
        elif len(index_sets) == 1:
            return (k for k in index_sets[0])
        else:
            raise NotImplementedError("There should always be at least " "a [None] key")


class Coef(IndexedElem):
    """A class for coefficient data indexed over material index sets.

    This class is useful for representing indexed data and automatically
    generating complex indexed expressions. For example, the multiplication
    of bond-type coefficients with bond-indexed variables would not be
    easily representable via standard Python objects.

    The key benefit is that these objects have the useful IndexedElem methods
    and also allow access of data via getitem operators.

    Attributes:
    vals (dict/list) data structure of coefficient values.
    (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, vals, **kwargs):
        """Standard constructor of indexed coefficients.

        Args:
            vals (list<float>/dict/other): Any data structure that supports the
                __getitem__ method for keys generated by this object's
                IndexedElem.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """

        self.vals = vals
        IndexedElem.__init__(self, **kwargs)

    # === BASIC QUERY METHODS
    def __getitem__(self, k):
        """Method to access coefficient values by bracket operator"""
        return self.vals[k]


class Expr(IndexedElem):
    """An abstract class for representing expressions when building rules.

    The key benefit of this class is that we utilize the useful methods of
    IndexedElem and establish the interface for derived expressions.

    Expressions can be generated over a subset of the design space
    (i.e., only for some combinations of sites, site-types, etc.) by
    providing keywords that are passed to the constructor of IndexedElem.
    Else, the relevant indexes are inferred from the expression components.

    Attributes:
        (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, **kwargs):
        """Standard constructor for abstract class Expr.

        Args:
        **kwargs: Optional, index information passed to IndexedElem if
            interested in a subset of indices
            Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        IndexedElem.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    @abstractmethod
    def _pyomo_expr(self, index=None):
        """Abstract interface for generating Pyomo expressions.

        Args:
        index (list): Optional, index to to create an instance of a Pyomo
            expression. In the case of a scalar, the valid index is None.

        Returns:
        An instance of a Pyomo expression.
        """
        raise NotImplementedError


class LinearExpr(Expr):
    """A class for representing simple expressions of site descriptors.

    The use of this class is to generate expressions from multiplication and
    summation of coefficients and descriptors. Importantly, expressions
    of this type maintain the same indexing of their component descriptors
    and coefficients. Summation is taken across multiple descriptors, not
    multiple instances of indexes.

    Attributes:
        coefs (float/list<float>): coefficient to multiply each descriptor by
        descs (Descriptor/list<Descriptor>): descriptors to add together
        offset (float/int): scalar value to add to the expression
            (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, descs=None, coefs=1.0, offset=0.0, **kwargs):
        """Standard constructor for linear expression objects.

        Args:
            coefs (float/list<float>): Optional, coefficient to multiply
                each descriptor by. Default: 1.0
            descs (Descriptor/list<Descriptor>): descriptors to add
            offset (float/int): Optional, scalar value to add to the expression
                Default: 0.0
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.coefs = coefs if type(coefs) is list else [coefs]
        self.descs = descs if type(descs) is list else [descs]
        self.offset = offset
        if descs is not None:
            if type(descs) is list:
                Comb = IndexedElem.fromComb(*descs)
                kwargs = {**Comb.index_dict, **kwargs}
            else:
                kwargs = {**descs.index_dict, **kwargs}
        Expr.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_expr(self, index=None):
        """Interface for generating Pyomo expressions.

        Args:
            index (list): Optional, index to to create an instance of a Pyomo
                expression. In the case of a scalar, the valid index is None.

        Returns:
            An instance of a Pyomo expression.
        """
        result = self.offset
        for it, desc in enumerate(self.descs):
            if desc is not None and self.coefs[it] is not None:
                result += self.coefs[it] * desc._pyomo_var[index]
        return result


class SiteCombination(Expr):
    """A class for representing summations of descriptors at two sites.

    Attributes:
        coefi (float/list<float>): coefficients at the first site
        desci (Descriptor/Expr): descriptor or expression for the first site
        coefj (float/list<float>): coefficients at the second site
        descj (Descriptor/Expr): descriptor or expression for the second site
        offset (float): scalar coefficient to add to the rest of the expression
        symmetric_bonds (bool): flag to indicate if site combinations should be
            considered symmetric (and therefore, should only generate half as
            many terms) (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(
        self,
        coefi,
        desci,
        coefj=None,
        descj=None,
        offset=0.0,
        symmetric_bonds=False,
        **kwargs
    ):
        """Standard constructor for site combination expressions.

        Args:
            coefi (float/list<float>): coefficients at the first site.
                Default: 1.0
            desci (Descriptor/Expr): term for first site
            coefj (float/list<float>): Optional, coefficients at the second site.
                Default: Equal to the coefficient for the first site.
            descj (Descriptor/Expr): Optional, term for the second site.
                Default: Equal to the descriptor for the first site.
            offset (float): Optional, scalar coefficient to add to expression.
                Default: 1.0
            symmetric_bonds (bool): Optional, flag to indicate if combinations
                are symmetric.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.coefi = coefi
        self.desci = desci
        self.coefj = coefj if coefj is not None else coefi
        self.descj = descj if descj is not None else desci
        self.offset = offset
        self.symmetric_bonds = symmetric_bonds
        if "bonds" not in kwargs:
            kwargs["bonds"] = [
                (i, j)
                for i in desci.sites
                for j in desci.canv.NeighborhoodIndexes[i]
                if (j is not None and (not symmetric_bonds or j > i))
            ]
        if "bond_types" in kwargs:
            pass  # use the kwargs bond_types
        elif coefi.bond_types is not None:
            kwargs["bond_types"] = coefi.bond_types
        elif desci.site_types is not None:
            kwargs["bond_types"] = [
                (k, l) for k in desci.site_types for l in desci.site_types
            ]
        Expr.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_expr(self, index=None):
        """Interface for generating Pyomo expressions.

        Args:
            index (list): Optional, index to to create an instance of a Pyomo
                expression. In the case of a scalar, the valid index is None.

        Returns:
            An instance of a Pyomo expression.
        """
        if len(index) == 4:
            i, j, k, l = index
        elif len(index) == 2:
            i, j, k, l = index[0], index[1], (), ()
        else:
            raise NotImplementedError(
                "Decide how to split the extra " "indices in this case..."
            )
        if (
            type(self.coefi) is float
            or type(self.coefi) is int
            or type(self.coefi) is SimpleParam
        ):
            ci = self.coefi
        else:
            coefi_index = self.coefi.mask(
                (i, i, j, k, k, l),
                IndexedElem(
                    sites=[i], bonds=[(i, j)], site_types=[k], bond_types=[(k, l)]
                ),
            )
            ci = self.coefi[coefi_index]

        if (
            type(self.coefj) is float
            or type(self.coefj) is int
            or type(self.coefj) is SimpleParam
        ):
            cj = self.coefj
        else:
            coefj_index = self.coefj.mask(
                (j, j, i, l, l, k),
                IndexedElem(
                    sites=[j], bonds=[(j, i)], site_types=[l], bond_types=[(l, k)]
                ),
            )
            cj = self.coefj[coefj_index]
        desci_index = self.desci.mask(
            (i, i, j, k, k, l),
            IndexedElem(sites=[i], bonds=[(i, j)], site_types=[k], bond_types=[(k, l)]),
        )
        descj_index = self.descj.mask(
            (j, j, i, l, l, k),
            IndexedElem(sites=[j], bonds=[(j, i)], site_types=[l], bond_types=[(l, k)]),
        )
        di = self.desci._pyomo_expr(index=desci_index)
        dj = self.descj._pyomo_expr(index=descj_index)
        return self.offset + ci * di + cj * dj


class SumNeighborSites(Expr):
    """A class for expressions for summation across neighbor sites.

    Attributes:
        desc (Descriptor): descriptors to sum around a site
        coefs (float/list<float>): Optional, coefficients to multiple each
            neighbor term by. Default=1.0
        offset: Optional, term to add to the expression.
            Default=0.0 (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, desc, coefs=1.0, offset=0.0, **kwargs):
        """Standard constructor for expressions of neighbor summations.

        Args:
            desc (Descriptor): descriptors to sum around a site
            coefs (float/list<float>): Optional, coefficients to multiple each
                neighbor term by. Default=1.0
            offset: Optional, term to add to the expression.
                Default=0.0
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.desc = desc
        self.coefs = coefs
        self.offset = offset
        kwargs = {**desc.index_dict, **kwargs}
        Expr.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_expr(self, index=None):
        """Interface for generating Pyomo expressions.

        Args:
        index (list): Optional, index to to create an instance of a Pyomo
            expression. In the case of a scalar, the valid index is None.

        Returns:
        An instance of a Pyomo expression.
        """
        i, *index = index
        if index == (None,):
            index = ()
        result = self.offset
        for n, j in enumerate(self.desc.canv.NeighborhoodIndexes[i]):
            if j is not None:
                result += (
                    self.coefs
                    if (
                        type(self.coefs) is float
                        or type(self.coefs) is int
                        or type(self.coefs) is SimpleParam
                    )
                    else self.coefs[n]
                ) * self.desc._pyomo_var[(j, *index)]
        return result


class SumNeighborBonds(Expr):
    """A class for expressions from summation of neighbor bond descriptors.

    Attributes:
        desc (Descriptor/Expr): descriptors to sum over
        coefs (float/list<float>): coefficients to multiply bonds to neighbor sites
        offset (float): coefficient to add to the expression
        symmetric_bonds (bool): flag to indicate if bond variables should be
            added in a symmetric way (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, desc, coefs=1.0, offset=0.0, symmetric_bonds=False, **kwargs):
        """Standard constructor for summation of neighboring bonds

        Args:
            desc (Descriptor): descriptors to sum around a site
            coefs (float/list<float>): Optional, coefficients to multiple each
                neighbor term by. Default=1.0
            offset (float): Optional, coefficient to add to the expression.
                Default=0.0
            symmetric_bonds (bool): Optional, flag to indicate if bond variables
                should be considered symmetric.
                Default=False
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.desc = desc
        self.coefs = coefs
        self.offset = offset
        self.symmetric_bonds = symmetric_bonds
        kwargs = {**desc.index_dict, **kwargs}
        Expr.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_expr(self, index=None):
        """Interface for generating Pyomo expressions.

        Args:
        index (list): Optional, index to to create an instance of a Pyomo
            expression. In the case of a scalar, the valid index is None.

        Returns:
        An instance of a Pyomo expression.
        """
        i, *index = index
        if index == (None,):
            index = ()
        result = self.offset
        for n, j in enumerate(self.desc.canv.NeighborhoodIndexes[i]):
            if j is not None:
                if self.symmetric_bonds:
                    i, j = min(i, j), max(i, j)
                result += (
                    self.coefs
                    if (
                        type(self.coefs) is float
                        or type(self.coefs) is int
                        or type(self.coefs) is SimpleParam
                    )
                    else self.coefs[n]
                ) * self.desc._pyomo_expr(index=(i, j, *index))
        return result


class SumSites(Expr):
    """A class for expressions formed by summation over canvas sites.

    Attributes:
        desc (Descriptor/Expr): descriptors or expressions to sum over
        coefs (float/list<float>): coefficients to multiply contributions
            from each site
        offset (float): coefficient to add to the expression
        sites_to_sum (list<int>): sites to consider in the summation
            (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, desc, coefs=1.0, offset=0.0, sites_to_sum=None, **kwargs):
        """Standard constructor for summation of site contributions.

        Args:
            desc (Descriptor): descriptors or expressions to sum across all sites
            coefs (float/list<float>): Optional, coefficients to multiple each
                site term by. Default=1.0
            offset (float): Optional, coefficient to add to the expression.
                Default=0.0
            sites_to_sum (list<int>): Optional, subset of canvas sites to sum.
                Default=None, meaning all sites in the desc object are considered.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.desc = desc
        self.coefs = coefs
        self.offset = offset
        self.sites_to_sum = sites_to_sum if sites_to_sum is not None else desc.sites
        kwargs = {**desc.index_dict, **kwargs}
        kwargs.pop("sites")
        Expr.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_expr(self, index=None):
        """Interface for generating Pyomo expressions.

        Args:
            index (list): Optional, index to to create an instance of a Pyomo
                expression. In the case of a scalar, the valid index is None.

        Returns:
            An instance of a Pyomo expression.
        """
        if index == (None,):
            index = ()
        result = self.offset
        for i in self.sites_to_sum:
            result += (
                self.coefs
                if (
                    type(self.coefs) is float
                    or type(self.coefs) is int
                    or type(self.coefs) is SimpleParam
                )
                else self.coefs[(i, *index)]
            ) * self.desc._pyomo_var[(i, *index)]
        return result


class SumBonds(Expr):
    """A class for expressions formed by summation over canvas bonds.

    Attributes:
        desc (Descriptor/Expr): descriptors or expressions to sum over
        coefs (float/list<float>): coefficients to multiply contributions
            from each bond
        offset (float): coefficient to add to the expression
        bonds_to_sum (list<int>): bonds to consider in the summation
            (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, desc, coefs=1.0, offset=0.0, bonds_to_sum=None, **kwargs):
        """Standard constructor for summation of bond contributions.

        Args:
            desc (Descriptor): descriptors or expressions to sum across all bonds
            coefs (float/list<float>): Optional, coefficients to multiple each
                bond term by. Default=1.0
            offset (float): Optional, coefficient to add to the expression.
                Default=0.0
            bonds_to_sum (list<int>): Optional, subset of canvas bonds
                (i.e., neighbor connections) to sum.
                Default=None, meaning all bonds in the desc object are considered.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.desc = desc
        self.coefs = coefs
        self.offset = offset
        self.bonds_to_sum = bonds_to_sum if bonds_to_sum is not None else desc.bonds
        kwargs = {**desc.index_dict, **kwargs}
        kwargs.pop("bonds")
        Expr.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_expr(self, index=None):
        """Interface for generating Pyomo expressions.

        Args:
            index (list): Optional, index to to create an instance of a Pyomo
                expression. In the case of a scalar, the valid index is None.

        Returns:
            An instance of a Pyomo expression.
        """
        if index == (None,):
            index = ()
        result = self.offset
        for i, j in self.bonds_to_sum:
            result += (
                self.coefs
                if (
                    type(self.coefs) is float
                    or type(self.coefs) is int
                    or type(self.coefs) is SimpleParam
                )
                else self.coefs[(i, j, *index)]
            ) * self.desc._pyomo_var[(i, j, *index)]
        return result


class SumSiteTypes(Expr):
    """A class for expressions formed by summation over building block types.

    Attributes:
        desc (Descriptor/Expr): descriptors or expressions to sum over
        coefs (float/list<float>): coefficients to multiply contributions
            from each building block type
        offset (float): coefficient to add to the expression
        site_types_to_sum (list<BBlock>): building block types to consider in
            the summation (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, desc, coefs=1.0, offset=0.0, site_types_to_sum=None, **kwargs):
        """Standard constructor for summation of contributions by site-type.

        Args:
            desc (Descriptor): descriptors or expressions to sum across site types
            coefs (float/list<float>): Optional, coefficients to multiple each
                site-type term by. Default=1.0
            offset (float): Optional, coefficient to add to the expression.
                Default=0.0
            bonds_types_to_sum (list<int>): Optional, subset of site types
                to sum. Default=None, meaning all site-types in the desc object are
                considered.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.desc = desc
        self.coefs = coefs
        self.offset = offset
        self.site_types_to_sum = (
            site_types_to_sum if site_types_to_sum is not None else desc.site_types
        )
        kwargs = {**desc.index_dict, **kwargs}
        kwargs.pop("site_types")
        Expr.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_expr(self, index=None):
        """Interface for generating Pyomo expressions.

        Args:
            index (list): Optional, index to to create an instance of a Pyomo
                expression. In the case of a scalar, the valid index is None.

        Returns:
            An instance of a Pyomo expression.
        """
        assert index is not None
        i, *index = index
        if index == (None,):
            index = ()
        result = self.offset
        for k in self.site_types_to_sum:
            result += (
                self.coefs
                if (
                    type(self.coefs) is float
                    or type(self.coefs) is int
                    or type(self.coefs) is SimpleParam
                )
                else self.coefs[(i, k, *index)]
            ) * self.desc._pyomo_var[(i, k, *index)]
        return result


class SumBondTypes(Expr):
    """A class for expressions formed by summation over building block types.

    Attributes:
        desc (Descriptor/Expr): descriptors or expressions to sum over
        coefs (float/list<float>): coefficients to multiply contributions
            from each pair of building block types
        offset (float): coefficient to add to the expression
        bond_types_to_sum (list<tuple<BBlock,BBlock>>): building block pairs
            to consider in the summation
            (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, desc, coefs=1.0, offset=0.0, bond_types_to_sum=None, **kwargs):
        """Standard constructor for summation of contributions by bond-type.

        Args:
            desc (Descriptor): descriptors or expressions to sum across bond types
            coefs (float/list<float>): Optional, coefficients to multiple each
                bond-type term by. Default=1.0
            offset (float): Optional, coefficient to add to the expression.
                Default=0.0
            bonds_types_to_sum (list<tuple<BBlock,BBlock>>): Optional, subset
                of bond types to sum.
                Default=None, meaning all bond-types in the desc object are
                considered.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.desc = desc
        self.coefs = coefs
        self.offset = offset
        self.bond_types_to_sum = (
            bond_types_to_sum if bond_types_to_sum is not None else desc.bond_types
        )
        kwargs = {**desc.index_dict, **kwargs}
        kwargs.pop("bond_types")
        Expr.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_expr(self, index=None):
        """Interface for generating Pyomo expressions.

        Args:
        index (list): Optional, index to to create an instance of a Pyomo
            expression. In the case of a scalar, the valid index is None.

        Returns:
        An instance of a Pyomo expression.
        """
        assert index is not None
        i, j, *index = index
        if index == (None,):
            index = ()
        result = self.offset
        for k, l in self.bond_types_to_sum:
            result += (
                self.coefs
                if (
                    type(self.coefs) is float
                    or type(self.coefs) is int
                    or type(self.coefs) is SimpleParam
                )
                else self.coefs[(i, j, k, l, *index)]
            ) * self.desc._pyomo_var[(i, j, k, l, *index)]
        return result


class SumSitesAndTypes(Expr):
    """A class for expressions formed by summation over sites and building
    block types.

    Attributes:
        desc (Descriptor/Expr): descriptors or expressions to sum over
        coefs (float/list<float>): coefficients to multiply contributions
            from each building block type
        offset (float): coefficient to add to the expression
        sites_to_sum (list<int>): sites to consider in the summation
        site_types_to_sum (list<BBlock>): building block types to consider in
            the summation (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(
        self,
        desc,
        coefs=1.0,
        offset=0.0,
        sites_to_sum=None,
        site_types_to_sum=None,
        **kwargs
    ):
        """Standard constructor for summation of site contributions.

        Args:
            desc (Descriptor): descriptors or expressions to sum across all
                sites and site types
            coefs (float/list<float>): Optional, coefficients to multiple each
                site and site-type term by. Default=1.0
            offset (float): Optional, coefficient to add to the expression.
                Default=0.0
            sites_to_sum (list<int>): Optional, subset of canvas sites to sum.
                Default=None, meaning all sites in the desc object are considered.
            site_types_to_sum (list<BBlock>): Optional, subset of site types to
                sum. Default=None, meaning all site types in the desc object are
                considered.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.desc = desc
        self.coefs = coefs
        self.offset = offset
        self.sites_to_sum = sites_to_sum if sites_to_sum is not None else desc.sites
        self.site_types_to_sum = (
            site_types_to_sum if site_types_to_sum is not None else desc.site_types
        )
        Expr.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_expr(self, index=None):
        """Interface for generating Pyomo expressions.

        Args:
            index (list): Optional, index to to create an instance of a Pyomo
                expression. In the case of a scalar, the valid index is None.

        Returns:
            An instance of a Pyomo expression.
        """
        if index == (None,):
            index = ()
        result = self.offset
        for i in self.sites_to_sum:
            for k in self.site_types_to_sum:
                result += (
                    self.coefs
                    if (
                        type(self.coefs) is float
                        or type(self.coefs) is int
                        or type(self.coefs) is SimpleParam
                    )
                    else self.coefs[(i, k, *index)]
                ) * self.desc._pyomo_var[(i, k, *index)]
        return result


class SumBondsAndTypes(Expr):
    """A class for expressions formed by summation over bonds and bond types.

    Attributes:
        desc (Descriptor/Expr): descriptors or expressions to sum over
        coefs (float/list<float>): coefficients to multiply contributions
            from each combination of bond and building block type
        offset (float): coefficient to add to the expression
        bonds_to_sum (list<tuple<int,int>>): bonds to consider in the summation
        bond_types_to_sum (list<tuple<BBlock,BBlock>>): building block types to
            consider in the summation
            (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(
        self,
        desc,
        coefs=1.0,
        offset=0.0,
        bonds_to_sum=None,
        bond_types_to_sum=None,
        **kwargs
    ):
        """Standard constructor for summation of contributions by bond-type.

        Args:
            desc (Descriptor): descriptors or expressions to sum across bonds
                and bond types
            coefs (float/list<float>): Optional, coefficients to multiple each
                term by. Default=1.0
            offset (float): Optional, coefficient to add to the expression.
                Default=0.0
            bonds_to_sum (list<int>): Optional, subset of bonds to sum.
                Default=None, meaning all bonds in the desc object are considered.
            bonds_types_to_sum (list<int>): Optional, subset of bond types
                to sum. Default=None, meaning all bond-types in the desc object are
                considered.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.desc = desc
        self.coefs = coefs
        self.offset = offset
        self.bonds_to_sum = bonds_to_sum if bonds_to_sum is not None else desc.bonds
        self.bond_types_to_sum = (
            bond_types_to_sum if bond_types_to_sum is not None else desc.bond_types
        )
        Expr.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_expr(self, index=None):
        """Interface for generating Pyomo expressions.

        Args:
        index (list): Optional, index to to create an instance of a Pyomo
            expression. In the case of a scalar, the valid index is None.

        Returns:
        An instance of a Pyomo expression.
        """
        if index == (None,):
            index = ()
        result = self.offset
        for i, j in self.bonds_to_sum:
            for k, l in self.bond_types_to_sum:
                result += (
                    self.coefs
                    if (
                        type(self.coefs) is float
                        or type(self.coefs) is int
                        or type(self.coefs) is SimpleParam
                    )
                    else self.coefs[i, j, k, l]
                ) * self.desc._pyomo_var[i, j, k, l]
        return result


class SumConfs(Expr):
    """A class for expressions formed by summation over conformation types.

    Attributes:
        desc (Descriptor/Expr): descriptors or expressions to sum over
        coefs (float/list<float>): coefficients to multiply contributions
            from each building block type
        offset (float): coefficient to add to the expression
        confs_to_sum (list<int>): conformations to consider in the summation
            (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, Zic, coefs=1.0, offset=0.0, confs_to_sum=None, **kwargs):
        """Standard constructor for summation of bond contributions.

        Args:
            Zic (Descriptor): descriptors or expressions to sum across
                conformations
            coefs (float/list<float>): Optional, coefficients to multiple each
                conformation term by. Default=1.0
            offset (float): Optional, coefficient to add to the expression.
                Default=0.0
            confs_to_sum (list<int>): Optional, subset of conformations to sum
                Default=None, meaning all conformations in the Zic object are
                considered.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.Zic = Zic
        self.coefs = coefs
        self.offset = offset
        self.confs_to_sum = confs_to_sum if confs_to_sum is not None else Zic.confs
        kwargs = {**Zic.index_dict, **kwargs}
        kwargs.pop("confs")
        Expr.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_expr(self, index=None):
        """Interface for generating Pyomo expressions.

        Args:
            index (list): Optional, index to to create an instance of a Pyomo
                expression. In the case of a scalar, the valid index is None.

        Returns:
            An instance of a Pyomo expression.
        """
        i, *index = index
        if index == (None,):
            index = ()
        result = self.offset
        for c in self.confs_to_sum:
            result += (
                self.coefs
                if (
                    type(self.coefs) is float
                    or type(self.coefs) is int
                    or type(self.coefs) is SimpleParam
                )
                else self.coefs[(i, c, *index)]
            ) * self.Zic._pyomo_var[(i, c, *index)]
        return result


class SumSitesAndConfs(Expr):
    """A class for expressions formed by summation over sites and building
    block types.

    Attributes:
        desc (Descriptor/Expr): descriptors or expressions to sum over
        coefs (float/list<float>): coefficients to multiply contributions
            from each building block type
        offset (float): coefficient to add to the expression
        sites_to_sum (list<int>): sites to consider in the summation
        confs_to_sum (list<BBlock>): conformations to consider in the summation
            (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(
        self, Zic, coefs=1.0, offset=0.0, sites_to_sum=None, confs_to_sum=None, **kwargs
    ):
        """Standard constructor for summation of site and conformation
           contributions.

        Args:
            Zic (Descriptor): descriptors or expressions to sum across all sites
                and conformations.
            coefs (float/list<float>): Optional, coefficients to multiple each
                site and conformation term by. Default=1.0
            offset (float): Optional, coefficient to add to the expression.
                Default=0.0
            sites_to_sum (list<int>): Optional, subset of canvas sites to sum.
                Default=None, meaning all sites in the desc object are considered.
            confs_to_sum (list<int>): Optional, subset of conformations to sum.
                Default=None, meaning all conformations in the desc object are
                considered.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.Zic = Zic
        self.coefs = coefs
        self.offset = offset
        self.sites_to_sum = sites_to_sum if sites_to_sum is not None else Zic.sites
        self.confs_to_sum = confs_to_sum if confs_to_sum is not None else Zic.confs
        Expr.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_expr(self, index=None):
        """Interface for generating Pyomo expressions.

        Args:
            index (list): Optional, index to to create an instance of a Pyomo
                expression. In the case of a scalar, the valid index is None.

        Returns:
            An instance of a Pyomo expression.
        """
        if index == (None,):
            index = ()
        result = self.offset
        for i in self.sites_to_sum:
            for c in self.confs_to_sum:
                result += (
                    self.coefs
                    if (
                        type(self.coefs) is float
                        or type(self.coefs) is int
                        or type(self.coefs) is SimpleParam
                    )
                    else self.coefs[(i, c, *index)]
                ) * self.Zic._pyomo_var[(i, c, *index)]
        return result


class DescriptorRule(IndexedElem):
    """An abstract base class for rules to define material descriptors.

    This class is only the abstract interface for other well-defined rules.
    Rules get attached to MaterialDescriptor objects and are intended to be
    interpretable in relation to the descriptor that they are attached to.

    Examples:
        'Coordination number is equal to the sum of variables for the presence
            of any bond to the neighbors of a site.'
            ->  m.CNi.rules.append(EqualTo(SumNeighborBonds(m.Bondij)))
       'Size of a cluster is equal to the number of of atoms'
           ->  m.ClusterSize.rules.append(EqualTo(SumSites(m.Yi)))

    Rules can be applied over a subset of the design space (i.e., only for
    some combinations of sites, site-types, etc.) by providing keywords
    that are passed to the constructor of IndexedElem. Else, the relevant
    indexes are inferred from the rule components.

    Attributes:
        (index information inherited from IndexedElem)
    """

    def __init__(self, **kwargs):
        """Standard constructor for DescriptorRule base class.

        Args:
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        IndexedElem.__init__(self, **kwargs)

    @abstractmethod
    def _pyomo_cons(self, var):
        """Abstract method to define necessary interface for descriptor rules.

        Args:
        var (MaterialDescriptor): Variable that the rule is attached to.
            (i.e., the variable that should be read before the class name to
            interpret the rule)

        Returns:
        (list<Constraint>) list of Pyomo constraint objects.
        """
        raise NotImplementedError


class SimpleDescriptorRule(DescriptorRule):
    """An base class for simple rules with a left and right hand side.

    This class is just intended to create a common interface for the
    EqualTo, LessThan, and GreaterThan rules.

    Attributes:
        expr (Expr): Right-hand side expression for the rule.
            (index information inherited from IndexedElem)
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, e, **kwargs):
        """Standard constructor for simple descriptor rules.

        Args:
            e (float/int/Expr): An expression to use are right hand side of
                a rule. If a float or int, a LinearExpr is created for the user.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.expr = LinearExpr(offset=e) if type(e) is float or type(e) is int else e
        kwargs = {**self.expr.index_dict, **kwargs}
        DescriptorRule.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_cons(self, var):
        """Method to create a Pyomo constraint from this rule.

        Args:
        var (MaterialDescriptor): The descriptor to be defined by this rule.

        Returns:
        (list<Constraint>) list of Pyomo constraint objects.
        """
        ConIndexes = IndexedElem.fromComb(var, self)
        return [Constraint(*ConIndexes.index_sets, rule=self._pyomo_rule(var))]

    def _pyomo_rule(self, LHS, operator, RHS):
        """Method to create a function for a Pyomo constraint rule.

        Args:
            LHS (MaterialDescriptor/Expr): The left hand side of a simple rule.
            operator (function): The relationship to encode in a rule.
            RHS (MaterialDescriptor/Expr): The right hand side of a simple rule.

        Returns:
            (function) A function interpretable by Pyomo for a 'rule' argument
        """
        ConIndexes = IndexedElem.fromComb(LHS, RHS)

        def rule(m, *args):
            LHS_index = LHS.mask(args, ConIndexes)
            RHS_index = RHS.mask(args, ConIndexes)
            return operator(LHS._pyomo_expr(LHS_index), RHS._pyomo_expr(RHS_index))

        return rule


class LessThan(SimpleDescriptorRule):
    """A class for rules implementing 'less than or equal to' an expression.

    Spelled out: 'the descriptor is less than or equal to a linear expression'

    Attributes:
        expr (Expr): Right-hand side expression for the rule.
            (index information inherited from IndexedElem)

    See DescriptorRule for more information.
    """

    # === STANDARD CONSTRUCTOR
    # --- Inherited from SimpleDescriptorRule ---

    # === PROPERTY EVALUATION METHODS
    def _pyomo_rule(self, desc):
        """Method to create a function for a Pyomo constraint rule.

        Args:
            desc (MaterialDescriptor/Expr): A descriptor to define as 'less than'
                the expression for this rule.

        Returns:
            (function) A function in the format of a Pyomo rule to construct a
            constraint.
        """

        def less_than(LHS, RHS):
            return LHS <= RHS

        return SimpleDescriptorRule._pyomo_rule(self, desc, less_than, self.expr)


class EqualTo(SimpleDescriptorRule):
    """A class for rules implementing 'equal to' an expression.

    Spelled out: 'the descriptor is equal to a linear expression'

    Attributes:
        expr (Expr): Right-hand side expression for the rule.
            (index information inherited from IndexedElem)

    See DescriptorRule for more information.
    """

    # === STANDARD CONSTRUCTOR
    # --- Inherited from SimpleDescriptorRule ---

    # === PROPERTY EVALUATION METHODS
    def _pyomo_rule(self, desc):
        """Method to create a function for a Pyomo constraint rule.

        Args:
        desc (MaterialDescriptor/Expr): A descriptor to define as 'equal to'
            the expression for this rule.

        Returns:
        (function) A function in the format of a Pyomo rule to construct a
            constraint.
        """

        def equal_to(LHS, RHS):
            return LHS == RHS

        return SimpleDescriptorRule._pyomo_rule(self, desc, equal_to, self.expr)


class GreaterThan(SimpleDescriptorRule):
    """A class for rules implementing 'greater than or equal to' an expr.

    Spelled out: 'descriptor is greater than or equal to a linear expression'

    Attributes:
        expr (Expr): Right-hand side expression for the rule.
            (index information inherited from IndexedElem)

    See DescriptorRule for more information.
    """

    # === STANDARD CONSTRUCTOR
    # --- Inherited from SimpleDescriptorRule ---

    # === PROPERTY EVALUATION METHODS
    def _pyomo_rule(self, desc):
        """Method to create a function for a Pyomo constraint rule.

        Args:
            desc (MaterialDescriptor/Expr): A descriptor to define as 'greater
                than' the expression for this rule.

        Returns:
            (function) A function in the format of a Pyomo rule to construct a
            constraint.
        """

        def greater_than(LHS, RHS):
            return LHS >= RHS

        return SimpleDescriptorRule._pyomo_rule(self, desc, greater_than, self.expr)


class FixedTo(DescriptorRule):
    """A class for rules that fix descriptors to required values.

    Spelled out: 'the descriptor is fixed to a scalar value'

    Attributes:
        val (float): the value that the descriptor is fixed to.
            (index information inherited from IndexedElem)

    See DescriptorRule for more information.
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, val, **kwargs):
        """Standard constructor for FixedTo rules.

        Args:
            val (float): The value to fix descriptors to.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.val = val
        DescriptorRule.__init__(self, **kwargs)

    def _pyomo_cons(self, var):
        """Method to create a Pyomo constraint from this rule.

        Args:
        var (MaterialDescriptor): The descriptor to be defined by this rule.

        Returns:
        (list<Constraint>) list of Pyomo constraint objects.
        """
        # NOTE: This method is used to ensure that basic variables that
        #       are fixed get referenced to write basic constraints in
        #       the model. Don't make constraints, but do instantiate
        #       variables
        Comb = IndexedElem.fromComb(var, self)
        for k in Comb.keys():
            var._pyomo_var[k]
        return []


class Disallow(DescriptorRule):
    """A class for rules that disallow a previously-identified design.

    Spelled out: 'the descriptors must attain a different solution than
        a given design'

    Attributes:
        D (Design): the design from which variable values to disallow are inferred
            (index information inherited from IndexedElem)

    See DescriptorRule for more information.
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, D, **kwargs):
        """Standard constructor for the Disallow rule.

        Args:
            D (Design): A design object to make infeasible in the resulting model
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.D = D
        DescriptorRule.__init__(self, **kwargs)

    def _pyomo_expr(self, var):
        """Method to create the integer cut for this disallowed design.

        Args:
            var (MaterialDescriptor): The descriptor to be defined by this rule.

        Returns:
            An instance of a Pyomo expression.
        """
        if var.name == "Yi":
            result = 0
            for i in range(len(self.D.Canvas)):
                if self.D.Contents[i] is None:
                    result += var._pyomo_var[i]
                else:
                    result += 1 - var._pyomo_var[i]
        elif var.name == "Yik":
            result = 0
            for i in range(len(self.D.Canvas)):
                for k in self.D.NonVoidElems:
                    if self.D.Contents[i] is not k:
                        result += var._pyomo_var[i, k]
                    else:
                        result += 1 - var._pyomo_var[i, k]
        else:
            # NOTE: This rule was intended to disallow structures
            #       or labelings of structures (i.e., Yi or Yik
            #       variables). It is not clear how we can generally
            #       disallow any general combination of variables.
            raise ValueError("Decide what to do in this case...")
        return result

    def _pyomo_cons(self, var):
        """
        Method to create a Pyomo constraint from this rule.

        Args:
            var (MaterialDescriptor): The descriptor to be defined by this rule.

        Returns:
            (list<Constraint>) list of Pyomo constraint objects.
        """
        return Constraint(expr=(self._pyomo_expr(var) >= 1))


class PiecewiseLinear(DescriptorRule):
    """A class for rules implementing 'equal to a piecewise linear function'.

    Spelled out: 'the descriptor is equal to a piecewise linear expression'

    Note: Innequalities of 'less than' or 'greater than' a piecewie function
    can be achieved by introducing an auxiliary descriptor to be equal to the
    piecewise function. Then, inequalities can be introduced using the
    auxiliary descriptor. Alternatively, users can modify the con_type
    attribute that is interpreted by Pyomo.

    Attributes:
        values (list<float>): values of univariate piecewise linear function at
            each breakpoint.
        breakpoints (list<float>): breakpoints of the piecewise linear function.
        input_desc (MaterialDescriptor): descriptor as the argument to the
            piecewise linear function
        con_type (string): indicates the bound type of the piecewise function.
            Options:
            “UB” - relevant descriptor is bounded above by piecewise function.
            “LB” - relevant descriptor is bounded below by piecewise function.
            “EQ” - relevant descriptor is equal to the piecewise function. (Default)

    See DescriptorRule for more information.
    """

    # === STANDARD CONSTRUCTOR
    def __init__(self, values, breakpoints, input_desc, con_type="EQ", **kwargs):
        """Standard constructor for simple descriptor rules.

        Args:
            values (list<float>): values of the function.
            breakpoints (list<float>): breakpoints of the function.
            input_desc (MaterialDescriptor): argument to the function
            con_type (string): Optional, indicates the bound type of the
                piecewise function
                Options:
                    “UB” - bounded above by piecewise function.
                    “LB” - bounded below by piecewise function.
                    “EQ” - equal to the piecewise function. (Default)
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.values = values
        self.breakpoints = breakpoints
        self.input_desc = input_desc
        self.con_type = con_type.upper()
        DescriptorRule.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_cons(self, var):
        """Method to create a Pyomo constraint from this rule.

        Args:
            var (MaterialDescriptor): The descriptor to be defined by this rule.

        Returns:
            (list<Block>) list of Pyomo model block objects created by Piecewise
                function.
        """
        Comb = IndexedElem.fromComb(var, self)
        return [
            Piecewise(
                *Comb.index_sets,
                var._pyomo_var,
                self.input_desc._pyomo_var,
                pw_pts=self.breakpoints,
                f_rule=self.values,
                pw_constr_type=self.con_type,
                pw_repn="MC"
            )
        ]


class Implies(DescriptorRule):
    """A class for rules that define simple logical implications.

    Spelled out: 'if this descriptor is true (i.e., equal to one),
        then another set of simple rules also apply'

    Attributes:
        concs (list<tuple<MaterialDescriptor,SimpleDescriptorRule>>):
            list of conclusions to enforce if the logical predicate is true.
            (index information inherited from IndexedElem)

    See DescriptorRule for more information.
    """

    DEFAULT_BIG_M = 9999

    # === STANDARD CONSTRUCTOR
    def __init__(self, concs, **kwargs):
        """Standard constructor for Implies rule.

        Args:
            concs (list<tuple<MaterialDescriptor,SimpleDescriptorRule>>):
                list of conclusions to conditionally enforce. Also, a single
                conclusion can be provided (i.e., a tuple<MaterialDescriptor,
                SimpleDescriptorRule>) and will be placed in a list.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.concs = concs if type(concs) is list else [concs]
        Comb = IndexedElem.fromComb(
            *(desc for desc, conc in self.concs), *(conc for desc, conc in self.concs)
        )
        kwargs = {**Comb.index_dict, **kwargs}
        DescriptorRule.__init__(self, **kwargs)

    def _pyomo_cons(self, var):
        """Method to create a Pyomo constraint from this rule.

        Args:
            var (MaterialDescriptor): The descriptor to be defined by this rule.

        Returns:
            (list<Constraint>) list of Pyomo constraint objects.
        """

        result = []
        for desc, conc in self.concs:
            ConIndexes = IndexedElem.fromComb(var, desc, conc)

            def rule_lb(m, *args):
                v = var._pyomo_expr(index=var.mask(args, ConIndexes))
                d = desc._pyomo_expr(index=desc.mask(args, ConIndexes))
                c = conc.expr._pyomo_expr(index=conc.expr.mask(args, ConIndexes))
                body_lb = getLB(d - c)
                MLB = body_lb if body_lb is not None else -Implies.DEFAULT_BIG_M
                return MLB * (1 - v) <= d - c

            def rule_ub(m, *args):
                v = var._pyomo_expr(index=var.mask(args, ConIndexes))
                d = desc._pyomo_expr(index=desc.mask(args, ConIndexes))
                c = conc.expr._pyomo_expr(index=conc.expr.mask(args, ConIndexes))
                body_ub = getUB(d - c)
                MUB = body_ub if body_ub is not None else Implies.DEFAULT_BIG_M
                return d - c <= MUB * (1 - v)

            if isinstance(conc, LessThan) or isinstance(conc, EqualTo):
                result.append(Constraint(*ConIndexes.index_sets, rule=rule_ub))
            if isinstance(conc, GreaterThan) or isinstance(conc, EqualTo):
                result.append(Constraint(*ConIndexes.index_sets, rule=rule_lb))
        return result


class NegImplies(DescriptorRule):
    """A class for rules that define logical implications with negation.

    Spelled out: 'if this descriptor is not true (i.e., is equal to zero),
        then another simple rule also applies'

    Attributes:
        concs (list<tuple<MaterialDescriptor,SimpleDescriptorRule>>):
            list of conclusions to enforce if the logical predicate is false.
            (index information inherited from IndexedElem)

    See DescriptorRule for more information.
    """

    DEFAULT_BIG_M = 9999

    # === STANDARD CONSTRUCTOR
    def __init__(self, concs, **kwargs):
        """Standard constructor for NegImplies rule.

        Args:
            concs (list<tuple<MaterialDescriptor,SimpleDescriptorRule>>):
                list of conclusions to conditionally enforce. Also, a single
                conclusion can be provided (i.e., a tuple<MaterialDescriptor,
                SimpleDescriptorRule>) and will be placed in a list.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.concs = concs if type(concs) is list else [concs]
        Comb = IndexedElem.fromComb(
            *(desc for desc, conc in self.concs), *(conc for desc, conc in self.concs)
        )
        kwargs = {**Comb.index_dict, **kwargs}
        DescriptorRule.__init__(self, **kwargs)

    def _pyomo_cons(self, var):
        """Method to create a Pyomo constraint from this rule.

        Args:
            var (MaterialDescriptor): The descriptor to be defined by this rule.

        Returns:
            (list<Constraint>) list of Pyomo constraint objects.
        """
        result = []
        for desc, conc in self.concs:
            ConIndexes = IndexedElem.fromComb(var, desc, conc)

            def rule_lb(m, *args):
                v = var._pyomo_expr(index=var.mask(args, ConIndexes))
                d = desc._pyomo_expr(index=desc.mask(args, ConIndexes))
                c = conc.expr._pyomo_expr(index=conc.expr.mask(args, ConIndexes))
                body_lb = getLB(d - c)
                MLB = body_lb if body_lb is not None else -NegImplies.DEFAULT_BIG_M
                return MLB * (v) <= d - c

            def rule_ub(m, *args):
                v = var._pyomo_expr(index=var.mask(args, ConIndexes))
                d = desc._pyomo_expr(index=desc.mask(args, ConIndexes))
                c = conc.expr._pyomo_expr(index=conc.expr.mask(args, ConIndexes))
                body_ub = getUB(d - c)
                MUB = body_ub if body_ub is not None else NegImplies.DEFAULT_BIG_M
                return d - c <= MUB * v

            if isinstance(conc, LessThan) or isinstance(conc, EqualTo):
                result.append(Constraint(*ConIndexes.index_sets, rule=rule_ub))
            if isinstance(conc, GreaterThan) or isinstance(conc, EqualTo):
                result.append(Constraint(*ConIndexes.index_sets, rule=rule_lb))
        return result


class ImpliesSiteCombination(DescriptorRule):
    """A class for rules that define logical implications between two sites.

    Spelled out: 'if this bond-indexed descriptor is true (i.e., is equal
        to one), then a pair of simple rules hold on the two bonding sites'

    Attributes:
        canv (Canvas): the data structure to identify neighbor connections
            to apply rules over.
        concis (list<tuple<MaterialDescriptor,SimpleDescriptorRule>>):
            list of conclusions to enforce at the first site in the pair if
            the logical predicate is true.
        concjs (list<tuple<MaterialDescriptor,SimpleDescriptorRule>>):
            list of conclusions to enforce at the second site in the pair if
            the logical predicate is true.
        symmetric_bonds (bool): flag to indicate if implications should be
            applied over symmetric bond indices
            (index information inherited from IndexedElem)

    See DescriptorRule for more information.
    """

    DEFAULT_BIG_M = 9999

    # === STANDARD CONSTRUCTOR
    def __init__(self, canv, concis, concjs, symmetric_bonds=False, **kwargs):
        """Standard constructor for ImpliesSiteCombination rules.

        Args:
            canv (Canvas): the data structure to identify neighbor connections
                to apply rules over.
            concis (list<tuple<MaterialDescriptor,SimpleDescriptorRule>>):
                list of conclusions to conditionally enforce at the first
                site in a bond.
                Note: single conclusions can be provided (i.e., a
                tuple<MaterialDescriptor,SimpleDescriptorRule>) and will
                be placed in lists.
            concjs (list<tuple<MaterialDescriptor,SimpleDescriptorRule>>):
                list of conclusions to conditionally enforce at the second
                site in a bond.
                Note: single conclusions can be provided (i.e., a
                tuple<MaterialDescriptor,SimpleDescriptorRule>) and will
                be placed in lists.
            symmetric_bonds (bool): flag to indicate if a symmetric versions
                of bonds should be enumerated or if both directions should
                be included.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """

        self.canv = canv
        self.concis = concis if type(concis) is list else [concis]
        self.concjs = concjs if type(concjs) is list else [concjs]
        self.symmetric_bonds = symmetric_bonds
        Combi = IndexedElem.fromComb(
            *(desc for desc, conc in self.concis), *(conc for desc, conc in self.concis)
        )
        Combj = IndexedElem.fromComb(
            *(desc for desc, conc in self.concjs), *(conc for desc, conc in self.concjs)
        )
        assert Combi.sites is not None and Combj.sites is not None
        if "bonds" not in kwargs:
            kwargs["bonds"] = [
                (i, j)
                for i in Combi.sites
                for j in canv.NeighborhoodIndexes[i]
                if (
                    j is not None
                    and j in Combj.sites
                    and (not symmetric_bonds or j > i)
                )
            ]
        if sum(Combi.dims) > 1:
            raise NotImplementedError(
                "Additional indexes are not supported, please contact MatOpt developer for "
                "possible feature addition"
            )
        DescriptorRule.__init__(self, **kwargs)

    def _pyomo_cons(self, var):
        """Method to create a Pyomo constraint from this rule.

        Args:
            var (MaterialDescriptor): The descriptor to be defined by this rule.

        Returns:
            (list<Constraint>) list of Pyomo constraint objects.
        """
        assert var.bonds is not None
        Comb = IndexedElem.fromComb(var, self)
        result = []
        # NOTE: After much confusion, I found a bug in the line of code
        #       below. Be careful not to use variable names "expr"
        #       because it gets mixed up with the Pyomo module "expr".
        #       No error, but it gives garbage expressions and wasn't
        #       clear to me what was being generated...
        # NOTE: Not clear if this was caused by module "expr" or the
        #       conflict of two local "expr,conc" objects in the two
        #       for loops...
        # for expr,conc in self.concis:
        for expri, conci in self.concis:

            def rule_i_lb(m, i, j):
                e = expri._pyomo_expr(index=(i,))
                c = conci.expr._pyomo_expr(index=(i,))
                body = e - c
                body_LB = getLB(body)
                MLBi = (
                    body_LB
                    if body_LB is not None
                    else -ImpliesSiteCombination.DEFAULT_BIG_M
                )
                return MLBi * (1 - var._pyomo_var[i, j]) <= body

            def rule_i_ub(m, i, j):
                e = expri._pyomo_expr(index=(i,))
                c = conci.expr._pyomo_expr(index=(i,))
                body = e - c
                body_UB = getUB(body)
                MUBi = (
                    body_UB
                    if body_UB is not None
                    else ImpliesSiteCombination.DEFAULT_BIG_M
                )
                return body <= MUBi * (1 - var._pyomo_var[i, j])

            if isinstance(conci, GreaterThan) or isinstance(conci, EqualTo):
                result.append(Constraint(*Comb.index_sets, rule=rule_i_lb))
            if isinstance(conci, LessThan) or isinstance(conci, EqualTo):
                result.append(Constraint(*Comb.index_sets, rule=rule_i_ub))
        # NOTE: See note above for variable name "expr"
        # for expr,conc in self.concjs:
        for exprj, concj in self.concjs:

            def rule_j_lb(m, i, j):
                e = exprj._pyomo_expr(index=(j,))
                c = concj.expr._pyomo_expr(index=(j,))
                body = e - c
                body_LB = getLB(body)
                MLBj = (
                    body_LB
                    if body_LB is not None
                    else -ImpliesSiteCombination.DEFAULT_BIG_M
                )
                return MLBj * (1 - var._pyomo_var[i, j]) <= body

            def rule_j_ub(m, i, j):
                e = exprj._pyomo_expr(index=(j,))
                c = concj.expr._pyomo_expr(index=(j,))
                body = e - c
                body_UB = getUB(body)
                MUBj = (
                    body_UB
                    if body_UB is not None
                    else ImpliesSiteCombination.DEFAULT_BIG_M
                )
                return body <= MUBj * (1 - var._pyomo_var[i, j])

            if isinstance(concj, GreaterThan) or isinstance(concj, EqualTo):
                result.append(Constraint(*Comb.index_sets, rule=rule_j_lb))
            if isinstance(concj, LessThan) or isinstance(concj, EqualTo):
                result.append(Constraint(*Comb.index_sets, rule=rule_j_ub))
        return result


class ImpliesNeighbors(DescriptorRule):
    """A class for rules that define logical implications on neighbor sites.

    Spelled out: 'if this site-indexed descriptor is true (i.e., is equal
        to one), then a set of simple rules hold on each of the neighboring
        sites'

    Attributes:
        concs (list<tuple<MaterialDescriptor,SimpleDescriptorRule>>):
            list of conclusions to enforce if the logical predicate is true.
        neighborhoods (list<list<int>>): neighborhood data structure to use
            if you do not want to use the neighborhoods of the descriptor
            that this rule is attached to.
            (index information inherited from IndexedElem)

    See DescriptorRule for more information on rules and Canvas for more
    information on 'neighborhoods'.
    """

    DEFAULT_BIG_M = 9999

    # === STANDARD CONSTRUCTOR
    def __init__(self, concs, neighborhoods=None, **kwargs):
        """Standard constructor for ImpliesNeighbors rules.

        Args:
            concs (list<tuple<MaterialDescriptor,SimpleDescriptorRule>>):
                list of conclusions to conditionally enforce. Also, a single
                conclusion can be provided (i.e., a tuple<MaterialDescriptor,
                SimpleDescriptorRule>) and will be placed in a list.
            neighborhoods (list<list<int>>) Optional, data structure to use
                as neighborhoods of interest. If not provided, then the
                neighborhoods of the descriptor that this rule is attached to
                is used.
            **kwargs: Optional, index information passed to IndexedElem if
                interested in a subset of indices
                Possible choices: sites, bonds, site_types, bond_types, confs.
        """
        self.concs = concs if type(concs) is list else [concs]
        self.neighborhoods = neighborhoods
        Comb = IndexedElem.fromComb(
            *(desc for desc, conc in self.concs), *(conc for desc, conc in self.concs)
        )
        assert Comb.sites is not None
        kwargs = {**Comb.index_dict, **kwargs}
        DescriptorRule.__init__(self, **kwargs)

    # === PROPERTY EVALUATION METHODS
    def _pyomo_cons(self, var):
        """Method to create a Pyomo constraint from this rule.

        Args:
            var (MaterialDescriptor): The descriptor to be defined by this rule.

        Returns:
            (list<Constraint>) list of Pyomo constraint objects.
        """
        var_dict_wo_s = var.index_dict
        var_dict_wo_s.pop("sites")  # no need to capture these sites
        neighborhoods = (
            self.neighborhoods
            if self.neighborhoods is not None
            else var.canv.NeighborhoodIndexes
        )
        bonds = [(i, j) for i in var.sites for j in neighborhoods[i] if j is not None]
        result = []
        # NOTE: After much confusion, I found a bug in the line of code
        #       below. Be careful not to use variable names "expr"
        #       because it gets mixed up with the Pyomo module "expr".
        #       No error, but it gives garbage expressions and wasn't
        #       clear to me what was being generated...
        # for expr,conc in self.concs:
        for expr_, conc in self.concs:
            Comb = IndexedElem.fromComb(expr_, conc)
            r_dict_wo_s = Comb.index_dict
            r_dict_wo_s.pop("sites")  # no need to capture these sites
            ConIndexes = IndexedElem.fromComb(
                IndexedElem(bonds=bonds),
                IndexedElem(**var_dict_wo_s),
                IndexedElem(**r_dict_wo_s),
            )

            def rule_lb(m, *args):
                i, j, *args = args
                v = var._pyomo_var[var.mask((i, None, *args), ConIndexes)]
                e = expr_._pyomo_expr(index=expr_.mask((j, None, *args), ConIndexes))
                c = conc.expr._pyomo_expr(
                    index=conc.expr.mask((j, None, *args), ConIndexes)
                )
                body = e - c
                body_LB = getLB(body)
                MLB = (
                    body_LB if body_LB is not None else -ImpliesNeighbors.DEFAULT_BIG_M
                )
                return MLB * (1 - v) <= body

            def rule_ub(m, *args):
                i, j, *args = args
                v = var._pyomo_var[var.mask((i, None, *args), ConIndexes)]
                e = expr_._pyomo_expr(index=expr_.mask((j, None, *args), ConIndexes))
                c = conc.expr._pyomo_expr(
                    index=conc.expr.mask((j, None, *args), ConIndexes)
                )
                body = e - c
                body_UB = getUB(body)
                MUB = body_UB if body_UB is not None else ImpliesNeighbors.DEFAULT_BIG_M
                return body <= MUB * (1 - v)

            if isinstance(conc, GreaterThan) or isinstance(conc, EqualTo):
                result.append(Constraint(*ConIndexes.index_sets, rule=rule_lb))
            if isinstance(conc, LessThan) or isinstance(conc, EqualTo):
                result.append(Constraint(*ConIndexes.index_sets, rule=rule_ub))
        return result


[docs]class MaterialDescriptor(IndexedElem): """A class to represent material geometric and energetic descriptors. This class holds the information to define mathematical optimization variables for the properties of materials. Additionally, each descriptor has a 'rules' list to which the user can append rules defining the descriptor and constraining the design space. Attributes: name (string): A unique (otherwise Pyomo will complain) name canv (``Canvas``): The canvas that the descriptor will be indexed over atoms (list<``BBlock``>): The building blocks to index the descriptor over. confDs (list<``Design``>): The designs for conformations to index over. integer (bool): Flag to indicate if the descriptor takes integer values. binary (bool): Flag to indicate if the descriptor takes boolean values. rules (list<``DescriptorRules``>): List of rules to define and constrain the material descriptor design space. bounds (tuple/dict/func): If tuple, the lower and upper bounds on the descriptor values across all indices. If dict, the bounds can be individually set for each index. See ``IndexedElem`` for more information on indexing. See ``DescriptorRule`` for information on defining descriptors. """ DBL_TOL = 1e-5 # === STANDARD CONSTRUCTOR def __init__( self, name, canv=None, atoms=None, confDs=None, bounds=(None, None), integer=False, binary=False, rules=None, **kwargs ): """Standard constructor for material descriptors. Note: It is generally not necessary for users to create MaterialDescriptors themselves. Instead, use the MatOptModel.add____Descriptor() methods for the right type of descriptor (i.e., Site, Bond, etc.). Args: name (string): A unique (otherwise Pyomo will complain) name canv (Canvas): The canvas that the descriptor will be indexed over atoms (list<BBlock>): Building blocks to index the descriptor over. confDs (list<Design>): The designs for conformations to index over. bounds (tuple/dict/func): If tuple, the lower and upper bounds on the descriptor values across all indices. If dict, the bounds can be individually set for each index. Otherwise, advanced users can specify a function to be interpreted by Pyomo. integer (bool): Flag to indicate if the descriptor is integer. binary (bool): Flag to indicate if the descriptor is boolean. rules (list<DescriptorRules>): List of rules to define and constrain the material descriptor design space. **kwargs: Optional, index information passed to IndexedElem if interested in a subset of indices. Possible choices: sites, bonds, site_types, bond_types, confs. """ self._name = name self._canv = canv self._atoms = atoms self._confDs = confDs self._integer = integer or binary self._binary = binary if rules is None: rules = [] self._rules = rules if type(rules) is list else [rules] self._bounds = bounds self._pyomo_var = None # Will be set by MatOptModel._make_pyomo_model IndexedElem.__init__(self, **kwargs) # === AUXILIARY METHODS def _fix_pyomo_var_by_rule(self, r, m): if self.name in ("Yik", "Yi", "Xijkl", "Xij", "Cikl", "Ci", "Zic"): return self.__fix_basic_pyomo_vars_by_rule(r, m) else: Comb = IndexedElem.fromComb(self, r) for k in Comb.keys(): self._pyomo_var[k].fix(r.val) def __fix_basic_pyomo_vars_by_rule(self, r, m): Comb = IndexedElem.fromComb(self, r) if self.name == "Yik": for i in Comb.sites: for k in Comb.site_types: fixYik(m, i, k, r.val) elif self.name == "Yi": for i in Comb.sites: fixYi(m, i, r.val) elif self.name == "Xijkl": for i, j in Comb.bonds: for k, l in Comb.bond_types: fixXijkl(m, i, j, k, l, r.val) elif self.name == "Xij": for i, j in Comb.bonds: fixXij(m, i, j, r.val) elif self.name == "Cikl": for i in Comb.sites: for k, l in Comb.bond_types: fixCikl(m, i, k, l, r.val) elif self.name == "Ci": for i in Comb.sites: fixCi(m, i, r.val) elif self.name == "Zic": for i in Comb.sites: for c in Comb.confs: fixZic(m, i, c, r.val) # === PROPERTY EVALUATION METHODS def _pyomo_cons(self, m): """Create a list of Pyomo constraints related to this descriptor.""" result = [] for rule in self.rules: if rule is not None: result.extend(rule._pyomo_cons(self)) return result @property def _pyomo_bounds(self): """Creates a bound rule/tuple that can interpreted by Pyomo.""" if type(self.bounds) is tuple: return self.bounds elif type(self.bounds) is dict: def rule_gen(m, *args): if args is not None and len(args) == 1: args = args[0] return self.bounds[args] return rule_gen else: # Else, assume that the user knows what they're doing # with functions for pyomo bounds return self.bounds def _pyomo_expr(self, index=None): """Interprets a variable as a Pyomo expression. Note: This is just necessary so that we can conveniently interpret MaterialDescriptor objects in place of Expr objects. """ return self._pyomo_var[index] @property def values(self): """Creates a dictionary of descriptor values after optimization. Note: Uses the Pyomo 'value' function and only works after the optimization of a model. Returns: (dict) Dictionary of keys to values after optimization. """ return {index: value(self._pyomo_var[index]) for index in self.keys()} # === BASIC QUERY METHODS @property def name(self): return self._name @property def canv(self): return self._canv @property def atoms(self): return self._atoms @property def confDs(self): return self._confDs @property def bounds(self): return self._bounds @property def integer(self): return self._integer @property def binary(self): return self._binary @property def continuous(self): return not self.integer @property def rules(self): return self._rules
[docs]class MatOptModel(object): """A class for the specification of a materials optimization problem. Once all the material information is specified, we use this class to specify the material design problem of interest. This class is intended to be interpretable without mathematical optimization background while the conversion to Pyomo optimization models happens automatically. Attributes: canv (``Canvas``): The canvas of the material design space atoms (list<``BBlock``>): The list of building blocks to consider. Note: This list does not need to include a void-atom type. We use 'None' to represent the absence of any building block at a given site. confDs (list<``Design``>): The list of conformations to consider. """ # === STANDARD CONSTRUCTOR def __init__(self, canv, atoms=None, confDs=None): """Standard constructor for materials optimization problems. Args: canv (``Canvas``): The canvas of the material design space atoms (list<``BBlock``>): The list of building blocks to consider. Note: This list does not need to include a void-atom type. We use 'None' to represent the absence of any building block at a given site. confDs (list<``Design``>): The list of conformations to consider. """ self._canv = canv self._atoms = atoms self._confDs = confDs self._descriptors = [] self.addSitesDescriptor("Yi", binary=True, rules=None) self.addBondsDescriptor("Xij", binary=True, rules=None) self.addNeighborsDescriptor("Ci", integer=True, rules=None) self.addSitesTypesDescriptor("Yik", binary=True, rules=None) self.addBondsTypesDescriptor("Xijkl", binary=True, rules=None) self.addNeighborsTypesDescriptor("Cikl", integer=True, rules=None) self.addSitesConfsDescriptor("Zic", binary=True, rules=None) # === MANIPULATION METHODS def addGlobalDescriptor( self, name, bounds=(None, None), integer=False, binary=False, rules=None ): """ Method to add scalar descriptor to the model. Args: name (string): A unique (otherwise Pyomo will complain) name. bounds (tuple/dict/func): If tuple, the lower and upper bounds on the descriptor values across all indices. If dict, the bounds can be individually set for each index. Otherwise, advanced users can specify a function to be interpreted by Pyomo. integer (bool): Flag to indicate if the descriptor is integer. binary (bool): Flag to indicate if the descriptor is boolean. rules (list<DescriptorRules>): List of rules to define and constrain the material descriptor design space. """ assert not hasattr(self, name) Desc = MaterialDescriptor( name=name, bounds=bounds, integer=integer, binary=binary, rules=rules ) setattr(self, name, Desc) self._descriptors.append(Desc) def addSitesDescriptor( self, name, sites=None, bounds=(None, None), integer=False, binary=False, rules=None, ): """Method to add a site-indexed descriptor to the model. Args: name (string): A unique (otherwise Pyomo will complain) name. sites (list<int>): Optional, subset of canvas sites to index the new descriptor over. Default: None (i.e., all sites in canvas are considered) bounds (tuple/dict/func): If tuple, the lower and upper bounds on the descriptor values across all indices. If dict, the bounds can be individually set for each index. Otherwise, advanced users can specify a function to be interpreted by Pyomo. integer (bool): Flag to indicate if the descriptor is integer. binary (bool): Flag to indicate if the descriptor is boolean. rules (list<DescriptorRules>): List of rules to define and constrain the material descriptor design space. """ assert not hasattr(self, name) sites = sites if sites is not None else list(range(len(self.canv))) Desc = MaterialDescriptor( name=name, canv=self.canv, sites=sites, bounds=bounds, integer=integer, binary=binary, rules=rules, ) setattr(self, name, Desc) self._descriptors.append(Desc) def addBondsDescriptor( self, name, bonds=None, bounds=(None, None), integer=False, binary=False, rules=None, symmetric_bonds=False, ): """Method to add a bond-indexed descriptor to the model. Args: name (string): A unique (otherwise Pyomo will complain) name. bonds (list<tuple<int,int>>): Optional, subset of canvas neighbor pairs to index the new descriptor over. Default: None (i.e., all neighbor pairs included) bounds (tuple/dict/func): If tuple, the lower and upper bounds on the descriptor values across all indices. If dict, the bounds can be individually set for each index. Otherwise, advanced users can specify a function to be interpreted by Pyomo. integer (bool): Flag to indicate if the descriptor is integer. binary (bool): Flag to indicate if the descriptor is boolean. rules (list<DescriptorRules>): List of rules to define and constrain the material descriptor design space. """ assert not hasattr(self, name) bonds = ( bonds if bonds is not None else [ (i, j) for i in range(len(self.canv)) for j in self.canv.NeighborhoodIndexes[i] if (j is not None and (not symmetric_bonds or j > i)) ] ) Desc = MaterialDescriptor( name=name, canv=self.canv, bonds=bonds, bounds=bounds, integer=integer, binary=binary, rules=rules, ) setattr(self, name, Desc) self._descriptors.append(Desc) def addNeighborsDescriptor( self, name, sites=None, bounds=(None, None), integer=False, binary=False, rules=None, ): """Method to add a neighborhood-indexed descriptor to the model. Args: name (string): A unique (otherwise Pyomo will complain) name. sites (list<int>): Optional, subset of canvas sites to index the new descriptor over. Default: None (i.e., all sites in canvas are considered) bounds (tuple/dict/func): If tuple, the lower and upper bounds on the descriptor values across all indices. If dict, the bounds can be individually set for each index. Otherwise, advanced users can specify a function to be interpreted by Pyomo. integer (bool): Flag to indicate if the descriptor is integer. binary (bool): Flag to indicate if the descriptor is boolean. rules (list<DescriptorRules>): List of rules to define and constrain the material descriptor design space. """ assert not hasattr(self, name) sites = sites if sites is not None else list(range(len(self.canv))) Desc = MaterialDescriptor( name=name, canv=self.canv, sites=sites, bounds=bounds, integer=integer, binary=binary, rules=rules, ) setattr(self, name, Desc) self._descriptors.append(Desc) def addGlobalTypesDescriptor( self, name, site_types=None, bond_types=None, bounds=(None, None), integer=False, binary=False, rules=None, ): """Method to add a type-indexed descriptor to the model. Args: name (string): A unique (otherwise Pyomo will complain) name. site_types (list<BBlock>): Optional, subset of building block types to index the new descriptor over. Note: If both site_types and bond_types are left to None, then we decide to index over building block types by default. bond_types (list<tuple<BBlock,BBlock>>): Optional, subset of building block pairs to index the new descriptor over. Note: If both site_types and bond_types are left to None, then we decide to index over building block types by default. bounds (tuple/dict/func): If tuple, the lower and upper bounds on the descriptor values across all indices. If dict, the bounds can be individually set for each index. Otherwise, advanced users can specify a function to be interpreted by Pyomo. integer (bool): Flag to indicate if the descriptor is integer. binary (bool): Flag to indicate if the descriptor is boolean. rules (list<DescriptorRules>): List of rules to define and constrain the material descriptor design space. """ assert not hasattr(self, name) site_types = ( site_types if site_types is not None else (self.atoms if bond_types is None else None) ) bond_types = bond_types Desc = MaterialDescriptor( name=name, atoms=self.atoms, site_types=site_types, bond_types=bond_types, bounds=bounds, integer=integer, binary=binary, rules=rules, ) setattr(self, name, Desc) self._descriptors.append(Desc) def addSitesTypesDescriptor( self, name, sites=None, site_types=None, bounds=(None, None), integer=False, binary=False, rules=None, ): """Method to add a site-and-type-indexed descriptor to the model. Args: name (string): A unique (otherwise Pyomo will complain) name. sites (list<int>): Optional, subset of canvas sites to index the new descriptor over. Default: None (i.e., all sites in canvas are considered) site_types (list<BBlock>): Optional, subset of building block types to index the new descriptor over. Default: None (i.e., all building block types are considered) bounds (tuple/dict/func): If tuple, the lower and upper bounds on the descriptor values across all indices. If dict, the bounds can be individually set for each index. Otherwise, advanced users can specify a function to be interpreted by Pyomo. integer (bool): Flag to indicate if the descriptor is integer. binary (bool): Flag to indicate if the descriptor is boolean. rules (list<DescriptorRules>): List of rules to define and constrain the material descriptor design space. """ assert not hasattr(self, name) sites = sites if sites is not None else list(range(len(self.canv))) site_types = site_types if site_types is not None else self.atoms Desc = MaterialDescriptor( name=name, atoms=self.atoms, canv=self.canv, sites=sites, site_types=site_types, bounds=bounds, integer=integer, binary=binary, rules=rules, ) setattr(self, name, Desc) self._descriptors.append(Desc) def addBondsTypesDescriptor( self, name, bonds=None, bond_types=None, bounds=(None, None), integer=False, binary=False, rules=None, symmetric_bonds=False, ): """Method to add a bond-and-type-indexed descriptor to the model. Args: name (string): A unique (otherwise Pyomo will complain) name. bonds (list<tuple<int,int>>): Optional, subset of canvas neighbor pairs to index the new descriptor over. Default: None (i.e., all neighbor pairs included) bond_types (list<tuple<BBlock,BBlock>>): Optional, subset of building block pairs to index the new descriptor over. Default: None (i.e., all pairs of building blocks considered) bounds (tuple/dict/func): If tuple, the lower and upper bounds on the descriptor values across all indices. If dict, the bounds can be individually set for each index. Otherwise, advanced users can specify a function to be interpreted by Pyomo. integer (bool): Flag to indicate if the descriptor is integer. binary (bool): Flag to indicate if the descriptor is boolean. rules (list<DescriptorRules>): List of rules to define and constrain the material descriptor design space. """ assert not hasattr(self, name) bonds = ( bonds if bonds is not None else [ (i, j) for i in range(len(self.canv)) for j in self.canv.NeighborhoodIndexes[i] if (j is not None and (not symmetric_bonds or j > i)) ] ) bond_types = ( bond_types if bond_types is not None else [(k, l) for k in self.atoms for l in self.atoms] ) Desc = MaterialDescriptor( name=name, atoms=self.atoms, canv=self.canv, bonds=bonds, bond_types=bond_types, bounds=bounds, integer=integer, binary=binary, rules=rules, ) setattr(self, name, Desc) self._descriptors.append(Desc) def addNeighborsTypesDescriptor( self, name, sites=None, bond_types=None, bounds=(None, None), integer=False, binary=False, rules=None, ): """Method to add a neighborhood-bond-type-indexed descriptor. Args: name (string): A unique (otherwise Pyomo will complain) name. sites (list<int>): Optional, subset of canvas sites to index the new descriptor over. Default: None (i.e., all sites in canvas are considered) bond_types (list<tuple<BBlock,BBlock>>): Optional, subset of building block pairs to index the new descriptor over. Default: None (i.e., all pairs of building blocks considered) bounds (tuple/dict/func): If tuple, the lower and upper bounds on the descriptor values across all indices. If dict, the bounds can be individually set for each index. Otherwise, advanced users can specify a function to be interpreted by Pyomo. integer (bool): Flag to indicate if the descriptor is integer. binary (bool): Flag to indicate if the descriptor is boolean. rules (list<DescriptorRules>): List of rules to define and constrain the material descriptor design space. """ assert not hasattr(self, name) sites = sites if sites is not None else list(range(len(self.canv))) bond_types = ( bond_types if bond_types is not None else [(k, l) for k in self.atoms for l in self.atoms] ) Desc = MaterialDescriptor( name=name, atoms=self.atoms, canv=self.canv, sites=sites, bond_types=bond_types, bounds=bounds, integer=integer, binary=binary, rules=rules, ) setattr(self, name, Desc) self._descriptors.append(Desc) def addSitesConfsDescriptor( self, name, sites=None, confs=None, bounds=(0, 1), integer=True, binary=True, rules=None, ): """Method to add a site-and-conformation-indexed descriptor. Args: name (string): A unique (otherwise Pyomo will complain) name. sites (list<int>): Optional, subset of canvas sites to index the new descriptor over. Default: None (i.e., all sites in canvas are considered) confs (list<int>): Optional, subset of conformation indices to index the new descriptor over. Default: None (i.e., all conformations included) bounds (tuple/dict/func): If tuple, the lower and upper bounds on the descriptor values across all indices. If dict, the bounds can be individually set for each index. Otherwise, advanced users can specify a function to be interpreted by Pyomo. integer (bool): Flag to indicate if the descriptor is integer. binary (bool): Flag to indicate if the descriptor is boolean. rules (list<DescriptorRules>): List of rules to define and constrain the material descriptor design space. """ sites = sites if sites is not None else list(range(len(self.canv))) confs = ( confs if confs is not None else (list(range(len(self.confDs))) if self.confDs is not None else None) ) Desc = MaterialDescriptor( name=name, canv=self.canv, confDs=self.confDs, sites=sites, confs=confs, bounds=bounds, integer=integer, binary=binary, rules=rules, ) setattr(self, name, Desc) self._descriptors.append(Desc) # === PROPERTY EVALUATION METHODS
[docs] def maximize(self, func, **kwargs): """Method to maximize a target functionality of the material model. Args: func (``MaterialDescriptor``/``Expr``): Material functionality to optimize. **kwargs: Arguments to ``MatOptModel.optimize`` Returns: (``Design``/list<``Design``>) Optimal designs. Raises: ``pyomo.common.errors.ApplicationError`` if MatOpt can not find usable solver (CPLEX or NEOS-CPLEX) See ``MatOptModel.optimize`` method for details. """ return self.optimize(func, sense=maximize, **kwargs)
[docs] def minimize(self, func, **kwargs): """Method to minimize a target functionality of the material model. Args: func (``MaterialDescriptor``/``Expr``): Material functionality to optimize. **kwargs: Arguments to ``MatOptModel.optimize`` Returns: (``Design``/list<``Design``>) Optimal designs. Raises: ``pyomo.common.errors.ApplicationError`` if MatOpt can not find usable solver (CPLEX or NEOS-CPLEX) See ``MatOptModel.optimize`` method for details. """ return self.optimize(func, sense=minimize, **kwargs)
[docs] def optimize( self, func, sense, nSolns=1, tee=True, disp=1, keepfiles=False, tilim=3600, trelim=None, solver="cplex", ): """Method to create and optimize the materials design problem. This method automatically creates a new optimization model every time it is called. Then, it solves the model via Pyomo with the CPLEX solver. If multiple solutions (called a 'solution pool') are desired, then the nSolns argument can be provided and the populate method will be called instead. Args: func (``MaterialDescriptor``/``Expr``): Material functionality to optimize. sense (int): flag to indicate the choice to minimize or maximize the functionality of interest. Choices: minimize/maximize (Pyomo constants 1,-1 respectively) nSolns (int): Optional, number of Design objects to return. Default: 1 (See ``MatOptModel.populate`` for more information) tee (bool): Optional, flag to turn on solver output. Default: True disp (int): Optional, flag to control level of MatOpt output. Choices: 0: No MatOpt output (other than solver tee) 1: MatOpt output for outer level method 2: MatOpt output for solution pool & individual solns. Default: 1 keepfiles (bool): Optional, flag to save temporary pyomo files. Default: True tilim (float): Optional, solver time limit (in seconds). Default: 3600 trelim (float): Optional, solver tree memory limit (in MB). Default: None (i.e., Pyomo/CPLEX default) solver (str): Solver choice. Currently only cplex or neos-cplex are supported Default: cplex Returns: (``Design``/list<``Design``>) Optimal design or designs, depending on the number of solutions requested by argument ``nSolns``. Raises: ``pyomo.common.errors.ApplicationError`` if MatOpt can not find usable solver (CPLEX or NEOS-CPLEX) """ if nSolns > 1: return self.populate( func, sense=sense, nSolns=nSolns, tee=tee, disp=disp, keepfiles=keepfiles, tilim=tilim, trelim=trelim, solver=solver, ) elif nSolns == 1: self._pyomo_m = self._make_pyomo_model(func, sense) return self.__solve_pyomo_model(tee, disp, keepfiles, tilim, trelim, solver)
[docs] def populate( self, func, sense, nSolns, tee=True, disp=1, keepfiles=False, tilim=3600, trelim=None, solver="cplex", ): """Method to a pool of solutions that optimize the material model. This method automatically creates a new optimization model every time it is called. Then, it solves the model via Pyomo with the CPLEX solver. The populate method iteratively solves the model, interprets the solution as a Design object, creates a constraint to disallow that design, and resolves to find the next best design. We build a pool of Designs that are guaranteed to be the nSolns-best solutions in the material design space. Args: func (``MaterialDescriptor``/``Expr``): Material functionality to optimize. sense (int): flag to indicate the choice to minimize or maximize the functionality of interest. Choices: minimize/maximize (Pyomo constants 1,-1 respectively) nSolns (int): Optional, number of Design objects to return. Default: 1 (See ``MatOptModel.populate`` for more information) tee (bool): Optional, flag to turn on solver output. Default: True disp (int): Optional, flag to control level of MatOpt output. Choices: 0: No MatOpt output (other than solver tee) 1: MatOpt output for outer level method 2: MatOpt output for solution pool & individual solns. Default: 1 keepfiles (bool): Optional, flag to save temporary pyomo files. Default: True tilim (float): Optional, solver time limit (in seconds). Default: 3600 trelim (float): Optional, solver tree memory limit (in MB). Default: None (i.e., Pyomo/CPLEX default) solver (str): Solver choice. Currently only cplex or neos-cplex are supported Default: cplex Returns: (list<``Design``>) A list of optimal Designs in order of decreasing optimality. Raises: ``pyomo.common.errors.ApplicationError`` if MatOpt can not find usable solver (CPLEX or NEOS-CPLEX) """ self._pyomo_m = self._make_pyomo_model(func, sense) self._pyomo_m.iSolns = Set(initialize=list(range(nSolns))) self._pyomo_m.IntCuts = Constraint(self._pyomo_m.iSolns) def dispPrint(*args): if disp > 0: print(*args) else: pass Ds = [] for iSoln in range(nSolns): dispPrint("Starting populate for solution #{}... ".format(iSoln)) D = self.__solve_pyomo_model( tee, disp - 1, keepfiles, tilim, trelim, solver ) if D is not None: dispPrint( "Found solution with objective: {}".format(value(self._pyomo_m.obj)) ) Ds.append(D) if len(self._pyomo_m.Yik) > 0: self._pyomo_m.IntCuts.add( index=iSoln, expr=(Disallow(D)._pyomo_expr(self.Yik) >= 1) ) elif len(self._pyomo_m.Yi) > 0: self._pyomo_m.IntCuts.add( index=iSoln, expr=(Disallow(D)._pyomo_expr(self.Yi) >= 1) ) else: raise NotImplementedError("Decide what to do " "in this case...") else: dispPrint("No solution found. Terminating populate.") break dispPrint("Identified {} solutions via populate.".format(len(Ds))) return Ds
def _make_pyomo_model(self, obj_expr, sense): """Method to create a Pyomo concrete model object. This method creates a Pyomo model and also modifies several objects in the MatOpt framework. It creates Pyomo variable objects and attaches references to those variables on each of the MaterialDescriptors attached to the MatOptModel. Args: obj_expr (MaterialDescriptor/Expr): Material functionality to optimize. sense (int): flag to indicate the choice to minimize or maximize the functionality of interest. Choices: minimize/maximize (Pyomo constants 1,-1 respectively) Returns: (ConcreteModel) Pyomo model object. """ m = makeMyPyomoBaseModel(self.canv, Atoms=self.atoms, Confs=self.confDs) self.Yi._pyomo_var = m.Yi self.Xij._pyomo_var = m.Xij self.Ci._pyomo_var = m.Ci self.Yik._pyomo_var = m.Yik self.Xijkl._pyomo_var = m.Xijkl self.Cikl._pyomo_var = m.Cikl self.Zic._pyomo_var = m.Zic for desc in self._descriptors: if desc.name not in ("Yik", "Yi", "Xijkl", "Xij", "Cikl", "Ci", "Zic"): v = Var( *desc.index_sets, domain=( Binary if desc.binary else (Integers if desc.integer else Reals) ), bounds=desc._pyomo_bounds, dense=False ) setattr(m, desc.name, v) setattr(desc, "_pyomo_var", v) for desc in self._descriptors: for c, pyomo_con in enumerate(desc._pyomo_cons(m)): setattr(m, "Assign{}_{}".format(desc.name, c), pyomo_con) if sum(obj_expr.dims) == 0: m.obj = Objective(expr=obj_expr._pyomo_expr(index=(None,)), sense=sense) else: raise TypeError( "The MaterialDescriptor chosen is not supported to be an objective, please contact MatOpt " "developer for potential fix" ) # NOTE: The timing of the call to addConsForGeneralVars is important # We need to call it after all user-defined descriptors are # encoded. # Else, lots of constraints for basic variables that are not # necessary will be written. addConsForGeneralVars(m) for desc in self._descriptors: for r in desc.rules: if isinstance(r, FixedTo): desc._fix_pyomo_var_by_rule(r, m) return m def __solve_pyomo_model(self, tee, disp, keepfiles, tilim, trelim, solver): """Method to solve the formulated Pyomo optimization model. This function is intended to standardize the printout and approach for converting results into Designs that is used by the optimize and populate methods. Args: tee (bool): Flag to turn on solver output. disp (int): Flag to control level of MatOpt output. keepfiles (bool): Flag to save temporary pyomo files. tilim (float): Solver time limit (in seconds). trelim (float): Solver tree memory limit (in MB). solver (str): Solver choice. Currently only cplex or neos-cplex are supported Returns: (Design) The best design identified by the solver, if any. The quality of the solution (optimal vs best found at termination) can be found by reading the output display. In the case that the model was infeasible or no solution could be identified, the method returns 'None'. """ if solver == "cplex": opt = SolverFactory("cplex") opt.options["mip_tolerances_absmipgap"] = 0.0 opt.options["mip_tolerances_mipgap"] = 0.0 if tilim is not None: opt.options["timelimit"] = tilim if trelim is not None: opt.options["mip_limits_treememory"] = trelim res = opt.solve( self._pyomo_m, tee=tee, symbolic_solver_labels=True, keepfiles=keepfiles ) elif solver == "neos-cplex": with SolverManagerFactory("neos") as manager: opt = SolverFactory("cplex") opt.options["absmipgap"] = 0.0 # NOTE: different option names opt.options["mipgap"] = 0.0 if tilim is not None: opt.options["timelimit"] = tilim if trelim is not None: opt.options["treememory"] = trelim res = manager.solve(self._pyomo_m, opt=opt) else: raise NotImplementedError( "MatOpt is tailored to perform best with CPLEX (locally or through NEOS), " "please contact MatOpt developer for additional solver support " ) solver_status = res.solver.status solver_term = res.solver.termination_condition soln_status = res.solution.status has_solution = ( soln_status == SolutionStatus.optimal or soln_status == SolutionStatus.feasible or soln_status == SolutionStatus.bestSoFar or soln_status == SolutionStatus.globallyOptimal or soln_status == SolutionStatus.locallyOptimal ) # NOTE: The block below is a hack to get around the fact that Pyomo # solution statuses are not flagged correctly all the time. If # solution status was unknown (but actually optimal or feasible) # then this should (hopefully) flag the solution as available if soln_status == SolutionStatus.unknown: value(self._pyomo_m.obj) has_solution = True def dispPrint(*args): if disp > 0: print(*args) else: pass if solver_status == SolverStatus.ok: dispPrint("The solver exited normally.") if ( solver_term == TerminationCondition.optimal or solver_term == TerminationCondition.locallyOptimal or solver_term == TerminationCondition.globallyOptimal ): # NOTE: This assertion should be re-enabled when Pyomo bug # described above is fixed. # assert(soln_status==SolutionStatus.optimal) dispPrint("A feasible and provably optimal solution " "is available.") else: dispPrint( "The solver exited due to termination criteria: {}".format( solver_term ) ) if has_solution: dispPrint( "A feasible (but not provably optimal) " "solution is available." ) else: dispPrint("No solution available.") else: dispPrint( "The solver did not exit normally. Status: {}".format(solver_status) ) if has_solution: dispPrint("The Design has objective: {}".format(value(self._pyomo_m.obj))) result = Design(self.canv) setDesignFromModel(result, self._pyomo_m) return result else: return None # === BASIC QUERY METHODS @property def canv(self): return self._canv @property def atoms(self): return self._atoms @property def confDs(self): return self._confDs @property def descriptors(self): return self._descriptors