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.

update

Updates the existing constraint(s) to match the constraint(s) in new_constraint.

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.

is_quadratic bool

Returns True if the constraint is quadratic, False otherwise.

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

is_quadratic: bool

Returns True if the constraint is quadratic, False otherwise.

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.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.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,995] * V[4,995] … >= 3                                                  │
│ 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,996] * V[4,996] … >= 3                                                  │
│ 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,997] * V[4,997] … >= 3                                                  │
│ 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,998] * V[4,998] … >= 3                                                  │
│ 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,999] * V[4,999] … >= 3                                                  │
└────────┴─────────────────────────────────────────────────────────────────────────────────────────┘
>>> 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,995] * V[4,995] … >= 3                                                  │
        │ 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,996] * V[4,996] … >= 3                                                  │
        │ 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,997] * V[4,997] … >= 3                                                  │
        │ 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,998] * V[4,998] … >= 3                                                  │
        │ 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,999] * V[4,999] … >= 3                                                  │
        └────────┴─────────────────────────────────────────────────────────────────────────────────────────┘
        >>> 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",
        _compute_all_rows=return_df,
    )
    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 = str_table.with_columns(rhs=pl.lit(rhs.item()))
    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

update(new_constraint: Constraint) -> None

Updates the existing constraint(s) to match the constraint(s) in new_constraint.

new_constraint must have the same dimensions as the existing constraint. An equality constraint cannot be updated with an inequality constraint and vice versa.

Conflicting labels (dimensioned constraints only)

An error will be raised if new_constraint tries to introduce labels that were not already present in the constraint. If the constraint has labels that are not present in new_constraint, those labels and their associated constraints will be left untouched.

Parameters:

Name Type Description Default
new_constraint Constraint

A constraint with the same dimensions and sense as the existing constraint in the model. The coefficients of this constraint will replace the coefficients of the existing constraint.

required

Examples:

Dimensionless constraints can be updated with new coefficients:

>>> m = pf.Model()
>>> m.X = pf.Variable(lb=0, ub=10)
>>> m.Y = pf.Variable(lb=0, ub=10)
>>> m.maximize = m.X + m.Y
>>> m.Con = m.X + 2 * m.Y <= 4
>>> m.optimize()
>>> m.X.solution, m.Y.solution
(4.0, 0.0)
>>> m.Con.update(m.X <= 6)
>>> m.optimize()
>>> m.X.solution, m.Y.solution
(6.0, 10.0)

Dimensioned constraints can also be updated:

>>> m = pf.Model()
>>> m.X = pf.Variable({"i": [1, 2, 3]}, lb=0, ub=10)
>>> m.maximize = m.X.sum()
>>> m.Con = m.X <= 4
>>> m.optimize()
>>> m.objective.value
12.0
>>> m.Con.update(m.X.filter(i=1) <= 5)
>>> m.optimize()
>>> m.objective.value
13.0
Source code in pyoframe/_core.py
def update(self, new_constraint: Constraint) -> None:
    """Updates the existing constraint(s) to match the constraint(s) in `new_constraint`.

    `new_constraint` must have the same dimensions as the existing constraint. An equality constraint cannot be updated with an inequality constraint and vice versa.

    !!! tip "Conflicting labels (dimensioned constraints only)"
        An error will be raised if `new_constraint` tries to introduce labels that were not already present in the constraint.
        If the constraint has labels that are not present in `new_constraint`, those labels and their associated constraints will be left untouched.

    Parameters:
        new_constraint:
            A constraint with the same dimensions and sense as the existing constraint in the model.
            The coefficients of this constraint will replace the coefficients of the existing constraint.

    Examples:
        Dimensionless constraints can be updated with new coefficients:
        >>> m = pf.Model()
        >>> m.X = pf.Variable(lb=0, ub=10)
        >>> m.Y = pf.Variable(lb=0, ub=10)
        >>> m.maximize = m.X + m.Y
        >>> m.Con = m.X + 2 * m.Y <= 4
        >>> m.optimize()
        >>> m.X.solution, m.Y.solution
        (4.0, 0.0)
        >>> m.Con.update(m.X <= 6)
        >>> m.optimize()
        >>> m.X.solution, m.Y.solution
        (6.0, 10.0)

        Dimensioned constraints can also be updated:
        >>> m = pf.Model()
        >>> m.X = pf.Variable({"i": [1, 2, 3]}, lb=0, ub=10)
        >>> m.maximize = m.X.sum()
        >>> m.Con = m.X <= 4
        >>> m.optimize()
        >>> m.objective.value
        12.0
        >>> m.Con.update(m.X.filter(i=1) <= 5)
        >>> m.optimize()
        >>> m.objective.value
        13.0
    """
    assert self._model is not None, (
        "Constraint must be added to a model before it can be updated."
    )
    if not self._model.solver.supports_updating_coefficients:
        raise PyoframeError(
            f"Solver '{self._model.solver_name}' does not support updating constraint."
        )
    if self.is_quadratic or new_constraint.is_quadratic:
        # Note to self: we probably shouldn't support changing the type during an update even if we do support quadratics
        raise NotImplementedError(
            f"Cannot update constraint '{self.name}' because updating with quadratic constraints is not yet supported."
        )
    if new_constraint._has_ids:
        raise PyoframeError(
            f"Cannot update constraint '{self.name}' because the new constraint was already added to the model."
        )

    if self.sense != new_constraint.sense:
        if (
            self.sense == ConstraintSense.EQ
            or new_constraint.sense == ConstraintSense.EQ
        ):
            self_type, other_type = (
                ("equality", "inequality")
                if self.sense == ConstraintSense.EQ
                else ("inequality", "equality")
            )
            raise PyoframeError(
                f"Cannot update {self_type} constraint '{self.name}' with {other_type} constraint ({new_constraint.sense})."
            )
        new_constraint = new_constraint._flip()
        assert self.sense == new_constraint.sense, (
            "Unexpected: Constraint senses don't match ({self.sense}!={new_constraint.sense})."
        )

    dims = self._dimensions_unsafe

    if set(dims) != set(new_constraint._dimensions_unsafe):
        raise PyoframeError(
            f"Cannot update constraint '{self.name}' (dimensions: {self.dimensions}) because new_constraint has different dimensions ({new_constraint.dimensions})."
        )

    new_data = new_constraint.lhs.data

    COEF_KEY_OLD = COEF_KEY + "_old"
    old_data = self.lhs.data.rename({COEF_KEY: COEF_KEY_OLD})

    if dims:
        # only update constraints with the same labels
        old_data = old_data.join(
            new_data,
            on=dims,
            how="semi",
            maintain_order="left_right" if Config.maintain_order else None,
        )

    # identify which coefficients need to be updated
    new_data = new_data.join(
        old_data,
        on=dims + self.lhs._variable_columns,
        how="full",
        coalesce=True,
        maintain_order="left_right" if Config.maintain_order else None,
    )
    # missing entries could just mean the coefficient was zero
    new_data = new_data.with_columns(pl.col(COEF_KEY, COEF_KEY_OLD).fill_null(0))
    new_data = new_data.filter(pl.col(COEF_KEY) != pl.col(COEF_KEY_OLD)).drop(
        COEF_KEY_OLD
    )

    # Get the constraint IDs
    id_data = self.data
    if self.dimensionless:
        new_data = new_data.with_columns(
            pl.lit(id_data.get_column(CONSTRAINT_KEY).item()).alias(CONSTRAINT_KEY)
        )
    else:
        new_data = new_data.join(
            id_data.select(dims + [CONSTRAINT_KEY]),
            on=dims,
            how="left",
            maintain_order="left_right" if Config.maintain_order else None,
        )
        if new_data.get_column(CONSTRAINT_KEY).null_count() > 0:
            extra_labels = (
                new_data.filter(pl.col(CONSTRAINT_KEY).is_null())
                .select(dims)
                .unique(maintain_order=Config.maintain_order)
            )
            raise PyoframeError(
                f"Could not update constraint '{self.name}' because new_constraint contains labels that do not exist in the existing constraint:\n{extra_labels}"
            )

    # update via pyoptinterface
    assert self._model is not None
    update_func = self._model.poi.set_normalized_coefficient
    variable_indexes = new_data.get_column(VAR_KEY).to_list()
    variable_indexes = [poi.VariableIndex(v) for v in variable_indexes]
    constraint_indexes = new_data.get_column(CONSTRAINT_KEY).to_list()
    _constraint_type = self._poi_constraint_type
    constraint_indexes = [
        poi.ConstraintIndex(_constraint_type, c) for c in constraint_indexes
    ]
    coefficients = new_data.get_column(COEF_KEY).to_list()

    for con, var, value in zip(constraint_indexes, variable_indexes, coefficients):
        update_func(con, var, value)

    # update Pyoframe data
    updated_data = (
        self.lhs.data.rename({COEF_KEY: COEF_KEY_OLD})
        .join(
            new_data.select(dims + self.lhs._variable_columns + [COEF_KEY]),
            on=dims + self.lhs._variable_columns,
            how="full",
            maintain_order="left_right" if Config.maintain_order else None,
            coalesce=True,
        )
        .with_columns(pl.col(COEF_KEY).fill_null(pl.col(COEF_KEY_OLD)))
        .drop(COEF_KEY_OLD)
        .filter(pl.col(COEF_KEY) != 0)
        .select(self.lhs.data.columns)  # reorder to maintain consistency
    )
    self.lhs._data = updated_data