Source code for gana.sets.variable

"""Continuous Variable"""

from __future__ import annotations

import logging
from itertools import product
from typing import TYPE_CHECKING, Self

from IPython.display import Math, display

from ..utils.draw import draw
from ._element import _E
from .birth import make_P, make_T
from .cases import Elem, FCase
from .constraint import C
from .function import F
from .index import I

logger = logging.getLogger("gana")


if TYPE_CHECKING:
    from .objective import O
    from .parameter import P
    from .theta import T

# try:
#     from pyomo.environ import (
#         Binary,
#         Integers,
#         NonNegativeIntegers,
#         NonNegativeReals,
#         Reals,
#     )
#     from pyomo.environ import Var as PyoVar

#     has_pyomo = True
# except ImportError:
#     has_pyomo = False

# try:
#     from sympy import Idx, IndexedBase, symbols

#     has_sympy = True
# except ImportError:
#     has_sympy = False


[docs] class V(_E): """ Ordered set of variables (Var). :param index: Indices. Defaults to None. :type index: I or tuple[I], optional :param itg: If the variable set is integer. Defaults to False. :type itg: bool, optional :param nn: If the variable set is non-negative. Defaults to True. :type nn: bool, optional :param bnr: If the variable set is binary. Defaults to False. :type bnr: bool, optional :param mutable: If the variable set is mutable. Defaults to False. :type mutable: bool, optional :param tag: Tag/details :type tag: str :param ltx: LaTeX representation of the variable set. :type ltx: str :ivar index: Index of the variable set (product of all indices) :vartype index: I :ivar map: Index to variable mapping :vartype map: dict[I, V] :ivar _: List of variables in the set :vartype _: list[V] :ivar itg: Integer variable set flag :vartype itg: bool :ivar nn: Non-negative variable set flag :vartype nn: bool :ivar bnr: Binary variable set flag :vartype bnr: bool :ivar mutable: Mutable variable set flag :vartype mutable: bool :ivar tag: Tag/details :vartype tag: str :ivar name: Name, set by the program :vartype name: str :ivar n: Number id, set by the program :vartype n: int :ivar args: Arguments for making similar variable sets :vartype args: dict[str, bool] :ivar ltx: LaTeX representation of the variable set :vartype ltx: str :raises ValueError: If variable is binary and not non-negative :raises ValueError: Multiplication by tuple or list of tuples :raises ValueError: Division by None, tuple, or list of tuples :raises ZeroDivisionError: Division by zero :raises ValueError: Division of something by a variable :raises ValueError: Raising variable to a power, except 0 or 1 """ def __init__( self, *index: I, itg: bool = False, nn: bool = True, bnr: bool = False, mutable: bool = False, tag: str = "", ltx: str = "", name: str = "", ): _E.__init__(self, *index, tag=tag, ltx=ltx, mutable=mutable, name=name) # integer variable set self.itg = itg # non-negative variable set self.bnr = bnr # all binaries are integer variables as well if self.bnr: self.itg = True if not nn: # Cannot be non negative and binary # {-1, 0, 1} is equivalent to {0, 1, 2} raise ValueError("Binary variables must be non-negative") self.nn = nn # a variable set of size 1 is a scalar variable # these are created at each index in the set # their position in the parent set is recorded # # Example: if v = V(I('i', 'j')) then v._ = [V(I('i)), V(I('j'))] # updated by the constraint # what constraints constrain this variable self.cons_by: list[C] = [] # which objectives minimize it (gana is always min) self.min_by: list[O] = [] # value after optimization self.X: dict[tuple[I, ...], float] = {} # these keep variables consistent with functions for some operations # Take the example of a variable set - parameter set # [v0 - 2, v1 - 0, v2 + 4] # at positions 0 and 2, we have functions # at position 1, v1 - 0 = v1, which is a variable # these attribute evades the need for an instance check self.variables = [self] self.struct = (Elem.V, None) self.case = FCase.VAR # TODO: check self.P = [self.n] self.copyof: Self | None = None # number of splices of the index set self.n_splices = 1 # this flag tells the function # that self in its entirety is being returned on call # thus a copy needs to be made in the function # this prevents the entirety of self being an element of a function # as variables can mutate in gana self.make_copy: bool = False self.category: str = "" # functions to evaluate within critical regions self.eval_funcs: dict[int, dict[int, F]] = {} # evaluations using parametric solutions self.evaluation: dict[int, dict[tuple[float, ...], float]] = {} @property def matrix(self) -> dict: """Matrix Representation""" if self.parent: return {self.n: 1} return {v: self.matrix for v in self._} @property def args(self) -> dict[str, str | bool]: """Return the arguments of the variable set""" return { "itg": self.itg, "nn": self.nn, "bnr": self.bnr, "mutable": self.mutable, "tag": self.tag, "ltx": self.ltx, } # ----------------------------------------------------- # Matrix # ----------------------------------------------------- @property def A(self) -> list[list[float]]: """Generate a diagonal matrix representation of the variable set""" return [[1] if self._[i] is not None else [] for i in range(len(self))] @property def features_in(self) -> list[C | O]: """Constraints and objectives that this variable set is part of""" return self.cons_by + self.min_by # ----------------------------------------------------- # Birthing # -----------------------------------------------------
[docs] def make_function(self) -> F: """ Make a function :returns: Function representing the variable set :rtype: F """ return F( one=make_P(1, self.index), mul=True, two=self, one_type=Elem.P, two_type=Elem.V, case=FCase.FVAR, )
[docs] def copy(self) -> V: """ Returns a copy of the variable set :returns: Copy of the variable set :rtype: V """ v = V(**self.args) v.name, v.n = self.name, self.n v.index = tuple(self.index) v.map = self.map.copy() v.case = self.case v._ = list(self._) v.copyof = self return v
[docs] def birth_variables(self, mutating: bool = False, n_start: int = 0): """ Births a variable at every index in the index set :param mutating: If the variable set is being mutated. Defaults to False. :type mutating: bool, optional :param n_start: The starting number for positioning the variables. Defaults to 0. :type n_start: int, optional """ for pos, idx in enumerate(self.map): # create a variable at each index variable = V(**self.args) # set the parent to self variable.parent = self # for mutations variable names # and positions will be set based on # the existing variable. # this is the nth variable declared variable.n = n_start + pos if not mutating: # give the same name as self variable.name = rf"{self}[{pos}]" # this is the position in the parent set variable.pos = pos # set the new variable's index variable.index = idx # the new variable set has only # one variable itself # I get that this is like a recursive definition variable._ = [variable] # append to the set of variables of self self._.append(variable) # update the index mapping self.map[idx] = variable variable.map[idx] = variable
# ----------------------------------------------------- # Solution # -----------------------------------------------------
[docs] def output( self, n_sol: int = 0, aslist: bool = False, asdict: bool = False, compare=False ) -> list[float] | dict[tuple[I, ...], float] | None: """ Solution :param n_sol: Solution number. Defaults to 0. :type n_sol: int, optional :param aslist: Returns values taken as list. Defaults to False. :type aslist: bool, optional :param asdict: Returns values taken as dictionary. Defaults to False. :type asdict: bool, optional :param compare: Displays a comparison of the solutions across multiple objectives. Defaults to False. :type compare: bool, optional :returns: Solution values :rtype: list[float] | dict[tuple[I, ...], float] | None """ if compare: # this writes out a comparison of the solutions across multiple objectives for v in self._: display( Math(v.latex() + r"=" + ", ".join(str(val) for val in v.X.values())) ) else: if aslist: return [v.X[n_sol] for v in self._ if n_sol in v.X] elif asdict: return {idx: v.X[n_sol] for idx, v in self.map.items() if n_sol in v.X} for v in self._: if n_sol in v.X: display(Math(v.latex() + r"=" + rf"{v.X[n_sol]}"))
[docs] def f_eval(self, *values: float | int, n_sol: int = 0, n_cr: int = 0) -> float: """ Evaluates the variable value as a function of parametric variables :param values: values of the parametric variables :type values: float | int :param n_sol: Solution number. Defaults to 0. :type n_sol: int, optional :param n_cr: Critical region number. Defaults to 0. :type n_cr: int, optional :returns: evaluated value :rtype: float """ return self.eval_funcs[n_sol][n_cr].eval(*values)
[docs] def eval(self, *theta_vals: float, n_sol: int = 0) -> float | None: """ Evaluates the variable value as a function of parametric variables :param theta_vals: values of the parametric variables :type theta_vals: float :param n_sol: solution number. Defaults to 0. :type n_sol: int, optional :param roundoff: round off the evaluated value. Defaults to 4. :type roundoff: int, optional :returns: evaluated value :rtype: float | None """ try: return self.evaluation[n_sol][theta_vals] except KeyError: logger.warning( "⛔ Run program.eval %s for appropriate solution number first ⛔", theta_vals, )
# ----------------------------------------------------- # Printing # ----------------------------------------------------- @property def ltx(self) -> str: """LaTeX representation""" if self.parent: return self._ltx if not self._ltx: # use name if no LaTeX self._ltx = self.name.replace("_", r"\_") return r"{\mathbf{" + self._ltx + r"}}" @property def index_ltx(self) -> str: """LaTeX representation of the index""" if len(self.index) == 1: try: return self.index[0].ltx except AttributeError: # if index is of the type ((a,b),) self.index = tuple(*self.index) if isinstance(self.index, set): return ( rf"({')|('.join(','.join(i.ltx for i in idx) for idx in self.index)})" ) return rf"{','.join(i.ltx if not isinstance(i, (list, tuple)) else i[0].ltx for i in self.index)}"
[docs] def latex(self) -> str: """ LaTeX representation :returns: LaTeX representation of the variable set :rtype: str """ return self.ltx + r"_{" + self.index_ltx + r"}"
[docs] def show(self, descriptive: bool = False): """ Display the variables :param descriptive: Print members of the index set :type descriptive: bool, optional """ if descriptive: for v in self._: if v: display(Math(v.latex())) else: display(Math(self.latex()))
[docs] def mps(self) -> str: """Name in MPS file :returns: Name in MPS file :rtype: str """ if self.bnr: return f"X{self.n}" return f"V{self.n}"
[docs] def lp(self) -> str: """LP representation :returns: LP representation :rtype: str """ return f"{self}_{self.pos}"
@property def longname(self) -> str: """Long name""" if self.parent: return f"{self.parent.name}(" + ",".join([i.name for i in self.index]) + ")" return f"{self.name}(" + ",".join([i.name for i in self.index]) + ")" # ----------------------------------------------------- # Birthers # -----------------------------------------------------
[docs] def report(self) -> V: """Return a reporting binary variable :returns: Reporting binary variable :rtype: V """ return V( *self.index, bnr=True, tag=f"Reporting binary for {self.tag}", ltx=rf"x_{self.ltx}", )
# ----------------------------------------------------- # Operators # ----------------------------------------------------- def __neg__(self) -> F: # doing this here saves some time # let the function know that you are passing something consistent already # saves time f = F( one=make_P(-1, self.index), mul=True, two=self, one_type=Elem.P, two_type=Elem.V, case=FCase.NEGVAR, consistent=True, ) return f def __add__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ) -> Self | F: if other is None: # if adding to nothing, return self # Duh return self if isinstance(other, (int, float)): # if adding to number, convert to P if other in [0, 0.0]: # if adding to zero, return self return self return F( one=self, add=True, two=make_P(other, self.index), one_type=Elem.V, two_type=Elem.P, consistent=True, ) if isinstance(other, tuple): # if adding to a tuple, convert to T return F( one=self, add=True, two=make_T(other, index=self.index), one_type=Elem.V, two_type=Elem.T, consistent=True, ) if isinstance(other, list): if isinstance(other[0], tuple): # if list of tuples # This does not allow for parametric variables and parameters # to be set sporadically across the index # that would take all instances in a list of be checked # which would be time consuming # Could make it an optional feature in the future return F( one=self, add=True, two=make_T(other), one_type=Elem.V, two_type=Elem.T, consistent=True, ) else: return F( one=self, add=True, two=make_P(other, index=self.index), one_type=Elem.V, two_type=Elem.P, consistent=True, ) return F(one=self, add=True, two=other, one_type=Elem.V) def __radd__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ) -> Self | F: # radd will only be called by non gana elements # default to add return self + other def __sub__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ) -> Self | F: if other is None: # if subtracting nothing from variable # return self return self if isinstance(other, (int, float)): # if subtracting a number, convert to P if other in [0, 0.0]: # if subtracting zero, return self return self return F( one=self, sub=True, two=make_P(other, self.index), one_type=Elem.V, two_type=Elem.P, consistent=True, ) if isinstance(other, tuple): # if subtracting a tuple, convert to T return F( one=self, sub=True, two=make_T(other, index=self.index), one_type=Elem.V, two_type=Elem.T, consistent=True, ) if isinstance(other, list): if isinstance(other[0], tuple): # This does not allow for parametric variables and parameters # to be set sporadically across the index # that would take all instances in a list of be checked # which would be time consuming # Could make it an optional feature in the future return F( one=self, sub=True, two=make_T(other), one_type=Elem.V, two_type=Elem.T, consistent=True, ) else: return F( one=self, sub=True, two=make_P(other), one_type=Elem.V, two_type=Elem.P, consistent=True, ) return F(one=self, sub=True, two=other, one_type=Elem.V) def __rsub__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ) -> Self | F: if other in [0, 0.0, None]: return -self else: # this is only called for non gana elements (lists, ints, floats, tuples) # as other - variable if isinstance(other, (int, float)) and other < 0: # if other is (int, float) and is negative # then -other - V should be -V - other return -self - (-other) # otherwise, it is -V + other return -self + other def __mul__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ) -> Self | F | float: if other is None: # multiplying by nothing # gives nothing return if isinstance(other, (int, float)): # multiplying by zero, gives 0 if other in [0, 0.0]: return 0.0 # multiplying by unity, gives itself if other in [1, 1.0]: return self # multiplying by negative unity, gives -negation if other in [1, -1.0]: return -self # let multiplication always be P*V return F( one=make_P(other, self.index), mul=True, two=self, one_type=Elem.P, two_type=Elem.V, consistent=True, ) if isinstance(other, tuple): # TODO multiplying by a tuple raise ValueError( f"{self}*{other}: Multiplication with multiparameteric variable is not supported yet" ) if isinstance(other, list): if isinstance(other[0], tuple): # TODO multiplying by a list of tuples raise ValueError( f"{self}*{other}: Multiplication with multiparameteric variable is not supported yet" ) else: return F( one=make_P(other), mul=True, two=self, one_type=Elem.P, two_type=Elem.V, consistent=True, ) from .parameter import P if isinstance(other, P): # multiplying by a parameter, make it a function # always keep the parameter upfront for multiplication return F(one=other, mul=True, two=self, one_type=Elem.P, two_type=Elem.V) return F(one=self, mul=True, two=other, one_type=Elem.V) def __rmul__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ) -> Self | F | float: # only called for non gana elements (tuple, list, int, float) # multiplication is commutative if isinstance(other, tuple): return other + (self,) # list int and float handle by __mul__ return self * other def __truediv__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ) -> Self | F: if other is None: raise ValueError("Cannot divide by None") if isinstance(other, (int, float)): # dividing by zero, raises error if other in [0, 0.0]: raise ZeroDivisionError("Cannot divide by zero") # dividing by unity, gives itself if other in [1, 1.0]: return self # dividing by negative unity, gives -negation if other in [-1, -1.0]: return -self # else make this a multiplication by reciprocal return F( one=make_P(1 / other, self.index), mul=True, two=self, one_type=Elem.P, two_type=Elem.V, consistent=True, ) if isinstance(other, tuple): # TODO division by tuple raise ValueError("Division by tuple is not supported yet, use T instead") if isinstance(other, list): # TODO division by list of tuples if isinstance(other[0], tuple): raise ValueError( "Division by tuple is not supported yet, use T instead" ) return F( one=make_P([1 / o for o in other]), mul=True, two=self, one_type=Elem.P, two_type=Elem.V, consistent=True, ) return F(one=self, div=True, two=other, one_type=Elem.V) def __rtruediv__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ): # TODO nonlinear stuff raise ValueError( "Division of something by a variable, non-linear operations are not supported yet" ) # ----------------------------------------------------- # Relational # ----------------------------------------------------- def __eq__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None | I ), ) -> C: if isinstance(other, I): # variables can be passed as indices return self.name == other.name return C(self - other) def __le__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ) -> C: return C(self - other, leq=True) def __ge__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ): return C(other - self, leq=True) def __lt__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ) -> C: return self <= other def __gt__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ) -> C: return self >= other def __pow__( self, other: ( Self | P | T | F | int | float | tuple[int | float] | list[int | float | tuple[int | float]] | None ), ) -> C: if other is None: # raising to nothing, return self return self if isinstance(other, (int, float)): if other in [0, 0.0]: # variable raised to 0 is 1 return 1.0 if other in [1, 1.0]: # variable raised to 1 is itself return self # f = self # for _ in range(other - 1): # f *= self # return f # TODO nonlinear stuff raise ValueError( "Raising variable to a power, non-linear operations are not supported yet" ) def __call__(self, *key: I, make_new: bool = False) -> Self: def lister(inp: tuple[I]) -> tuple[I | list[V]]: return tuple([i] if isinstance(i, V) else i for i in inp) # if a dependent variable is being passed in the key # extract variable from the index (it will be in a list) # the problem with equating variables is that # the __eq__ method is overloaded def delister(inp: tuple[I | list[V]]): return tuple(i[0] if isinstance(i, list) else i for i in inp) if not make_new: if not key or delister(key) == delister(self.index): # if the index is an exact match # or no key is passed self.make_copy = True return self # the check helps to handle if a variable itself is an index # we do not want to iterate over the entire variable set # but treat the variable as a single index element key: tuple[I] | set[tuple[I]] = lister(key) # if a subset is passed, # first create a product to match # the indices # indices = product(*key) # create a new variable set to return v = V(**self.args) v.name, v.n = self.name, self.n v.index = key # should be able to map these for index in product(*key): try: variable = self.map[index] except KeyError: variable = None try: v.map[index] = variable except TypeError: v.map = {index: variable} v._.append(variable) return v # ----------------------------------------------------- # Export # ----------------------------------------------------- # def sympy(self): # """symbolic representation""" # if has_sympy: # return IndexedBase(str(self))[ # symbols(",".join([f"{d}" for d in self.index]), cls=Idx) # ] # logger.warning( # "sympy is an optional dependency, pip install gana[all] to get optional dependencies" # ) # def pyomo(self): # """Pyomo representation""" # if has_pyomo: # idx = [i.pyomo() for i in self.index] # if self.bnr: # return PyoVar(*idx, domain=Binary, doc=str(self)) # elif self.itg: # if self.nn: # return PyoVar(*idx, domain=NonNegativeIntegers, doc=str(self)) # else: # return PyoVar(*idx, domain=Integers, doc=str(self)) # else: # if self.nn: # return PyoVar(*idx, domain=NonNegativeReals, doc=str(self)) # else: # return PyoVar(*idx, domain=Reals, doc=str(self)) # logger.warning( # "pyomo is an optional dependency, pip install gana[all] to get optional dependencies" # ) # ----------------------------------------------------- # Plotting # -----------------------------------------------------
[docs] def line( self, font_size: float = 16, fig_size: tuple[float, float] = (12, 6), linewidth: float = 0.7, color: str = "blue", grid_alpha: float = 0.3, usetex: bool = True, str_idx_lim: int = 10, ): """ Plot the variable set :param font_size: Font size for the plot. Defaults to 16. :type font_size: float, optional :param fig_size: Size of the figure. Defaults to (12, 6). :type fig_size: tuple[float, float], optional :param linewidth: Width of the line in the plot. Defaults to 0.7. :type linewidth: float, optional :param color: Color of the line in the plot. Defaults to 'blue'. :type color: str, optional :param grid_alpha: Transparency of the grid lines. Defaults to 0.3. :type grid_alpha: float, optional :param usetex: Use LaTeX for text rendering. Defaults to True. :type usetex: bool, optional :param str_idx_lim: Limit for string indices display. Defaults to 10. :type str_idx_lim: int, optional """ draw( element=self, data=self.output(aslist=True), kind="line", font_size=font_size, fig_size=fig_size, linewidth=linewidth, color=color, grid_alpha=grid_alpha, usetex=usetex, str_idx_lim=str_idx_lim, )
[docs] def bar( self, font_size: float = 16, fig_size: tuple[float, float] = (12, 6), linewidth: float = 0.7, color: str = "blue", grid_alpha: float = 0.3, usetex: bool = True, str_idx_lim: int = 10, ): """ Plot the variable set :param font_size: Font size for the plot. Defaults to 16. :type font_size: float, optional :param fig_size: Size of the figure. Defaults to (12, 6). :type fig_size: tuple[float, float], optional :param linewidth: Width of the line in the plot. Defaults to 0.7. :type linewidth: float, optional :param color: Color of the line in the plot. Defaults to 'blue'. :type color: str, optional :param grid_alpha: Transparency of the grid lines. Defaults to 0.3. :type grid_alpha: float, optional :param usetex: Use LaTeX for text rendering. Defaults to True. :type usetex: bool, optional :param str_idx_lim: Limit for string indices display. Defaults to 10. :type str_idx_lim: int, optional """ draw( element=self, data=self.output(aslist=True), kind="bar", font_size=font_size, fig_size=fig_size, linewidth=linewidth, color=color, grid_alpha=grid_alpha, usetex=usetex, str_idx_lim=str_idx_lim, )
def __len__(self) -> int: # For the love of god # Do not change this return len(self._)