Source code for gamspy.formulations.general_constraints

from __future__ import annotations

import math
import typing

import gamspy as gp
from gamspy._algebra.condition import Condition
from gamspy._algebra.expression import Expression
from gamspy._algebra.operable import Operable
from gamspy._symbols.implicits import ImplicitSet, ImplicitVariable
from gamspy.exceptions import ValidationError
from gamspy.formulations.result import FormulationResult
from gamspy.formulations.utils import _domain_name

if typing.TYPE_CHECKING:
    from collections.abc import Sequence

    from gamspy._universe import Universe

    DomainElementType: typing.TypeAlias = (
        gp.Set | gp.Alias | gp.UniverseAlias | Universe | str
    )
    DomainType: typing.TypeAlias = list[DomainElementType]
    BigMType: typing.TypeAlias = int | float | Operable
    BinaryType: typing.TypeAlias = gp.Variable | ImplicitVariable


_RELATIONAL_OPERATORS = {"=l=", "=e=", "=g="}


def _is_binary(symbol: typing.Any) -> bool:
    if isinstance(symbol, ImplicitVariable):
        return symbol.parent.type == "binary"

    return isinstance(symbol, gp.Variable) and symbol.type == "binary"


def _domain_names(domain: Sequence[DomainElementType]) -> set[str]:
    return {_domain_name(set_) for set_ in domain}


def _validate_big_m(big_m: BigMType | None, controlled: set[str]) -> None:
    if big_m is None:
        return

    if isinstance(
        big_m, (gp.Variable, ImplicitVariable, gp.Set, gp.Alias, ImplicitSet)
    ):
        raise ValidationError("big_m cannot be a variable or a set")

    if isinstance(big_m, Expression) and big_m.operator in _RELATIONAL_OPERATORS:
        raise ValidationError("big_m cannot be an inequality or equality")

    if isinstance(big_m, Operable):
        if not _domain_names(big_m.domain) <= controlled:  # ty: ignore[unresolved-attribute]
            raise ValidationError(
                "The domain of big_m must be a subset of the domain of the constraint"
            )
        return

    if (
        isinstance(big_m, bool)
        or not isinstance(big_m, (int, float))
        or not math.isfinite(big_m)
        or big_m <= 0
    ):
        raise ValidationError(
            "big_m must be a positive finite number, a parameter expression or None"
        )


def _declaration_domain(index: DomainType) -> DomainType:
    # multidimensional subsets such as ij(i,j) cannot be declaration domains,
    # the symbols are declared over (i,j) and indexed with ij instead
    domain: DomainType = []
    for set_ in index:
        if _is_multidimensional(set_):
            domain.extend(set_.domain)
        else:
            domain.append(set_)

    return domain


def _dimension(set_: DomainElementType) -> int:
    return set_.dimension if isinstance(set_, (gp.Set, gp.Alias)) else 1


def _is_multidimensional(set_: typing.Any) -> typing.TypeGuard[gp.Set | gp.Alias]:
    return isinstance(set_, (gp.Set, gp.Alias)) and set_.dimension > 1


def _control_index(
    domain: DomainType,
) -> tuple[DomainType, list[typing.Any], set[str]]:
    # defining the equations over ij(i,j) instead of ij also controls i and j,
    # e.g. eq(ij(i,j)) .. x(ij) =l= b(i)
    expanded: dict[str, typing.Any] = {}
    components: set[str] = set()
    for set_ in domain:
        if not _is_multidimensional(set_) or not all(
            isinstance(elem, (gp.Set, gp.Alias)) for elem in set_.domain
        ):
            continue

        names = _domain_names(set_.domain)
        if len(names) == set_.dimension and not names & components:
            expanded[_domain_name(set_)] = set_[tuple(set_.domain)]
            components |= names

    # x[ij] <= p[i] has the domain [ij, i] but ij(i,j) already controls i
    index = [
        set_
        for set_ in domain
        if _is_multidimensional(set_) or _domain_name(set_) not in components
    ]
    key = [expanded.get(_domain_name(set_), set_) for set_ in index]

    return index, key, _domain_names(index) | components


