Skip to content

Constraint

Bases: BaseBlock

An optimization constraint that can be added to a Model.

Implementation Note

Pyoframe simplifies constraints by moving all the constraint's mathematical terms to the left-hand side. This way, the right-hand side is always zero, and constraints only need to manage one expression.

Use <=, >=, or == operators to create constraints

Constraints should be created using the <=, >=, or == operators, not by directly calling the Constraint constructor.

Parameters:

Name Type Description Default
lhs Expression

The constraint's left-hand side expression.

required
sense ConstraintSense

The sense of the constraint.

required

Methods:

Name Description
estimated_size

Returns the estimated size of the constraint.

filter

Syntactic sugar on Constraint.lhs.data.filter(), to help debugging.

relax

Allows the constraint to be violated at a cost and, optionally, up to a maximum.

to_str

Converts the constraint to a human-readable string, or several arranged in a table.

Attributes:

Name Type Description
attr Container

Allows reading and writing constraint attributes similarly to Model.attr.

dual DataFrame | float

Returns the constraint's dual values.

lhs Expression
sense
Source code in pyoframe/_core.py
def __init__(self, lhs: Expression, sense: ConstraintSense):
    self.lhs: Expression = lhs
    self._model = lhs._model
    self.sense = sense
    self._to_relax: FuncArgs | None = None
    self._attr = Container(self._set_attribute, self._get_attribute)

    dims = self.lhs.dimensions
    data = (
        pl.DataFrame()
        if dims is None
        else self.lhs.data.select(dims).unique(maintain_order=Config.maintain_order)
    )

    super().__init__(data)

attr: Container

Allows reading and writing constraint attributes similarly to Model.attr.

dual: pl.DataFrame | float

Returns the constraint's dual values.

Examples:

>>> m = pf.Model()
>>> m.x = pf.Variable()
>>> m.y = pf.Variable()
>>> m.maximize = m.x - m.y

Notice that for every unit increase in the right-hand side, the objective only improves by 0.5.

>>> m.constraint_x = 2 * m.x <= 10
>>> m.constraint_y = 2 * m.y >= 5
>>> m.optimize()

For every unit increase in the right-hand side of constraint_x, the objective improves by 0.5.

>>> m.constraint_x.dual
0.5

For every unit increase in the right-hand side of constraint_y, the objective worsens by 0.5.

>>> m.constraint_y.dual
-0.5

lhs: Expression = lhs

sense = sense

estimated_size(*args, **kwargs)

Returns the estimated size of the constraint.

Includes the size of the underlying expression (Constraint.lhs).

See Expression.estimated_size for details on signature and behavior.

Examples:

An dimensionless constraint has contains a 32 bit constraint id and, for each term, a 64 bit coefficient with a 32 bit variable id. For a two-term expression that is: (32 + 2 * (64 + 32)) = 224 bits = 28 bytes.

>>> m = pf.Model()
>>> m.x = pf.Variable()
>>> m.con = m.x <= 4
>>> m.con.estimated_size()
28
Source code in pyoframe/_core.py
def estimated_size(self, *args, **kwargs):
    """Returns the estimated size of the constraint.

    Includes the size of the underlying expression (`Constraint.lhs`).

    See [`Expression.estimated_size`][pyoframe.Expression.estimated_size] for details on signature and behavior.

    Examples:
        An dimensionless constraint has contains a 32 bit constraint id and, for each term, a 64 bit coefficient with a 32 bit variable id.
        For a two-term expression that is: (32 + 2 * (64 + 32)) = 224 bits = 28 bytes.

        >>> m = pf.Model()
        >>> m.x = pf.Variable()
        >>> m.con = m.x <= 4
        >>> m.con.estimated_size()
        28
    """
    return super().estimated_size(*args, **kwargs) + self.lhs.estimated_size(
        *args, **kwargs
    )

filter(*args, **kwargs) -> pl.DataFrame

Syntactic sugar on Constraint.lhs.data.filter(), to help debugging.

Source code in pyoframe/_core.py
def filter(self, *args, **kwargs) -> pl.DataFrame:
    """Syntactic sugar on `Constraint.lhs.data.filter()`, to help debugging."""
    return self.lhs.data.filter(*args, **kwargs)

