Skip to content

Variable

Bases: BaseOperableBlock

A decision variable for an optimization model.

Tip

If lb or ub are a dimensioned object (e.g. an Expression), they will automatically be broadcasted to match the variable's dimensions.

Parameters:

Name Type Description Default
*indexing_sets SetTypes | Iterable[SetTypes]

If no indexing_sets are provided, a single variable with no dimensions is created. Otherwise, a variable is created for each element in the Cartesian product of the indexing_sets (see Set for details on behaviour).

()
vtype VType | VTypeValue

The type of the variable. Can be either a VType enum or a string. Default is VType.CONTINUOUS.

CONTINUOUS
lb Operable | None

The lower bound for the variables.

None
ub Operable | None

The upper bound for the variables.

None
equals Operable | None

When specified, a variable is created for every label in equals and a constraint is added to make the variable equal to the provided expression. indexing_sets cannot be provided when using equals.

None

Examples:

>>> import pandas as pd
>>> m = pf.Model()
>>> df = pd.DataFrame(
...     {"dim1": [1, 1, 2, 2, 3, 3], "dim2": ["a", "b", "a", "b", "a", "b"]}
... )
>>> Variable(df)
<Variable 'unnamed' height=6>
┌──────┬──────┐
│ dim1 ┆ dim2 │
│ (3)  ┆ (2)  │
╞══════╪══════╡
│ 1    ┆ a    │
│ 1    ┆ b    │
│ 2    ┆ a    │
│ 2    ┆ b    │
│ 3    ┆ a    │
│ 3    ┆ b    │
└──────┴──────┘

Variables cannot be used until they're added to the model.

>>> m.constraint = Variable(df) <= 3
Traceback (most recent call last):
...
ValueError: Cannot use 'Variable' before it has been added to a model.

Instead, assign the variable to the model first:

>>> m.v = Variable(df)
>>> m.constraint = m.v <= 3
>>> m.v
<Variable 'v' height=6>
┌──────┬──────┬──────────┐
│ dim1 ┆ dim2 ┆ variable │
│ (3)  ┆ (2)  ┆          │
╞══════╪══════╪══════════╡
│ 1    ┆ a    ┆ v[1,a]   │
│ 1    ┆ b    ┆ v[1,b]   │
│ 2    ┆ a    ┆ v[2,a]   │
│ 2    ┆ b    ┆ v[2,b]   │
│ 3    ┆ a    ┆ v[3,a]   │
│ 3    ┆ b    ┆ v[3,b]   │
└──────┴──────┴──────────┘
>>> m.v2 = Variable(df[["dim1"]])
Traceback (most recent call last):
...
ValueError: Duplicate rows found in input data.
>>> m.v3 = Variable(df[["dim1"]].drop_duplicates())
>>> m.v3
<Variable 'v3' height=3>
┌──────┬──────────┐
│ dim1 ┆ variable │
│ (3)  ┆          │
╞══════╪══════════╡
│ 1    ┆ v3[1]    │
│ 2    ┆ v3[2]    │
│ 3    ┆ v3[3]    │
└──────┴──────────┘

Methods:

Name Description
get_solution

Retrieves a variable's optimal value after the model has been solved.

next

Creates an expression where the variable at each label is the next variable in the specified dimension.

to_expr

Converts the Variable to an Expression.

Attributes:

Name Type Description
attr Container

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

solution DataFrame | float | int

Syntactic shortcut for Variable.get_solution().