def _indicator_positions(
    indicator_var: BinaryType, index: DomainType
) -> tuple[int, ...]:
    # positions of the indices of indicator_var: e.g. (0, 2) for indicator(b[i, k], 1, x[ij, k] <= 1)
    if isinstance(indicator_var, ImplicitVariable) and (
        indicator_var.permutation is not None
        or any(
            not isinstance(set_, (gp.Set, gp.Alias, gp.UniverseAlias, str))
            for set_ in indicator_var.domain
        )
        or sum(_dimension(set_) for set_ in indicator_var.domain)
        != indicator_var.parent.dimension
    ):
        raise ValidationError(
            "indicator_var must be indexed with sets only for native indicator"
            " constraints"
        )

    offsets: dict[str, range] = {}
    position = 0
    for set_ in index:
        dimension = _dimension(set_)
        offsets.setdefault(_domain_name(set_), range(position, position + dimension))
        if dimension > 1:
            for offset, component in enumerate(set_.domain):  # ty: ignore[unresolved-attribute]
                offsets.setdefault(
                    _domain_name(component),
                    range(position + offset, position + offset + 1),
                )
        position += dimension

    return tuple(
        position
        for set_ in indicator_var.domain
        for position in offsets[_domain_name(set_)]
    )


def _add_native_indicator(
    indicator_var: BinaryType,
    indicator_val: typing.Literal[0, 1],
    expr: Expression,
    condition: typing.Any,
    domain: DomainType,
    index: DomainType,
    key: list[typing.Any],
) -> FormulationResult:
    m = indicator_var.container
    positions = _indicator_positions(indicator_var, index)
    binary = (
        indicator_var.parent
        if isinstance(indicator_var, ImplicitVariable)
        else indicator_var
    )

    result = FormulationResult()
    equation = _add_equation(m, domain, key, expr, condition)

    # GAMS generates only the variables that appear in the equations of the
    # model but indicator_var might only appear in the options file
    if key:
        sum_domain = gp.Domain(*key)
        if condition is not None:
            sum_domain = sum_domain.where[condition]
        generation = gp.Sum(sum_domain, indicator_var) >= 0
    else:
        generated = (
            indicator_var if condition is None else indicator_var.where[condition]
        )
        generation = generated >= 0

    result.equations_created["indicator"] = equation
    result.equations_created["binary"] = m.addEquation(definition=generation)

    # Model adds the generation equation if the indicator equation is in the model
    equation._indicator = (
        binary,
        positions,
        indicator_val,
        result.equations_created["binary"],
    )
    return result


def _add_sos1_pair(m: gp.Container, domain: DomainType) -> gp.Variable:
    # at most one of [*domain, "0"] and [*domain, "1"] can be nonzero
    sos_dim = gp.math._generate_dims(m, [2])[0]
    return m.addVariable(domain=[*domain, sos_dim], type="sos1")


def _add_equation(
    m: gp.Container,
    domain: DomainType,
    key: list[typing.Any],
    definition: Expression,
    condition: typing.Any,
) -> gp.Equation:
    equation = m.addEquation(domain=domain)
    indices = key if key else ...
    if condition is None:
        equation[indices] = definition
    else:
        equation[indices].where[condition] = definition

    return equation