relax(cost: Operable, max: Operable | None = None) -> Constraint

Allows the constraint to be violated at a cost and, optionally, up to a maximum.

Warning

.relax() must be called before the constraint is assigned to the Model (see examples below).

Parameters:

Name Type Description Default
cost Operable

The cost of violating the constraint. Costs should be positive because Pyoframe will automatically make them negative for maximization problems.

required
max Operable | None

The maximum value of the relaxation variable.

None

Returns:

Type Description
Constraint

The same constraint

Examples:

>>> m = pf.Model()
>>> m.hours_sleep = pf.Variable(lb=0)
>>> m.hours_day = pf.Variable(lb=0)
>>> m.hours_in_day = m.hours_sleep + m.hours_day == 24
>>> m.maximize = m.hours_day
>>> m.must_sleep = (m.hours_sleep >= 8).relax(cost=2, max=3)
>>> m.optimize()
>>> m.hours_day.solution
16.0
>>> m.maximize += 2 * m.hours_day
>>> m.optimize()
>>> m.hours_day.solution
19.0

relax can only be called after the sense of the model has been defined.

>>> m = pf.Model()
>>> m.hours_sleep = pf.Variable(lb=0)
>>> m.hours_day = pf.Variable(lb=0)
>>> m.hours_in_day = m.hours_sleep + m.hours_day == 24
>>> m.must_sleep = (m.hours_sleep >= 8).relax(cost=2, max=3)
Traceback (most recent call last):
...
ValueError: Cannot relax a constraint before the objective sense has been set. Try setting the objective first or using Model(sense=...).

One way to solve this is by setting the sense directly on the model. See how this works fine:

>>> m = pf.Model(sense="max")
>>> m.hours_sleep = pf.Variable(lb=0)
>>> m.hours_day = pf.Variable(lb=0)
>>> m.hours_in_day = m.hours_sleep + m.hours_day == 24
>>> m.must_sleep = (m.hours_sleep >= 8).relax(cost=2, max=3)

And now an example with dimensions:

>>> homework_due_tomorrow = pl.DataFrame(
...     {
...         "project": ["A", "B", "C"],
...         "cost_per_hour_underdelivered": [10, 20, 30],
...         "hours_to_finish": [9, 9, 9],
...         "max_underdelivered": [1, 9, 9],
...     }
... )
>>> m.hours_spent = pf.Variable(homework_due_tomorrow["project"], lb=0)
>>> m.must_finish_project = (
...     m.hours_spent
...     >= homework_due_tomorrow[["project", "hours_to_finish"]]
... ).relax(
...     homework_due_tomorrow[["project", "cost_per_hour_underdelivered"]],
...     max=homework_due_tomorrow[["project", "max_underdelivered"]],
... )
>>> m.only_one_day = m.hours_spent.sum("project") <= 24
>>> # Relaxing a constraint after it has already been assigned will give an error
>>> m.only_one_day.relax(1)
Traceback (most recent call last):
...
ValueError: .relax() must be called before the Constraint is added to the model
>>> m.attr.Silent = True
>>> m.optimize()
>>> m.maximize.value
-50.0
>>> m.hours_spent.solution
shape: (3, 2)
┌─────────┬──────────┐
│ project ┆ solution │
│ ---     ┆ ---      │
│ str     ┆ f64      │
╞═════════╪══════════╡
│ A       ┆ 8.0      │
│ B       ┆ 7.0      │
│ C       ┆ 9.0      │
└─────────┴──────────┘
Source code in pyoframe/_core.py
def relax(self, cost: Operable, max: Operable | None = None) -> Constraint:
    """Allows the constraint to be violated at a `cost` and, optionally, up to a maximum.

    Warning:
        `.relax()` must be called before the constraint is assigned to the [Model][pyoframe.Model] (see examples below).

    Parameters:
        cost:
            The cost of violating the constraint. Costs should be positive because Pyoframe will automatically
            make them negative for maximization problems.
        max:
            The maximum value of the relaxation variable.

    Returns:
        The same constraint

    Examples:
        >>> m = pf.Model()
        >>> m.hours_sleep = pf.Variable(lb=0)
        >>> m.hours_day = pf.Variable(lb=0)
        >>> m.hours_in_day = m.hours_sleep + m.hours_day == 24
        >>> m.maximize = m.hours_day
        >>> m.must_sleep = (m.hours_sleep >= 8).relax(cost=2, max=3)
        >>> m.optimize()
        >>> m.hours_day.solution
        16.0
        >>> m.maximize += 2 * m.hours_day
        >>> m.optimize()
        >>> m.hours_day.solution
        19.0

        `relax` can only be called after the sense of the model has been defined.

        >>> m = pf.Model()
        >>> m.hours_sleep = pf.Variable(lb=0)
        >>> m.hours_day = pf.Variable(lb=0)
        >>> m.hours_in_day = m.hours_sleep + m.hours_day == 24
        >>> m.must_sleep = (m.hours_sleep >= 8).relax(cost=2, max=3)
        Traceback (most recent call last):
        ...
        ValueError: Cannot relax a constraint before the objective sense has been set. Try setting the objective first or using Model(sense=...).

        One way to solve this is by setting the sense directly on the model. See how this works fine:

        >>> m = pf.Model(sense="max")
        >>> m.hours_sleep = pf.Variable(lb=0)
        >>> m.hours_day = pf.Variable(lb=0)
        >>> m.hours_in_day = m.hours_sleep + m.hours_day == 24
        >>> m.must_sleep = (m.hours_sleep >= 8).relax(cost=2, max=3)

        And now an example with dimensions:

        >>> homework_due_tomorrow = pl.DataFrame(
        ...     {
        ...         "project": ["A", "B", "C"],
        ...         "cost_per_hour_underdelivered": [10, 20, 30],
        ...         "hours_to_finish": [9, 9, 9],
        ...         "max_underdelivered": [1, 9, 9],
        ...     }
        ... )
        >>> m.hours_spent = pf.Variable(homework_due_tomorrow["project"], lb=0)
        >>> m.must_finish_project = (
        ...     m.hours_spent
        ...     >= homework_due_tomorrow[["project", "hours_to_finish"]]
        ... ).relax(
        ...     homework_due_tomorrow[["project", "cost_per_hour_underdelivered"]],
        ...     max=homework_due_tomorrow[["project", "max_underdelivered"]],
        ... )
        >>> m.only_one_day = m.hours_spent.sum("project") <= 24
        >>> # Relaxing a constraint after it has already been assigned will give an error
        >>> m.only_one_day.relax(1)
        Traceback (most recent call last):
        ...
        ValueError: .relax() must be called before the Constraint is added to the model
        >>> m.attr.Silent = True
        >>> m.optimize()
        >>> m.maximize.value
        -50.0
        >>> m.hours_spent.solution
        shape: (3, 2)
        ┌─────────┬──────────┐
        │ project ┆ solution │
        │ ---     ┆ ---      │
        │ str     ┆ f64      │
        ╞═════════╪══════════╡
        │ A       ┆ 8.0      │
        │ B       ┆ 7.0      │
        │ C       ┆ 9.0      │
        └─────────┴──────────┘
    """
    if self._has_ids:
        raise ValueError(
            ".relax() must be called before the Constraint is added to the model"
        )

    m = self._model
    if m is None:
        self._to_relax = FuncArgs(args=[cost, max])
        return self

    var_name = f"{self.name}_relaxation"
    assert not hasattr(m, var_name), (
        "Conflicting names, relaxation variable already exists on the model."
    )
    var = Variable(self, lb=0, ub=max)
    setattr(m, var_name, var)

    if self.sense == ConstraintSense.LE:
        self.lhs -= var
    elif self.sense == ConstraintSense.GE:
        self.lhs += var
    else:  # pragma: no cover
        # TODO
        raise NotImplementedError(
            "Relaxation for equalities has not yet been implemented. Submit a pull request!"
        )

    penalty = var * cost
    if self.dimensions:
        penalty = penalty.sum()
    if m.sense is None:
        raise ValueError(
            "Cannot relax a constraint before the objective sense has been set. Try setting the objective first or using Model(sense=...)."
        )
    elif m.sense == ObjSense.MAX:
        penalty *= -1
    if m.has_objective:
        m.objective += penalty
    else:
        m.objective = penalty

    return self

