diff --git a/.gitignore b/.gitignore index 5c6986ab..7b962a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ benchmark/scripts/leftovers/ # direnv .envrc +AGENTS.md diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 5ca5ecc7..b5fc9b43 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -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 diff --git a/linopy/common.py b/linopy/common.py index f9474d3a..7dd97b65 100644 --- a/linopy/common.py +++ b/linopy/common.py @@ -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 @@ -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) @@ -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) @@ -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)}." + ) diff --git a/linopy/constraints.py b/linopy/constraints.py index c0dfd3cc..6ddb9b2e 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -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, @@ -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) diff --git a/linopy/expressions.py b/linopy/expressions.py index d60c8be5..8e14dd0b 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -58,6 +58,7 @@ get_index_map, group_terms_polars, has_optimized_model, + is_constant, iterate_slices, print_coord, print_single_expression, @@ -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. @@ -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. @@ -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 @@ -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) @@ -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): """ @@ -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) diff --git a/linopy/solvers.py b/linopy/solvers.py index f0f732fe..2783e7b8 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -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 diff --git a/linopy/variables.py b/linopy/variables.py index 396f165f..a22da638 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -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, @@ -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. @@ -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. diff --git a/test/test_common.py b/test/test_common.py index 0ec933bf..db218375 100644 --- a/test/test_common.py +++ b/test/test_common.py @@ -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 @@ -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) diff --git a/test/test_constraint.py b/test/test_constraint.py index 716fff40..35f49ea2 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -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 @@ -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) diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index 2551c203..cf8eb4bb 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -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) diff --git a/test/test_optimization.py b/test/test_optimization.py index f8bdcb27..12399a4e 100644 --- a/test/test_optimization.py +++ b/test/test_optimization.py @@ -19,6 +19,7 @@ from linopy import GREATER_EQUAL, LESS_EQUAL, Model, solvers from linopy.common import to_path +from linopy.expressions import LinearExpression from linopy.solver_capabilities import ( SolverFeature, get_available_solvers_with_feature, @@ -121,6 +122,18 @@ def model_maximization() -> Model: return m +@pytest.fixture +def model_with_constant_expression() -> Model: + m = Model(chunk=None) + + x = m.add_variables(lower=0, name="x") + const = LinearExpression.from_constant(model=m, constant=2) + + m.add_constraints(x + const, GREATER_EQUAL, 5) + m.add_objective(x) + return m + + @pytest.fixture def model_with_inf() -> Model: m = Model() @@ -393,6 +406,21 @@ def test_default_setting_expression_sol_accessor( assert_equal(qexpr.solution, 4 * x.solution * y.solution) +@pytest.mark.parametrize("solver,io_api,explicit_coordinate_names", params) +def test_constant_expression_in_constraint( + model_with_constant_expression: Model, + solver: str, + io_api: str, + explicit_coordinate_names: bool, +) -> None: + status, condition = model_with_constant_expression.solve( + solver, io_api=io_api, explicit_coordinate_names=explicit_coordinate_names + ) + assert status == "ok" + assert np.isclose(model_with_constant_expression.objective.value or 0, 3.0) + assert np.isclose(model_with_constant_expression.solution["x"].item(), 3.0) + + @pytest.mark.parametrize("solver,io_api,explicit_coordinate_names", params) def test_anonymous_constraint( model: Model, diff --git a/test/test_quadratic_expression.py b/test/test_quadratic_expression.py index f5f86c35..fc1bb25f 100644 --- a/test/test_quadratic_expression.py +++ b/test/test_quadratic_expression.py @@ -287,7 +287,7 @@ def test_quadratic_expression_flat(x: Variable, y: Variable) -> None: assert len(expr.flat) == 2 -def test_linear_expression_to_polars(x: Variable, y: Variable) -> None: +def test_quadratic_expression_to_polars(x: Variable, y: Variable) -> None: expr = x * y + x + 5 df = expr.to_polars() assert isinstance(df, pl.DataFrame) @@ -296,6 +296,22 @@ def test_linear_expression_to_polars(x: Variable, y: Variable) -> None: assert len(df) == expr.nterm * 2 +def test_quadratic_expression_constant_to_polars() -> None: + m = Model() + arr = pd.Series(index=pd.Index([0, 1], name="t"), data=[10, 20]) + lin_expr = LinearExpression.from_constant(model=m, constant=arr) + quad_expr = lin_expr.to_quadexpr() + + assert quad_expr.is_constant + df = quad_expr.to_polars() + assert isinstance(df, pl.DataFrame) + assert df.columns == ["vars1", "vars2", "coeffs", "const"] + assert all(df["vars1"].is_null()) + assert all(df["vars2"].is_null()) + assert all(df["coeffs"].is_null()) + assert all(arr.to_numpy() == df["const"].to_numpy()) + + def test_quadratic_expression_to_matrix(model: Model, x: Variable, y: Variable) -> None: expr: QuadraticExpression = x * y + x + 5 # type: ignore diff --git a/test/test_variable.py b/test/test_variable.py index d1b5b4f5..b7aa0491 100644 --- a/test/test_variable.py +++ b/test/test_variable.py @@ -149,6 +149,11 @@ def test_variable_upper_setter_with_array_invalid_dim(x: linopy.Variable) -> Non x.upper = upper +def test_variable_upper_setter_with_non_constant(z: linopy.Variable) -> None: + with pytest.raises(TypeError): + z.upper = z + + def test_variable_lower_setter_with_array(x: linopy.Variable) -> None: idx = pd.RangeIndex(10, name="first") lower = pd.Series(range(15, 25), index=idx)