vtype VType
Source code in pyoframe/_core.py
def __init__(
    self,
    *indexing_sets: SetTypes | Iterable[SetTypes],
    lb: Operable | None = None,
    ub: Operable | None = None,
    vtype: VType | VTypeValue = VType.CONTINUOUS,
    equals: Operable | None = None,
):
    if equals is not None:
        if isinstance(equals, (float, int)):
            if lb is not None:
                raise ValueError("Cannot specify 'lb' when 'equals' is a constant.")
            if ub is not None:
                raise ValueError("Cannot specify 'ub' when 'equals' is a constant.")
            lb = ub = equals
            equals = None
        else:
            assert len(indexing_sets) == 0, (
                "Cannot specify both 'equals' and 'indexing_sets'"
            )
            equals = equals.to_expr()  # TODO don't rely on monkey patch
            indexing_sets = (equals,)

    data = Set(*indexing_sets).data if len(indexing_sets) > 0 else pl.DataFrame()
    super().__init__(data)

    self.vtype: VType = VType(vtype)
    self._attr = Container(self._set_attribute, self._get_attribute)
    self._equals: Expression | None = equals

    if lb is not None and not isinstance(lb, (float, int)):
        lb: Expression = lb.to_expr()  # TODO don't rely on monkey patch
        if not self.dimensionless:
            lb = lb.over(*self.dimensions)
        self._lb_expr, self.lb = lb, None
    else:
        self._lb_expr, self.lb = None, lb
    if ub is not None and not isinstance(ub, (float, int)):
        ub = ub.to_expr()  # TODO don't rely on monkey patch
        if not self.dimensionless:
            ub = ub.over(*self.dimensions)  # pyright: ignore[reportOptionalIterable]
        self._ub_expr, self.ub = ub, None
    else:
        self._ub_expr, self.ub = None, ub

attr: Container

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

solution: pl.DataFrame | float | int

Syntactic shortcut for Variable.get_solution().

vtype: VType = VType(vtype)

get_solution(return_integers: bool = True) -> pl.DataFrame | float | int

Retrieves a variable's optimal value after the model has been solved.

Returns:

Type Description
DataFrame | float | int

A DataFrame if the variable has dimensions and a float or int otherwise.

Raises:

Type Description
RuntimeError

If the model has not been solved or the solver failed to find an optimal solution.

Parameters:

Name Type Description Default
return_integers bool

When True, the solution to binary and integer variables are returned as integers instead of floats. Integers are obtained by rounding floats to the nearest integer. If the rounding causes a change greater than Config.integer_tolerance, rounding is canceled, a warning is emitted, and floats are returned.

True

Examples:

>>> m = pf.Model()
>>> m.var_continuous = pf.Variable({"dim1": [1, 2, 3]}, lb=5, ub=5)
>>> m.var_integer = pf.Variable(
...     {"dim1": [1, 2, 3]}, lb=4.5, ub=5.5, vtype=pf.VType.INTEGER
... )
>>> m.var_dimensionless = pf.Variable(
...     lb=4.5, ub=5.5, vtype=pf.VType.INTEGER
... )
>>> m.optimize()
>>> m.var_continuous.solution
shape: (3, 2)
┌──────┬──────────┐
│ dim1 ┆ solution │
│ ---  ┆ ---      │
│ i64  ┆ f64      │
╞══════╪══════════╡
│ 1    ┆ 5.0      │
│ 2    ┆ 5.0      │
│ 3    ┆ 5.0      │
└──────┴──────────┘
>>> m.var_integer.solution
shape: (3, 2)
┌──────┬──────────┐
│ dim1 ┆ solution │
│ ---  ┆ ---      │
│ i64  ┆ i64      │
╞══════╪══════════╡
│ 1    ┆ 5        │
│ 2    ┆ 5        │
│ 3    ┆ 5        │
└──────┴──────────┘
>>> m.var_dimensionless.solution
5
Source code in pyoframe/_core.py
def get_solution(self, return_integers: bool = True) -> pl.DataFrame | float | int:
    """Retrieves a variable's optimal value after the model has been solved.

    Returns:
        A DataFrame if the variable has dimensions and a `float` or `int` otherwise.

    Raises:
        RuntimeError:
            If the model has not been solved or the solver failed to find an optimal solution.

    Parameters:
        return_integers:
            When `True`, the solution to binary and integer variables are returned as integers instead of floats.
            Integers are obtained by rounding floats to the nearest integer.
            If the rounding causes a change greater than [`Config.integer_tolerance`][pyoframe._Config.integer_tolerance], rounding is canceled, a warning is emitted, and floats are returned.

    Examples:
        >>> m = pf.Model()
        >>> m.var_continuous = pf.Variable({"dim1": [1, 2, 3]}, lb=5, ub=5)
        >>> m.var_integer = pf.Variable(
        ...     {"dim1": [1, 2, 3]}, lb=4.5, ub=5.5, vtype=pf.VType.INTEGER
        ... )
        >>> m.var_dimensionless = pf.Variable(
        ...     lb=4.5, ub=5.5, vtype=pf.VType.INTEGER
        ... )
        >>> m.optimize()
        >>> m.var_continuous.solution
        shape: (3, 2)
        ┌──────┬──────────┐
        │ dim1 ┆ solution │
        │ ---  ┆ ---      │
        │ i64  ┆ f64      │
        ╞══════╪══════════╡
        │ 1    ┆ 5.0      │
        │ 2    ┆ 5.0      │
        │ 3    ┆ 5.0      │
        └──────┴──────────┘
        >>> m.var_integer.solution
        shape: (3, 2)
        ┌──────┬──────────┐
        │ dim1 ┆ solution │
        │ ---  ┆ ---      │
        │ i64  ┆ i64      │
        ╞══════╪══════════╡
        │ 1    ┆ 5        │
        │ 2    ┆ 5        │
        │ 3    ┆ 5        │
        └──────┴──────────┘
        >>> m.var_dimensionless.solution
        5
    """
    assert self._model is not None, (
        "Cannot retrieve the variable's solution because the variable has not been added to a model."
    )

    if self._model.solver.check_termination_status_when_retrieving_solution:
        try:
            termination_status = self._model.attr.TerminationStatus
        except RuntimeError:
            termination_status = None

        invalid_termination_codes = (
            poi.TerminationStatusCode.INFEASIBLE,
            poi.TerminationStatusCode.INFEASIBLE_OR_UNBOUNDED,
            poi.TerminationStatusCode.DUAL_INFEASIBLE,
        )

        if termination_status in invalid_termination_codes:
            raise RuntimeError(
                f"Cannot retrieve the variable's solution because the solver did not find an optimal solution (its termination status is '{termination_status.name}')."
            )

    try:
        solution = self.attr.Value
    except RuntimeError as e:
        raise failed_attr_error(
            self._model, "Failed to retrieve the variable's solution."
        ) from e

    is_df = isinstance(solution, pl.DataFrame)

    if is_df:
        solution = solution.rename({"Value": SOLUTION_KEY})

    if return_integers and self.vtype in [VType.BINARY, VType.INTEGER]:
        if is_df:
            solution = solution.with_columns(
                _solution_int=pl.col(SOLUTION_KEY).round().cast(pl.Int64),
            )
        else:
            solution_int = int(round(solution))  # type: ignore

        if Config.integer_tolerance == 0:
            within_tolerance = True
        else:
            if is_df:
                within_tolerance = solution.filter(
                    (pl.col(SOLUTION_KEY) - pl.col("_solution_int")).abs()
                    > Config.integer_tolerance
                ).is_empty()
            else:
                within_tolerance = (
                    abs(solution - solution_int) <= Config.integer_tolerance  # type: ignore
                )

        if within_tolerance:
            solution = (
                solution.drop(SOLUTION_KEY).rename({"_solution_int": SOLUTION_KEY})
                if is_df
                else solution_int  # type: ignore
            )
        else:
            warnings.warn(
                f"Unable to convert solution for variable '{self.name}' from float to int. Consider loosening pf.Config.integer_tolerance."
            )

    return solution

next(dim: str, wrap_around: bool = False) -> Expression

Creates an expression where the variable at each label is the next variable in the specified dimension.

Parameters:

Name Type Description Default
dim str

The dimension over which to shift the variable.

required
wrap_around bool

If True, the last label in the dimension is connected to the first label.

False

Examples:

>>> import pandas as pd
>>> time_dim = pd.DataFrame({"time": ["00:00", "06:00", "12:00", "18:00"]})
>>> space_dim = pd.DataFrame({"city": ["Toronto", "Berlin"]})
>>> m = pf.Model()
>>> m.bat_charge = pf.Variable(time_dim, space_dim)
>>> m.bat_flow = pf.Variable(time_dim, space_dim)
>>> # Fails because the dimensions are not the same
>>> m.bat_charge + m.bat_flow == m.bat_charge.next("time")
Traceback (most recent call last):
...
pyoframe._constants.PyoframeError: Cannot subtract the two expressions below because expression 1 has extra labels.
Expression 1:       (bat_charge + bat_flow)
Expression 2:       bat_charge.next(…)
Extra labels in expression 1:
┌───────┬─────────┐
│ time  ┆ city    │
╞═══════╪═════════╡
│ 18:00 ┆ Toronto │
│ 18:00 ┆ Berlin  │
└───────┴─────────┘
Use .drop_extras() or .keep_extras() to indicate how the extra labels should be handled. Learn more at
    https://bravos-power.github.io/pyoframe/latest/learn/concepts/addition
>>> (m.bat_charge + m.bat_flow).drop_extras() == m.bat_charge.next("time")
<Constraint 'unnamed' (linear) height=6 terms=18>
┌───────┬─────────┬────────────────────────────────────────────────────────────────────────────────┐
│ time  ┆ city    ┆ constraint                                                                     │
│ (3)   ┆ (2)     ┆                                                                                │
╞═══════╪═════════╪════════════════════════════════════════════════════════════════════════════════╡
│ 00:00 ┆ Toronto ┆ bat_charge[00:00,Toronto] + bat_flow[00:00,Toronto]                            │
│       ┆         ┆ - bat_charge[06:00,Toronto] = 0                                                │
│ 00:00 ┆ Berlin  ┆ bat_charge[00:00,Berlin] + bat_flow[00:00,Berlin] - bat_charge[06:00,Berlin]   │
│       ┆         ┆ = 0                                                                            │
│ 06:00 ┆ Toronto ┆ bat_charge[06:00,Toronto] + bat_flow[06:00,Toronto]                            │
│       ┆         ┆ - bat_charge[12:00,Toronto] = 0                                                │
│ 06:00 ┆ Berlin  ┆ bat_charge[06:00,Berlin] + bat_flow[06:00,Berlin] - bat_charge[12:00,Berlin]   │
│       ┆         ┆ = 0                                                                            │
│ 12:00 ┆ Toronto ┆ bat_charge[12:00,Toronto] + bat_flow[12:00,Toronto]                            │
│       ┆         ┆ - bat_charge[18:00,Toronto] = 0                                                │
│ 12:00 ┆ Berlin  ┆ bat_charge[12:00,Berlin] + bat_flow[12:00,Berlin] - bat_charge[18:00,Berlin]   │
│       ┆         ┆ = 0                                                                            │
└───────┴─────────┴────────────────────────────────────────────────────────────────────────────────┘
>>> (m.bat_charge + m.bat_flow) == m.bat_charge.next(
...     "time", wrap_around=True
... )
<Constraint 'unnamed' (linear) height=8 terms=24>
┌───────┬─────────┬────────────────────────────────────────────────────────────────────────────────┐
│ time  ┆ city    ┆ constraint                                                                     │
│ (4)   ┆ (2)     ┆                                                                                │
╞═══════╪═════════╪════════════════════════════════════════════════════════════════════════════════╡
│ 00:00 ┆ Toronto ┆ bat_charge[00:00,Toronto] + bat_flow[00:00,Toronto]                            │
│       ┆         ┆ - bat_charge[06:00,Toronto] = 0                                                │
│ 00:00 ┆ Berlin  ┆ bat_charge[00:00,Berlin] + bat_flow[00:00,Berlin] - bat_charge[06:00,Berlin]   │
│       ┆         ┆ = 0                                                                            │
│ 06:00 ┆ Toronto ┆ bat_charge[06:00,Toronto] + bat_flow[06:00,Toronto]                            │
│       ┆         ┆ - bat_charge[12:00,Toronto] = 0                                                │
│ 06:00 ┆ Berlin  ┆ bat_charge[06:00,Berlin] + bat_flow[06:00,Berlin] - bat_charge[12:00,Berlin]   │
│       ┆         ┆ = 0                                                                            │
│ 12:00 ┆ Toronto ┆ bat_charge[12:00,Toronto] + bat_flow[12:00,Toronto]                            │
│       ┆         ┆ - bat_charge[18:00,Toronto] = 0                                                │
│ 12:00 ┆ Berlin  ┆ bat_charge[12:00,Berlin] + bat_flow[12:00,Berlin] - bat_charge[18:00,Berlin]   │
│       ┆         ┆ = 0                                                                            │
│ 18:00 ┆ Toronto ┆ bat_charge[18:00,Toronto] + bat_flow[18:00,Toronto]                            │
│       ┆         ┆ - bat_charge[00:00,Toronto] = 0                                                │
│ 18:00 ┆ Berlin  ┆ bat_charge[18:00,Berlin] + bat_flow[18:00,Berlin] - bat_charge[00:00,Berlin]   │
│       ┆         ┆ = 0                                                                            │
└───────┴─────────┴────────────────────────────────────────────────────────────────────────────────┘
Source code in pyoframe/_core.py
@return_new
def next(self, dim: str, wrap_around: bool = False):
    """Creates an expression where the variable at each label is the next variable in the specified dimension.

    Parameters:
        dim:
            The dimension over which to shift the variable.
        wrap_around:
            If `True`, the last label in the dimension is connected to the first label.

    Examples:
        >>> import pandas as pd
        >>> time_dim = pd.DataFrame({"time": ["00:00", "06:00", "12:00", "18:00"]})
        >>> space_dim = pd.DataFrame({"city": ["Toronto", "Berlin"]})
        >>> m = pf.Model()
        >>> m.bat_charge = pf.Variable(time_dim, space_dim)
        >>> m.bat_flow = pf.Variable(time_dim, space_dim)
        >>> # Fails because the dimensions are not the same
        >>> m.bat_charge + m.bat_flow == m.bat_charge.next("time")
        Traceback (most recent call last):
        ...
        pyoframe._constants.PyoframeError: Cannot subtract the two expressions below because expression 1 has extra labels.
        Expression 1:	(bat_charge + bat_flow)
        Expression 2:	bat_charge.next(…)
        Extra labels in expression 1:
        ┌───────┬─────────┐
        │ time  ┆ city    │
        ╞═══════╪═════════╡
        │ 18:00 ┆ Toronto │
        │ 18:00 ┆ Berlin  │
        └───────┴─────────┘
        Use .drop_extras() or .keep_extras() to indicate how the extra labels should be handled. Learn more at
            https://bravos-power.github.io/pyoframe/latest/learn/concepts/addition

        >>> (m.bat_charge + m.bat_flow).drop_extras() == m.bat_charge.next("time")
        <Constraint 'unnamed' (linear) height=6 terms=18>
        ┌───────┬─────────┬────────────────────────────────────────────────────────────────────────────────┐
        │ time  ┆ city    ┆ constraint                                                                     │
        │ (3)   ┆ (2)     ┆                                                                                │
        ╞═══════╪═════════╪════════════════════════════════════════════════════════════════════════════════╡
        │ 00:00 ┆ Toronto ┆ bat_charge[00:00,Toronto] + bat_flow[00:00,Toronto]                            │
        │       ┆         ┆ - bat_charge[06:00,Toronto] = 0                                                │
        │ 00:00 ┆ Berlin  ┆ bat_charge[00:00,Berlin] + bat_flow[00:00,Berlin] - bat_charge[06:00,Berlin]   │
        │       ┆         ┆ = 0                                                                            │
        │ 06:00 ┆ Toronto ┆ bat_charge[06:00,Toronto] + bat_flow[06:00,Toronto]                            │
        │       ┆         ┆ - bat_charge[12:00,Toronto] = 0                                                │
        │ 06:00 ┆ Berlin  ┆ bat_charge[06:00,Berlin] + bat_flow[06:00,Berlin] - bat_charge[12:00,Berlin]   │
        │       ┆         ┆ = 0                                                                            │
        │ 12:00 ┆ Toronto ┆ bat_charge[12:00,Toronto] + bat_flow[12:00,Toronto]                            │
        │       ┆         ┆ - bat_charge[18:00,Toronto] = 0                                                │
        │ 12:00 ┆ Berlin  ┆ bat_charge[12:00,Berlin] + bat_flow[12:00,Berlin] - bat_charge[18:00,Berlin]   │
        │       ┆         ┆ = 0                                                                            │
        └───────┴─────────┴────────────────────────────────────────────────────────────────────────────────┘

        >>> (m.bat_charge + m.bat_flow) == m.bat_charge.next(
        ...     "time", wrap_around=True
        ... )
        <Constraint 'unnamed' (linear) height=8 terms=24>
        ┌───────┬─────────┬────────────────────────────────────────────────────────────────────────────────┐
        │ time  ┆ city    ┆ constraint                                                                     │
        │ (4)   ┆ (2)     ┆                                                                                │
        ╞═══════╪═════════╪════════════════════════════════════════════════════════════════════════════════╡
        │ 00:00 ┆ Toronto ┆ bat_charge[00:00,Toronto] + bat_flow[00:00,Toronto]                            │
        │       ┆         ┆ - bat_charge[06:00,Toronto] = 0                                                │
        │ 00:00 ┆ Berlin  ┆ bat_charge[00:00,Berlin] + bat_flow[00:00,Berlin] - bat_charge[06:00,Berlin]   │
        │       ┆         ┆ = 0                                                                            │
        │ 06:00 ┆ Toronto ┆ bat_charge[06:00,Toronto] + bat_flow[06:00,Toronto]                            │
        │       ┆         ┆ - bat_charge[12:00,Toronto] = 0                                                │
        │ 06:00 ┆ Berlin  ┆ bat_charge[06:00,Berlin] + bat_flow[06:00,Berlin] - bat_charge[12:00,Berlin]   │
        │       ┆         ┆ = 0                                                                            │
        │ 12:00 ┆ Toronto ┆ bat_charge[12:00,Toronto] + bat_flow[12:00,Toronto]                            │
        │       ┆         ┆ - bat_charge[18:00,Toronto] = 0                                                │
        │ 12:00 ┆ Berlin  ┆ bat_charge[12:00,Berlin] + bat_flow[12:00,Berlin] - bat_charge[18:00,Berlin]   │
        │       ┆         ┆ = 0                                                                            │
        │ 18:00 ┆ Toronto ┆ bat_charge[18:00,Toronto] + bat_flow[18:00,Toronto]                            │
        │       ┆         ┆ - bat_charge[00:00,Toronto] = 0                                                │
        │ 18:00 ┆ Berlin  ┆ bat_charge[18:00,Berlin] + bat_flow[18:00,Berlin] - bat_charge[00:00,Berlin]   │
        │       ┆         ┆ = 0                                                                            │
        └───────┴─────────┴────────────────────────────────────────────────────────────────────────────────┘

    """
    wrapped = (
        self.data.select(dim)
        .unique(maintain_order=Config.maintain_order)
        .sort(by=dim)
    )
    wrapped = wrapped.with_columns(pl.col(dim).shift(-1).alias("__next"))
    if wrap_around:
        wrapped = wrapped.with_columns(pl.col("__next").fill_null(pl.first(dim)))
    else:
        wrapped = wrapped.drop_nulls(dim)

    expr = self.to_expr()
    data = expr.data.rename({dim: "__prev"})

    data = data.join(
        wrapped,
        left_on="__prev",
        right_on="__next",
        # We use "right" instead of "left" to maintain consistency with the behavior without maintain_order
        maintain_order="right" if Config.maintain_order else None,
    ).drop(["__prev", "__next"], strict=False)

    return data

to_expr() -> Expression

Converts the Variable to an Expression.

Source code in pyoframe/_core.py
def to_expr(self) -> Expression:
    """Converts the Variable to an Expression."""
    self._assert_has_ids()
    return self._new(self.data.drop(SOLUTION_KEY, strict=False), self.name)  # pyright: ignore[reportArgumentType], we know it's safe after _assert_has_ids()