Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ benchmark/scripts/leftovers/

# direnv
.envrc
AGENTS.md
2 changes: 1 addition & 1 deletion doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Release Notes
=============

.. Upcoming Version

* Add convenience function to create LinearExpression from constant
* Fix compatibility for xpress versions below 9.6 (regression)
* Performance: Up to 50x faster ``repr()`` for variables/constraints via O(log n) label lookup and direct numpy indexing
* Performance: Up to 46x faster ``ncons`` property by replacing ``.flat.labels.unique()`` with direct counting
Expand Down
48 changes: 45 additions & 3 deletions linopy/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@
SIGNS_pretty,
sign_replace_dict,
)
from linopy.types import CoordsLike, DimsLike
from linopy.types import (
CoordsLike,
DimsLike,
SideLike,
)

if TYPE_CHECKING:
from linopy.constraints import Constraint
Expand Down Expand Up @@ -1120,7 +1124,7 @@ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
return wrapper


def is_constant(func: Callable[..., Any]) -> Callable[..., Any]:
def require_constant(func: Callable[..., Any]) -> Callable[..., Any]:
from linopy import expressions, variables

@wraps(func)
Expand All @@ -1129,7 +1133,8 @@ def wrapper(self: Any, arg: Any) -> Any:
arg,
variables.Variable
| variables.ScalarVariable
| expressions.LinearExpression,
| expressions.LinearExpression
| expressions.QuadraticExpression,
):
raise TypeError(f"Assigned rhs must be a constant, got {type(arg)}).")
return func(self, arg)
Expand Down Expand Up @@ -1325,3 +1330,40 @@ def __call__(self) -> bool:
stacklevel=2,
)
return self.value


def is_constant(x: SideLike) -> bool:
"""
Check if the given object is a constant type or an expression type without
any variables.

Note that an expression such as ``x - x + 1`` will evaluate to ``False`` as
the expression is not simplified before evaluation.

Parameters
----------
x : SideLike
The object to check.

Returns
-------
bool
True if the object is constant-like, False otherwise.
"""
from linopy.expressions import (
SUPPORTED_CONSTANT_TYPES,
LinearExpression,
QuadraticExpression,
)
from linopy.variables import ScalarVariable, Variable

if isinstance(x, Variable | ScalarVariable):
return False
if isinstance(x, LinearExpression | QuadraticExpression):
return x.is_constant
if isinstance(x, SUPPORTED_CONSTANT_TYPES):
return True
raise TypeError(
"Expected a constant, variable, or expression on the constraint side, "
f"got {type(x)}."
)
4 changes: 2 additions & 2 deletions linopy/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@
group_terms_polars,
has_optimized_model,
infer_schema_polars,
is_constant,
iterate_slices,
maybe_replace_signs,
print_coord,
print_single_constraint,
print_single_expression,
replace_by_map,
require_constant,
save_join,
to_dataframe,
to_polars,
Expand Down Expand Up @@ -457,7 +457,7 @@ def sign(self) -> DataArray:
return self.data.sign

@sign.setter
@is_constant
@require_constant
def sign(self, value: SignLike) -> None:
value = maybe_replace_signs(DataArray(value)).broadcast_like(self.sign)
self._data = assign_multiindex_safe(self.data, sign=value)
Expand Down
55 changes: 50 additions & 5 deletions linopy/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
get_index_map,
group_terms_polars,
has_optimized_model,
is_constant,
iterate_slices,
print_coord,
print_single_expression,
Expand Down Expand Up @@ -441,6 +442,11 @@ def __repr__(self) -> str:

return "\n".join(lines)

@property
def is_constant(self) -> bool:
"""True if the expression contains no variables."""
return self.data.sizes[TERM_DIM] == 0

def print(self, display_max_rows: int = 20, display_max_terms: int = 20) -> None:
"""
Print the linear expression.
Expand Down Expand Up @@ -840,9 +846,7 @@ def cumsum(
dim_dict = {dim_name: self.data.sizes[dim_name] for dim_name in dim}
return self.rolling(dim=dim_dict).sum(keep_attrs=keep_attrs, skipna=skipna)

def to_constraint(
self, sign: SignLike, rhs: ConstantLike | VariableLike | ExpressionLike
) -> Constraint:
def to_constraint(self, sign: SignLike, rhs: SideLike) -> Constraint:
"""
Convert a linear expression to a constraint.