to_str(return_df: bool = False) -> str | pl.DataFrame

to_str(return_df: Literal[False] = False) -> str
to_str(return_df: Literal[True] = True) -> pl.DataFrame

Converts the constraint to a human-readable string, or several arranged in a table.

Long expressions are truncated according to Config.print_max_terms and Config.print_polars_config.

Parameters:

Name Type Description Default
return_df bool

If True, returns a DataFrame containing strings instead of the string representation of the DataFrame.

False

Examples:

>>> import polars as pl
>>> m = pf.Model()
>>> x = pf.Set(x=range(1000))
>>> y = pf.Set(y=range(1000))
>>> m.V = pf.Variable(x, y)
>>> expr = 2 * m.V * m.V
>>> print((expr <= 3).to_str())
┌────────┬────────┬────────────────────────────────┐
│ x      ┆ y      ┆ constraint                     │
│ (1000) ┆ (1000) ┆                                │
╞════════╪════════╪════════════════════════════════╡
│ 0      ┆ 0      ┆ 2 V[0,0] * V[0,0] <= 3         │
│ 0      ┆ 1      ┆ 2 V[0,1] * V[0,1] <= 3         │
│ 0      ┆ 2      ┆ 2 V[0,2] * V[0,2] <= 3         │
│ 0      ┆ 3      ┆ 2 V[0,3] * V[0,3] <= 3         │
│ 0      ┆ 4      ┆ 2 V[0,4] * V[0,4] <= 3         │
│ …      ┆ …      ┆ …                              │
│ 999    ┆ 995    ┆ 2 V[999,995] * V[999,995] <= 3 │
│ 999    ┆ 996    ┆ 2 V[999,996] * V[999,996] <= 3 │
│ 999    ┆ 997    ┆ 2 V[999,997] * V[999,997] <= 3 │
│ 999    ┆ 998    ┆ 2 V[999,998] * V[999,998] <= 3 │
│ 999    ┆ 999    ┆ 2 V[999,999] * V[999,999] <= 3 │
└────────┴────────┴────────────────────────────────┘
>>> expr = expr.sum("x")
>>> print((expr >= 3).to_str())
┌────────┬─────────────────────────────────────────────────────────────────────────────────────────┐
│ y      ┆ constraint                                                                              │
│ (1000) ┆                                                                                         │
╞════════╪═════════════════════════════════════════════════════════════════════════════════════════╡
│ 0      ┆ 2 V[0,0] * V[0,0] +2 V[1,0] * V[1,0] +2 V[2,0] * V[2,0] +2 V[3,0] * V[3,0] +2 V[4,0] *  │
│        ┆ V[4,0] … >= 3                                                                           │
│ 1      ┆ 2 V[0,1] * V[0,1] +2 V[1,1] * V[1,1] +2 V[2,1] * V[2,1] +2 V[3,1] * V[3,1] +2 V[4,1] *  │
│        ┆ V[4,1] … >= 3                                                                           │
│ 2      ┆ 2 V[0,2] * V[0,2] +2 V[1,2] * V[1,2] +2 V[2,2] * V[2,2] +2 V[3,2] * V[3,2] +2 V[4,2] *  │
│        ┆ V[4,2] … >= 3                                                                           │
│ 3      ┆ 2 V[0,3] * V[0,3] +2 V[1,3] * V[1,3] +2 V[2,3] * V[2,3] +2 V[3,3] * V[3,3] +2 V[4,3] *  │
│        ┆ V[4,3] … >= 3                                                                           │
│ 4      ┆ 2 V[0,4] * V[0,4] +2 V[1,4] * V[1,4] +2 V[2,4] * V[2,4] +2 V[3,4] * V[3,4] +2 V[4,4] *  │
│        ┆ V[4,4] … >= 3                                                                           │
│ …      ┆ …                                                                                       │
│ 995    ┆ 2 V[0,995] * V[0,995] +2 V[1,995] * V[1,995] +2 V[2,995] * V[2,995] +2 V[3,995] *       │
│        ┆ V[3,995] +2 V[4,99…                                                                     │
│ 996    ┆ 2 V[0,996] * V[0,996] +2 V[1,996] * V[1,996] +2 V[2,996] * V[2,996] +2 V[3,996] *       │
│        ┆ V[3,996] +2 V[4,99…                                                                     │
│ 997    ┆ 2 V[0,997] * V[0,997] +2 V[1,997] * V[1,997] +2 V[2,997] * V[2,997] +2 V[3,997] *       │
│        ┆ V[3,997] +2 V[4,99…                                                                     │
│ 998    ┆ 2 V[0,998] * V[0,998] +2 V[1,998] * V[1,998] +2 V[2,998] * V[2,998] +2 V[3,998] *       │
│        ┆ V[3,998] +2 V[4,99…                                                                     │
│ 999    ┆ 2 V[0,999] * V[0,999] +2 V[1,999] * V[1,999] +2 V[2,999] * V[2,999] +2 V[3,999] *       │
│        ┆ V[3,999] +2 V[4,99…                                                                     │
└────────┴─────────────────────────────────────────────────────────────────────────────────────────┘
>>> expr = expr.sum("y")
>>> print((expr == 3).to_str())
2 V[0,0] * V[0,0] +2 V[0,1] * V[0,1] +2 V[0,2] * V[0,2] +2 V[0,3] * V[0,3] +2 V[0,4] * V[0,4] … = 3
Source code in pyoframe/_core.py
def to_str(self, return_df: bool = False) -> str | pl.DataFrame:
    """Converts the constraint to a human-readable string, or several arranged in a table.

    Long expressions are truncated according to [`Config.print_max_terms`][pyoframe._Config.print_max_terms] and [`Config.print_polars_config`][pyoframe._Config.print_polars_config].

    Parameters:
        return_df:
            If `True`, returns a DataFrame containing strings instead of the string representation of the DataFrame.

    Examples:
        >>> import polars as pl
        >>> m = pf.Model()
        >>> x = pf.Set(x=range(1000))
        >>> y = pf.Set(y=range(1000))
        >>> m.V = pf.Variable(x, y)
        >>> expr = 2 * m.V * m.V
        >>> print((expr <= 3).to_str())
        ┌────────┬────────┬────────────────────────────────┐
        │ x      ┆ y      ┆ constraint                     │
        │ (1000) ┆ (1000) ┆                                │
        ╞════════╪════════╪════════════════════════════════╡
        │ 0      ┆ 0      ┆ 2 V[0,0] * V[0,0] <= 3         │
        │ 0      ┆ 1      ┆ 2 V[0,1] * V[0,1] <= 3         │
        │ 0      ┆ 2      ┆ 2 V[0,2] * V[0,2] <= 3         │
        │ 0      ┆ 3      ┆ 2 V[0,3] * V[0,3] <= 3         │
        │ 0      ┆ 4      ┆ 2 V[0,4] * V[0,4] <= 3         │
        │ …      ┆ …      ┆ …                              │
        │ 999    ┆ 995    ┆ 2 V[999,995] * V[999,995] <= 3 │
        │ 999    ┆ 996    ┆ 2 V[999,996] * V[999,996] <= 3 │
        │ 999    ┆ 997    ┆ 2 V[999,997] * V[999,997] <= 3 │
        │ 999    ┆ 998    ┆ 2 V[999,998] * V[999,998] <= 3 │
        │ 999    ┆ 999    ┆ 2 V[999,999] * V[999,999] <= 3 │
        └────────┴────────┴────────────────────────────────┘
        >>> expr = expr.sum("x")
        >>> print((expr >= 3).to_str())
        ┌────────┬─────────────────────────────────────────────────────────────────────────────────────────┐
        │ y      ┆ constraint                                                                              │
        │ (1000) ┆                                                                                         │
        ╞════════╪═════════════════════════════════════════════════════════════════════════════════════════╡
        │ 0      ┆ 2 V[0,0] * V[0,0] +2 V[1,0] * V[1,0] +2 V[2,0] * V[2,0] +2 V[3,0] * V[3,0] +2 V[4,0] *  │
        │        ┆ V[4,0] … >= 3                                                                           │
        │ 1      ┆ 2 V[0,1] * V[0,1] +2 V[1,1] * V[1,1] +2 V[2,1] * V[2,1] +2 V[3,1] * V[3,1] +2 V[4,1] *  │
        │        ┆ V[4,1] … >= 3                                                                           │
        │ 2      ┆ 2 V[0,2] * V[0,2] +2 V[1,2] * V[1,2] +2 V[2,2] * V[2,2] +2 V[3,2] * V[3,2] +2 V[4,2] *  │
        │        ┆ V[4,2] … >= 3                                                                           │
        │ 3      ┆ 2 V[0,3] * V[0,3] +2 V[1,3] * V[1,3] +2 V[2,3] * V[2,3] +2 V[3,3] * V[3,3] +2 V[4,3] *  │
        │        ┆ V[4,3] … >= 3                                                                           │
        │ 4      ┆ 2 V[0,4] * V[0,4] +2 V[1,4] * V[1,4] +2 V[2,4] * V[2,4] +2 V[3,4] * V[3,4] +2 V[4,4] *  │
        │        ┆ V[4,4] … >= 3                                                                           │
        │ …      ┆ …                                                                                       │
        │ 995    ┆ 2 V[0,995] * V[0,995] +2 V[1,995] * V[1,995] +2 V[2,995] * V[2,995] +2 V[3,995] *       │
        │        ┆ V[3,995] +2 V[4,99…                                                                     │
        │ 996    ┆ 2 V[0,996] * V[0,996] +2 V[1,996] * V[1,996] +2 V[2,996] * V[2,996] +2 V[3,996] *       │
        │        ┆ V[3,996] +2 V[4,99…                                                                     │
        │ 997    ┆ 2 V[0,997] * V[0,997] +2 V[1,997] * V[1,997] +2 V[2,997] * V[2,997] +2 V[3,997] *       │
        │        ┆ V[3,997] +2 V[4,99…                                                                     │
        │ 998    ┆ 2 V[0,998] * V[0,998] +2 V[1,998] * V[1,998] +2 V[2,998] * V[2,998] +2 V[3,998] *       │
        │        ┆ V[3,998] +2 V[4,99…                                                                     │
        │ 999    ┆ 2 V[0,999] * V[0,999] +2 V[1,999] * V[1,999] +2 V[2,999] * V[2,999] +2 V[3,999] *       │
        │        ┆ V[3,999] +2 V[4,99…                                                                     │
        └────────┴─────────────────────────────────────────────────────────────────────────────────────────┘
        >>> expr = expr.sum("y")
        >>> print((expr == 3).to_str())
        2 V[0,0] * V[0,0] +2 V[0,1] * V[0,1] +2 V[0,2] * V[0,2] +2 V[0,3] * V[0,3] +2 V[0,4] * V[0,4] … = 3
    """
    dims = self.dimensions
    str_table = self.lhs.to_str(
        include_const_term=False, return_df=True, str_col_name="constraint"
    )
    rhs = self.lhs.constant_terms.with_columns(pl.col(COEF_KEY) * -1)
    rhs = cast_coef_to_string(rhs, drop_ones=False, always_show_sign=False)
    rhs = rhs.rename({COEF_KEY: "rhs"})
    if dims:
        constr_str = str_table.join(
            rhs, on=dims, how="left", maintain_order="left", coalesce=True
        )
    else:
        constr_str = pl.concat([str_table, rhs], how="horizontal")
    constr_str = constr_str.with_columns(
        pl.concat_str("constraint", pl.lit(f" {self.sense.value} "), "rhs")
    ).drop("rhs")

    if not return_df:
        if self.dimensions is None:
            constr_str = constr_str.item()
        else:
            constr_str = self._add_shape_to_columns(constr_str)
            with Config.print_polars_config:
                constr_str = repr(constr_str)

    return constr_str