[docs] def indicator( indicator_var: BinaryType, indicator_val: typing.Literal[0, 1], expr: Expression | Condition, *, big_m: BigMType | None = None, native: bool = False, ) -> FormulationResult: """ Enforces the constraint ``expr`` only when ``indicator_var`` equals ``indicator_val``. When the binary variable takes the other value, the constraint is relaxed. This corresponds to the indicator constraint ``indicator_var == indicator_val => expr``. By default, the relationship is modeled with SOS1 variables and therefore does not require any bounds. Each ``<=`` or ``>=`` constraint **generates** one SOS1 variable and two equations, and an equality constraint generates one SOS1 variable and three equations. Usage of SOS1 variables requires a MIP solver that supports them. If ``big_m`` is provided, a big-M formulation is used instead, which **generates** one equation per ``<=`` or ``>=`` constraint and two per equality constraint. ``big_m`` must be at least as large as the largest possible violation of ``expr``, e.g. ``max(lhs - rhs)`` for a ``<=`` constraint. Tighter and **correct** values improve the linear relaxation. ``big_m`` can also be an expression of parameters and variable bounds, e.g. ``x.up - 10``, but must not contain variables. If ``native`` is True, the constraint is not reformulated. Instead, ``Model.solve`` passes it to the solver as an indicator constraint through the solver options file, so that the solver can handle it directly in its branch-and-cut algorithm. This is supported by COPT, CPLEX, GUROBI, SCIP and XPRESS. Solving a model that contains native indicator constraints with any other solver raises an error. Besides the constraint, one redundant equation is **generated** to ensure that GAMS generates ``indicator_var``. The domain of ``indicator_var`` must be a subset of the domain of ``expr``. If ``expr`` has additional domains, a single binary variable controls every constraint over those domains. If ``expr`` has a condition, e.g. ``(x <= 10).where[p > 0]``, the generated equations are only defined where the condition holds. Similarly, indexing with a multidimensional subset, e.g. ``x[ij] <= 10``, generates equations only for the elements of ``ij`` instead of the entire Cartesian product. FormulationResult: - With SOS1: variables_created: ["sos1"], equations_created: ["slack", "link"] - With big-M: equations_created: ["big_m"] - With native: equations_created: ["indicator", "binary"] - For equality constraints, the ``slack`` and ``big_m`` keys are prefixed with ``le_`` and ``ge_``. Parameters ---------- indicator_var : Variable | ImplicitVariable Binary variable controlling the constraint. indicator_val : Literal[0, 1] Value of ``indicator_var`` that activates the constraint. expr : Expression | Condition Constraint in the form of ``lhs <= rhs``, ``lhs >= rhs`` or ``lhs == rhs``, optionally with a condition. big_m : int | float | Operable | None, optional Big-M value to use instead of the SOS1 formulation. native : bool, optional Pass the indicator constraint to the solver instead of reformulating it. Returns ------- FormulationResult Examples -------- >>> import gamspy as gp >>> m = gp.Container() >>> i = gp.Set(m, "i", records=range(3)) >>> x = gp.Variable(m, "x", domain=i) >>> b = gp.Variable(m, "b", type="binary", domain=i) >>> p = gp.Parameter(m, "p", domain=i, records=[("0", 1), ("2", 5)]) >>> res = gp.formulations.indicator(b, 1, x <= 10) >>> len(res.equations_created) 2 >>> res = gp.formulations.indicator(b, 0, x == 5, big_m=100) >>> list(res.equations_created.keys()) ['le_big_m', 'ge_big_m'] >>> res = gp.formulations.indicator(b, 1, (x <= 10).where[p > 0], big_m=x.up - 10) >>> list(res.equations_created.keys()) ['big_m'] >>> res = gp.formulations.indicator(b, 1, x <= 10, native=True) >>> list(res.equations_created.keys()) ['indicator', 'binary'] """ if not _is_binary(indicator_var): raise ValidationError("indicator_var needs to be a binary variable") if isinstance(indicator_val, bool) or indicator_val not in (0, 1): raise ValidationError("indicator_val needs to be 1 or 0") condition = None if isinstance(expr, Condition): condition = expr.condition expr = expr.conditioning_on # ty: ignore[invalid-assignment] if not isinstance(expr, Expression) or expr.operator not in _RELATIONAL_OPERATORS: raise ValidationError("expr needs to be inequality or equality") index, key, controlled = _control_index(list(expr.domain)) if not _domain_names(indicator_var.domain) <= controlled: raise ValidationError( "The domain of indicator_var must be a subset of the domain of expr" ) if isinstance(condition, (gp.Set, gp.Alias)): # a bare s in $(s) does not compile, s(s) or s(i) does if condition.name in controlled: condition = condition[condition] elif _domain_names(condition.domain) <= controlled: condition = condition[tuple(condition.domain)] if isinstance(condition, (gp.Set, gp.Alias)): condition_domain = [condition] else: condition_domain = getattr(condition, "domain", []) if not _domain_names(condition_domain) <= controlled: raise ValidationError( "The domain of the condition must be a subset of the domain of expr" ) _validate_big_m(big_m, controlled) domain = _declaration_domain(index) if native: if big_m is not None: raise ValidationError("big_m cannot be used with native=True") return _add_native_indicator( indicator_var, indicator_val, expr, condition, domain, index, key ) lhs_minus_rhs = expr.left - expr.right # ty: ignore[unsupported-operator] if expr.operator == "=l=": rows = [("", lhs_minus_rhs)] elif expr.operator == "=g=": rows = [("", -lhs_minus_rhs)] else: rows = [("le_", lhs_minus_rhs), ("ge_", -lhs_minus_rhs)] active = indicator_var if indicator_val == 1 else 1 - indicator_var m = indicator_var.container result = FormulationResult() if big_m is not None: for prefix, row in rows: # enforces row <= 0 when active == 1 result.equations_created[f"{prefix}big_m"] = _add_equation( m, domain, key, row <= big_m * (1 - active), condition ) return result # SOS1 allows either active or the slack to be nonzero, not both sos1_var = _add_sos1_pair(m, domain) result.variables_created["sos1"] = sos1_var for prefix, row in rows: result.equations_created[f"{prefix}slack"] = _add_equation( m, domain, key, row <= sos1_var[[*index, "1"]], condition ) result.equations_created["link"] = _add_equation( m, domain, key, sos1_var[[*index, "0"]] == active, condition ) return result