Expand All @@ -859,6 +863,11 @@ def to_constraint(
which are moved to the left-hand-side and constant values which are moved
to the right-hand side.
"""
if self.is_constant and is_constant(rhs):
raise ValueError(
f"Both sides of the constraint are constant. At least one side must contain variables. {self} {rhs}"
)

all_to_lhs = (self - rhs).data
data = assign_multiindex_safe(
all_to_lhs[["coeffs", "vars"]], sign=sign, rhs=-all_to_lhs.const
Expand Down Expand Up @@ -1439,12 +1448,18 @@ def to_polars(self) -> pl.DataFrame:

The resulting DataFrame represents a long table format of the all
non-masked expressions with non-zero coefficients. It contains the
columns `coeffs`, `vars`.
columns `coeffs`, `vars`, `const`. The coeffs and vars columns will be null if the expression is constant.

Returns
-------
df : polars.DataFrame
"""
if self.is_constant:
df = pl.DataFrame(
{"const": self.data["const"].values.reshape(-1)}
).with_columns(pl.lit(None).alias("coeffs"), pl.lit(None).alias("vars"))
return df.select(["vars", "coeffs", "const"])

df = to_polars(self.data)
df = filter_nulls_polars(df)
df = group_terms_polars(df)
Expand Down Expand Up @@ -1647,6 +1662,26 @@ def process_one(

return merge(exprs, cls=cls) if len(exprs) > 1 else exprs[0]

@classmethod
def from_constant(cls, model: Model, constant: ConstantLike) -> LinearExpression:
"""
Create a linear expression from a constant value or series

Parameters
----------
model : linopy.Model
The model to which the constant expression will belong.
constant : int/float/array_like
The constant value for the linear expression.

Returns
-------
linopy.LinearExpression
A linear expression representing the constant value.
"""
const_da = as_dataarray(constant)
return LinearExpression(const_da, model)


class QuadraticExpression(BaseExpression):
"""
Expand Down Expand Up @@ -1835,12 +1870,22 @@ def to_polars(self, **kwargs: Any) -> pl.DataFrame:

The resulting DataFrame represents a long table format of the all
non-masked expressions with non-zero coefficients. It contains the
columns `coeffs`, `vars`.
columns `vars1`, `vars2`, `coeffs`, `const`. If the expression is constant, the `vars1` and `vars2` and `coeffs` columns will be null.

Returns
-------
df : polars.DataFrame
"""
if self.is_constant:
df = pl.DataFrame(
{"const": self.data["const"].values.reshape(-1)}
).with_columns(
pl.lit(None).alias("coeffs"),
pl.lit(None).alias("vars1"),
pl.lit(None).alias("vars2"),
)
return df.select(["vars1", "vars2", "coeffs", "const"])

vars = self.data.vars.assign_coords(
{FACTOR_DIM: ["vars1", "vars2"]}
).to_dataset(FACTOR_DIM)
Expand Down
6 changes: 5 additions & 1 deletion linopy/solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@
with contextlib.suppress(ModuleNotFoundError):
import gurobipy

available_solvers.append("gurobi")
try:
with contextlib.closing(gurobipy.Env()):
available_solvers.append("gurobi")
except gurobipy.GurobiError:
pass
with contextlib.suppress(ModuleNotFoundError):
_new_highspy_mps_layout = None
import highspy
Expand Down
6 changes: 3 additions & 3 deletions linopy/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@
get_dims_with_index_levels,
get_label_position,
has_optimized_model,
is_constant,
iterate_slices,
print_coord,
print_single_variable,
require_constant,
save_join,
set_int_index,
to_dataframe,
Expand Down Expand Up @@ -764,7 +764,7 @@ def upper(self) -> DataArray:
return self.data.upper

@upper.setter
@is_constant
@require_constant
def upper(self, value: ConstantLike) -> None:
"""
Set the upper bounds of the variables.
Expand All @@ -788,7 +788,7 @@ def lower(self) -> DataArray:
return self.data.lower

@lower.setter
@is_constant
@require_constant
def lower(self, value: ConstantLike) -> None:
"""
Set the lower bounds of the variables.
Expand Down
28 changes: 27 additions & 1 deletion test/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@
from xarray import DataArray
from xarray.testing.assertions import assert_equal

from linopy import LinearExpression, Variable
from linopy import LinearExpression, Model, Variable
from linopy.common import (
align,
as_dataarray,
assign_multiindex_safe,
best_int,
get_dims_with_index_levels,
is_constant,
iterate_slices,
)
from linopy.testing import assert_linequal, assert_varequal
Expand Down Expand Up @@ -711,3 +712,28 @@ def test_align(x: Variable, u: Variable) -> None: # noqa: F811
assert expr_obs.shape == (1, 1) # _term dim
assert isinstance(expr_obs, LinearExpression)
assert_linequal(expr_obs, expr.loc[[1]])


def test_is_constant() -> None:
model = Model()
index = pd.Index(range(10), name="t")
a = model.add_variables(name="a", coords=[index])
b = a.sel(t=1)
c = a * 2
d = a * a

non_constant = [a, b, c, d]
for nc in non_constant:
assert not is_constant(nc)

constant_values = [
5,
3.14,
np.int32(7),
np.float64(2.71),
pd.Series([1, 2, 3]),
np.array([4, 5, 6]),
xr.DataArray([k for k in range(10)], coords=[index]),
]
for cv in constant_values:
assert is_constant(cv)
7 changes: 6 additions & 1 deletion test/test_constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import polars as pl
import pytest
import xarray as xr
import xarray.core
from xarray.testing import assert_equal

import linopy
Expand Down Expand Up @@ -70,6 +69,12 @@ def test_empty_constraints_repr() -> None:
Model().constraints.__repr__()


def test_cannot_create_constraint_without_variable() -> None:
model = linopy.Model()
with pytest.raises(ValueError):
_ = linopy.LinearExpression(12, model) == linopy.LinearExpression(13, model)


def test_constraints_getter(m: Model, c: linopy.constraints.Constraint) -> None:
assert c.shape == (10,)
assert isinstance(m.constraints[["c"]], Constraints)
Expand Down
37 changes: 37 additions & 0 deletions test/test_linear_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,43 @@ def test_linear_expression_from_tuples_bad_calls(
LinearExpression.from_tuples(10)


def test_linear_expression_from_constant_scalar(m: Model) -> None:
expr = LinearExpression.from_constant(model=m, constant=10)
assert expr.is_constant
assert isinstance(expr, LinearExpression)
assert (expr.const == 10).all()


def test_linear_expression_from_constant_1D(m: Model) -> None:
arr = pd.Series(index=pd.Index([0, 1], name="t"), data=[10, 20])
expr = LinearExpression.from_constant(model=m, constant=arr)
assert isinstance(expr, LinearExpression)
assert list(expr.coords.keys())[0] == "t"
assert expr.nterm == 0
assert (expr.const.values == [10, 20]).all()
assert expr.is_constant


def test_constant_linear_expression_to_polars_2D(m: Model) -> None:
index_a = pd.Index([0, 1], name="a")
index_b = pd.Index([0, 1, 2], name="b")
arr = np.array([[10, 20, 30], [40, 50, 60]])
const = xr.DataArray(data=arr, coords=[index_a, index_b])

le_variable = m.add_variables(name="var", coords=[index_a, index_b]) * 1 + const
assert not le_variable.is_constant
le_const = LinearExpression.from_constant(model=m, constant=const)
assert le_const.is_constant

var_pol = le_variable.to_polars()
const_pol = le_const.to_polars()
assert var_pol.shape == const_pol.shape
assert var_pol.columns == const_pol.columns
assert all(const_pol["const"] == var_pol["const"])
assert all(const_pol["coeffs"].is_null())
assert all(const_pol["vars"].is_null())


def test_linear_expression_sanitize(x: Variable, y: Variable, z: Variable) -> None:
expr = 10 * x + y + z
assert isinstance(expr.sanitize(), LinearExpression)
Expand Down
Loading