From ba4ac0a07c906e3c040129b964ef4b8744dd52c4 Mon Sep 17 00:00:00 2001 From: Jonas Hoersch Date: Sun, 7 Sep 2025 20:52:23 +0200 Subject: [PATCH 01/10] feat: add sos constraints --- linopy/io.py | 68 +++++++++++++++++++++++++++++++++++++++++++++ linopy/model.py | 35 ++++++++++++++++++++++- linopy/variables.py | 29 ++++++++++++++++++- 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/linopy/io.py b/linopy/io.py index 7065adbb..f52db5d4 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -23,6 +23,7 @@ from tqdm import tqdm from linopy import solvers +from linopy.common import to_polars from linopy.constants import CONCAT_DIM from linopy.objective import Objective @@ -327,6 +328,66 @@ def integers_to_file( formatted.write_csv(f, **kwargs) +def sos_to_file( + m: Model, + f: BufferedWriter, + progress: bool = False, + slice_size: int = 2_000_000, + explicit_coordinate_names: bool = False, +) -> None: + """ + Write out integers of a model to a lp file. + """ + names = m.variables.sos + if not len(list(names)): + return + + print_variable, _ = get_printers( + m, explicit_coordinate_names=explicit_coordinate_names + ) + + f.write(b"\n\nsos\n\n") + if progress: + names = tqdm( + list(names), + desc="Writing sos constraints.", + colour=TQDM_COLOR, + ) + + for name in names: + var = m.variables[name] + sos_type = var.attrs["sos_type"] + sos_dim = var.attrs["sos_dim"] + + other_dims = tuple([dim for dim in var.labels.dims if dim != sos_dim]) + for var_slice in var.iterate_slices(slice_size, other_dims): + ds = var_slice.labels.to_dataset() + ds["sos_labels"] = ds["labels"].isel({sos_dim: 0}) + ds["weights"] = ds.coords[sos_dim] + df = to_polars(ds) + + df = df.group_by("sos_labels").agg( + pl.concat_str( + *print_variable(pl.col("labels")), pl.lit(":"), pl.col("weights") + ) + .str.join(" ") + .alias("var_weights") + ) + + columns = [ + pl.lit("s"), + pl.col("sos_labels"), + pl.lit(f": S{sos_type} :: "), + pl.col("var_weights"), + ] + + kwargs: Any = dict( + separator=" ", null_value="", quote_style="never", include_header=False + ) + formatted = df.select(pl.concat_str(columns, ignore_nulls=True)) + formatted.write_csv(f, **kwargs) + + def constraints_to_file( m: Model, f: BufferedWriter, @@ -464,6 +525,13 @@ def to_lp_file( slice_size=slice_size, explicit_coordinate_names=explicit_coordinate_names, ) + sos_to_file( + m, + f=f, + progress=progress, + slice_size=slice_size, + explicit_coordinate_names=explicit_coordinate_names, + ) f.write(b"end\n") logger.info(f" Writing time: {round(time.time() - start, 2)}s") diff --git a/linopy/model.py b/linopy/model.py index 149c2cc2..29a318c5 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -12,7 +12,7 @@ from collections.abc import Callable, Mapping, Sequence from pathlib import Path from tempfile import NamedTemporaryFile, gettempdir -from typing import Any, overload +from typing import Any, Literal, overload import numpy as np import pandas as pd @@ -551,6 +551,39 @@ def add_variables( self.variables.add(variable) return variable + def add_sos_constraints( + self, + variable: Variable, + sos_type: Literal[1, 2], + sos_dim: str, + ): + """ + Add an sos1 or sos2 constraint for one dimension of a variable + + The dimension values are used as SOS. + + Parameters + ---------- + variable : Variable + sos_type : {1, 2} + Type of SOS + sos_dim : str + Which dimension of variable to add SOS constraint to + """ + if sos_type not in (1, 2): + raise ValueError(f"sos_type must be 1 or 2, got {sos_type}") + if sos_dim not in variable.dims: + raise ValueError(f"sos_dim must name a variable dimension, got {sos_dim}") + + if "sos_type" in variable.attrs or "sos_dim" in variable.attrs: + sos_type = variable.attrs.get("sos_type") + sos_dim = variable.attrs.get("sos_dim") + raise ValueError( + "variable already has an sos{sos_type} constraint on {sos_dim}" + ) + + variable.attrs.update(sos_type=sos_type, sos_dim=sos_dim) + def add_constraints( self, lhs: VariableLike diff --git a/linopy/variables.py b/linopy/variables.py index 2a929515..b1fe486b 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -193,6 +193,14 @@ def __init__( if "label_range" not in data.attrs: data.assign_attrs(label_range=(data.labels.min(), data.labels.max())) + if "sos_type" in data.attrs or "sos_dim" in data.attrs: + if (sos_type := data.attrs.get("sos_type")) not in (1, 2): + raise ValueError(f"sos_type must be 1 or 2, got {sos_type}") + if (sos_dim := data.attrs.get("sos_dim")) not in data.dims: + raise ValueError( + f"sos_dim must name a variable dimension, got {sos_dim}" + ) + self._data = data self._model = model @@ -323,6 +331,8 @@ def __repr__(self) -> str: dim_names = self.coord_names dim_sizes = list(self.sizes.values()) masked_entries = (~self.mask).sum().values + sos_type = self.attrs.get("sos_type") + sos_dim = self.attrs.get("sos_dim") lines = [] if dims: @@ -344,9 +354,11 @@ def __repr__(self) -> str: shape_str = ", ".join(f"{d}: {s}" for d, s in zip(dim_names, dim_sizes)) mask_str = f" - {masked_entries} masked entries" if masked_entries else "" + sos_str = f" - sos{sos_type} on {sos_dim}" if sos_type and sos_dim else "" lines.insert( 0, - f"Variable ({shape_str}){mask_str}\n{'-' * (len(shape_str) + len(mask_str) + 11)}", + f"Variable ({shape_str}){mask_str}{sos_str}\n" + f"{'-' * (len(shape_str) + len(mask_str) + len(sos_str) + 11)}", ) else: lines.append( @@ -1362,6 +1374,21 @@ def continuous(self) -> Variables: self.model, ) + @property + def sos(self) -> Variables: + """ + Get all variables involved in an sos constraint. + """ + return self.__class__( + { + name: self.data[name] + for name in self + if self[name].attrs.get("sos_dim") + and self[name].attrs.get("sos_type") in (1, 2) + }, + self.model, + ) + @property def solution(self) -> Dataset: """ From 0ba34f85183078c0ef727b2c33b39d649333fa32 Mon Sep 17 00:00:00 2001 From: Jonas Hoersch Date: Sun, 7 Sep 2025 23:09:49 +0200 Subject: [PATCH 02/10] add gurobipy direct implementation --- linopy/io.py | 17 +++++++++++++++++ linopy/model.py | 13 ++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/linopy/io.py b/linopy/io.py index f52db5d4..d9e0f2a8 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -751,6 +751,23 @@ def to_gurobipy( c = model.addMConstr(M.A, x, M.sense, M.b) # type: ignore c.setAttr("ConstrName", list(names)) # type: ignore + if m.variables.sos: + for var_name in m.variables.sos: + var = m.variables.sos[var_name] + sos_type = var.attrs["sos_type"] + sos_dim = var.attrs["sos_dim"] + + def add_sos(s): + s = s.squeeze() + model.addSOS(sos_type, x[s].tolist(), s.coords[sos_dim].values) + + others = tuple(dim for dim in var.labels.dims if dim != sos_dim) + if not others: + add_sos(var.labels) + else: + for _, s in var.labels.groupby(*others): + add_sos(s) + model.update() return model diff --git a/linopy/model.py b/linopy/model.py index 29a318c5..59ae34c5 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -576,10 +576,17 @@ def add_sos_constraints( raise ValueError(f"sos_dim must name a variable dimension, got {sos_dim}") if "sos_type" in variable.attrs or "sos_dim" in variable.attrs: - sos_type = variable.attrs.get("sos_type") - sos_dim = variable.attrs.get("sos_dim") + existing_sos_type = variable.attrs.get("sos_type") + existing_sos_dim = variable.attrs.get("sos_dim") raise ValueError( - "variable already has an sos{sos_type} constraint on {sos_dim}" + f"variable already has an sos{existing_sos_type} constraint on {existing_sos_dim}" + ) + + # Validate that sos_dim coordinates are numeric (needed for weights) + if not pd.api.types.is_numeric_dtype(variable.coords[sos_dim]): + raise ValueError( + f"SOS constraint requires numeric coordinates for dimension '{sos_dim}', " + f"but got {variable.coords[sos_dim].dtype}" ) variable.attrs.update(sos_type=sos_type, sos_dim=sos_dim) From f82876f47b183ccd7ef0210787bedb06714011aa Mon Sep 17 00:00:00 2001 From: Jonas Hoersch Date: Sun, 7 Sep 2025 23:15:23 +0200 Subject: [PATCH 03/10] Add documentation (claude) --- doc/index.rst | 1 + doc/sos-constraints.rst | 303 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 doc/sos-constraints.rst diff --git a/doc/index.rst b/doc/index.rst index 2591021b..27a511a4 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -109,6 +109,7 @@ This package is published under MIT license. creating-variables creating-expressions creating-constraints + sos-constraints manipulating-models testing-framework transport-tutorial diff --git a/doc/sos-constraints.rst b/doc/sos-constraints.rst new file mode 100644 index 00000000..aa9d1bd2 --- /dev/null +++ b/doc/sos-constraints.rst @@ -0,0 +1,303 @@ +.. _sos-constraints: + +Special Ordered Sets (SOS) Constraints +======================================= + +Special Ordered Sets (SOS) are a constraint type used in mixed-integer programming to model situations where only one or two variables from an ordered set can be non-zero. Linopy supports both SOS Type 1 and SOS Type 2 constraints. + +.. contents:: + :local: + :depth: 2 + +Overview +-------- + +SOS constraints are particularly useful for: + +- **SOS1**: Modeling mutually exclusive choices (e.g., selecting one facility from multiple locations) +- **SOS2**: Piecewise linear approximations of nonlinear functions +- Improving branch-and-bound efficiency in mixed-integer programming + +Types of SOS Constraints +------------------------- + +SOS Type 1 (SOS1) +~~~~~~~~~~~~~~~~~~ + +In an SOS1 constraint, **at most one** variable in the ordered set can be non-zero. + +**Example use cases:** +- Facility location problems (choose one location among many) +- Technology selection (choose one technology option) +- Mutually exclusive investment decisions + +SOS Type 2 (SOS2) +~~~~~~~~~~~~~~~~~~ + +In an SOS2 constraint, **at most two adjacent** variables in the ordered set can be non-zero. The adjacency is determined by the ordering weights (coordinates) of the variables. + +**Example use cases:** +- Piecewise linear approximation of nonlinear functions +- Portfolio optimization with discrete risk levels +- Production planning with discrete capacity levels + +Basic Usage +----------- + +Adding SOS Constraints +~~~~~~~~~~~~~~~~~~~~~~~ + +To add SOS constraints to variables in linopy: + +.. code-block:: python + + import linopy + import pandas as pd + import xarray as xr + + # Create model + m = linopy.Model() + + # Create variables with numeric coordinates + coords = pd.Index([0, 1, 2], name="options") + x = m.add_variables(coords=[coords], name="x", lower=0, upper=1) + + # Add SOS1 constraint + m.add_sos_constraints(x, sos_type=1, sos_dim="options") + + # For SOS2 constraint + breakpoints = pd.Index([0.0, 1.0, 2.0], name="breakpoints") + lambdas = m.add_variables(coords=[breakpoints], name="lambdas", lower=0, upper=1) + m.add_sos_constraints(lambdas, sos_type=2, sos_dim="breakpoints") + +Method Signature +~~~~~~~~~~~~~~~~ + +.. code-block:: python + + Model.add_sos_constraints(variable, sos_type, sos_dim) + +**Parameters:** + +- ``variable`` : Variable + The variable to which the SOS constraint should be applied +- ``sos_type`` : {1, 2} + Type of SOS constraint (1 or 2) +- ``sos_dim`` : str + Name of the dimension along which the SOS constraint applies + +**Requirements:** + +- The specified dimension must exist in the variable +- The coordinates for the SOS dimension must be numeric (used as weights for ordering) +- Only one SOS constraint can be applied per variable + +Examples +-------- + +Example 1: Facility Location (SOS1) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import linopy + import pandas as pd + import xarray as xr + + # Problem data + locations = pd.Index([0, 1, 2, 3], name="locations") + costs = xr.DataArray([100, 150, 120, 80], coords=[locations]) + benefits = xr.DataArray([200, 300, 250, 180], coords=[locations]) + + # Create model + m = linopy.Model() + + # Decision variables: build facility at location i + build = m.add_variables(coords=[locations], name="build", lower=0, upper=1) + + # SOS1 constraint: at most one facility can be built + m.add_sos_constraints(build, sos_type=1, sos_dim="locations") + + # Objective: maximize net benefit + net_benefit = benefits - costs + m.add_objective(-((net_benefit * build).sum())) + + # Solve + m.solve(solver_name="highs") + + if m.status == "ok": + solution = build.solution.to_pandas() + selected_location = solution[solution > 0.5].index[0] + print(f"Build facility at location {selected_location}") + +Example 2: Piecewise Linear Approximation (SOS2) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import numpy as np + + # Approximate f(x) = x² over [0, 3] with breakpoints + breakpoints = pd.Index([0, 1, 2, 3], name="breakpoints") + + x_vals = xr.DataArray(breakpoints.to_series()) + y_vals = x_vals**2 + + # Create model + m = linopy.Model() + + # SOS2 variables (interpolation weights) + lambdas = m.add_variables(lower=0, upper=1, coords=[breakpoints], name="lambdas") + m.add_sos_constraints(lambdas, sos_type=2, sos_dim="breakpoints") + + # Interpolated coordinates + x = m.add_variables(name="x", lower=0, upper=3) + y = m.add_variables(name="y", lower=0, upper=9) + + # Constraints + m.add_constraints(lambdas.sum() == 1, name="convexity") + m.add_constraints(x == lambdas @ x_vals, name="x_interpolation") + m.add_constraints(y == lambdas @ y_vals, name="y_interpolation") + m.add_constraints(x >= 1.5, name="x_minimum") + + # Objective: minimize approximated function value + m.add_objective(y) + + # Solve + m.solve(solver_name="highs") + +Working with Multi-dimensional Variables +----------------------------------------- + +SOS constraints are created for each dimension that is not sos_dim. + +.. code-block:: python + + # Multi-period production planning + periods = pd.Index(range(3), name="periods") + modes = pd.Index([0, 1, 2], name="modes") + + # 2D variables: periods × modes + period_modes = m.add_variables( + lower=0, upper=1, coords=[periods, modes], name="use_mode" + ) + + # Adds SOS1 constraint for each period + m.add_sos_constraints(period_modes, sos_type=1, sos_dim="modes") + +Accessing SOS Variables +----------------------- + +You can easily identify and access variables with SOS constraints: + +.. code-block:: python + + # Get all variables with SOS constraints + sos_variables = m.variables.sos + print(f"SOS variables: {list(sos_variables.keys())}") + + # Check SOS properties of a variable + for var_name in sos_variables: + var = m.variables[var_name] + sos_type = var.attrs["sos_type"] + sos_dim = var.attrs["sos_dim"] + print(f"{var_name}: SOS{sos_type} on dimension '{sos_dim}'") + +Variable Representation +~~~~~~~~~~~~~~~~~~~~~~~ + +Variables with SOS constraints show their SOS information in string representations: + +.. code-block:: python + + print(build) + # Output: Variable (locations: 4) - sos1 on locations + # ----------------------------------------------- + # [0]: build[0] ∈ [0, 1] + # [1]: build[1] ∈ [0, 1] + # [2]: build[2] ∈ [0, 1] + # [3]: build[3] ∈ [0, 1] + +LP File Export +-------------- + +The generated LP file will include a SOS section: + +.. code-block:: text + + sos + + s0: S1 :: x0:0 x1:1 x2:2 + s3: S2 :: x3:0.0 x4:1.0 x5:2.0 + +Solver Compatibility +-------------------- + +SOS constraints are supported by most modern mixed-integer programming solvers through the LP file format: + +**Supported solvers:** +- HiGHS +- Gurobi +- CPLEX +- COIN-OR CBC +- SCIP +- Xpress + +**Note:** Some solvers may have varying levels of SOS support. Check your solver's documentation for specific capabilities. + +Common Patterns +--------------- + +Piecewise Linear Cost Function +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + def add_piecewise_cost(model, variable, breakpoints, costs): + """Add piecewise linear cost function using SOS2.""" + n_segments = len(breakpoints) + lambda_coords = pd.Index(range(n_segments), name="segments") + + lambdas = model.add_variables( + coords=[lambda_coords], name="cost_lambdas", lower=0, upper=1 + ) + model.add_sos_constraints(lambdas, sos_type=2, sos_dim="segments") + + cost_var = model.add_variables(name="cost", lower=0) + + x_vals = xr.DataArray(breakpoints, coords=[lambda_coords]) + c_vals = xr.DataArray(costs, coords=[lambda_coords]) + + model.add_constraints(lambdas.sum() == 1, name="cost_convexity") + model.add_constraints(variable == (x_vals * lambdas).sum(), name="cost_x_def") + model.add_constraints(cost_var == (c_vals * lambdas).sum(), name="cost_def") + + return cost_var + +Mutually Exclusive Investments +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + def add_exclusive_investments(model, projects, costs, returns): + """Add mutually exclusive investment decisions using SOS1.""" + project_coords = pd.Index(projects, name="projects") + + invest = model.add_variables( + coords=[project_coords], name="invest", binary=True + ) + model.add_sos_constraints(invest, sos_type=1, sos_dim="projects") + + total_cost = (invest * costs).sum() + total_return = (invest * returns).sum() + + return invest, total_cost, total_return + + +See Also +-------- + +- :doc:`creating-variables`: Creating variables with coordinates +- :doc:`creating-constraints`: Adding regular constraints +- :doc:`user-guide`: General linopy usage patterns +- Example notebook: ``examples/sos-constraints-example.ipynb`` From 7367e184fcb53303f88302d6ccf308892e8af71d Mon Sep 17 00:00:00 2001 From: Jonas Hoersch Date: Wed, 10 Sep 2025 16:07:27 +0200 Subject: [PATCH 04/10] add sos information to variables representation --- linopy/variables.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/linopy/variables.py b/linopy/variables.py index b1fe486b..2ad76012 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -1244,6 +1244,10 @@ def __repr__(self) -> str: if ds.coords else "" ) + if (sos_type := ds.attrs.get("sos_type")) in (1, 2) and ( + sos_dim := ds.attrs.get("sos_dim") + ): + coords += f" - sos{sos_type} on {sos_dim}" r += f" * {name}{coords}\n" if not len(list(self)): r += "\n" From 9aa18325f3a6d9bce0f95455c3b73ab6e36ba706 Mon Sep 17 00:00:00 2001 From: Jonas Hoersch Date: Wed, 10 Sep 2025 16:07:48 +0200 Subject: [PATCH 05/10] add m.remove_sos_constraints --- linopy/model.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/linopy/model.py b/linopy/model.py index 59ae34c5..38cb09eb 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -816,6 +816,29 @@ def remove_constraints(self, name: str | list[str]) -> None: logger.debug(f"Removed constraint: {name}") self.constraints.remove(name) + def remove_sos_constraints(self, variable: Variable) -> None: + """ + Remove all sos constraints from a given variable. + + Parameters + ---------- + variable : Variable + Variable instance from which to remove all sos constraints. + Can be retrieved from `m.variables.sos`. + + Returns + ------- + None. + """ + sos_type = variable.attrs.get("sos_type") + sos_dim = variable.attrs.get("sos_dim") + + del variable.attrs["sos_type"], variable.attrs["sos_dim"] + + logger.debug( + f"Removed sos{sos_type} constraint on {sos_dim} from {variable.name}" + ) + def remove_objective(self) -> None: """ Remove the objective's linear expression from the model. From 8a7c817effc90b64b70a8cc9a8ed1ea5a7dfde1f Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 8 Oct 2025 08:54:54 +0200 Subject: [PATCH 06/10] add tests --- test/test_sos_constraints.py | 74 ++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 test/test_sos_constraints.py diff --git a/test/test_sos_constraints.py b/test/test_sos_constraints.py new file mode 100644 index 00000000..b4e4dc3f --- /dev/null +++ b/test/test_sos_constraints.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from linopy import Model, available_solvers + + +def test_add_sos_constraints_registers_variable() -> None: + m = Model() + locations = pd.Index([0, 1, 2], name="locations") + build = m.add_variables(coords=[locations], name="build") + + m.add_sos_constraints(build, sos_type=1, sos_dim="locations") + + assert build.attrs["sos_type"] == 1 + assert build.attrs["sos_dim"] == "locations" + assert list(m.variables.sos) == ["build"] + + m.remove_sos_constraints(build) + assert "sos_type" not in build.attrs + assert "sos_dim" not in build.attrs + + +def test_add_sos_constraints_validation() -> None: + m = Model() + strings = pd.Index(["a", "b"], name="strings") + with pytest.raises(ValueError, match="sos_type"): + m.add_sos_constraints(m.add_variables(name="x"), sos_type=3, sos_dim="i") + + variable = m.add_variables(coords=[strings], name="string_var") + + with pytest.raises(ValueError, match="dimension"): + m.add_sos_constraints(variable, sos_type=1, sos_dim="missing") + + with pytest.raises(ValueError, match="numeric"): + m.add_sos_constraints(variable, sos_type=1, sos_dim="strings") + + numeric = m.add_variables(coords=[pd.Index([0, 1], name="dim")], name="num") + m.add_sos_constraints(numeric, sos_type=1, sos_dim="dim") + with pytest.raises(ValueError, match="already has"): + m.add_sos_constraints(numeric, sos_type=1, sos_dim="dim") + + +def test_sos_constraints_written_to_lp(tmp_path) -> None: + m = Model() + breakpoints = pd.Index([0.0, 1.5, 3.5], name="bp") + lambdas = m.add_variables(coords=[breakpoints], name="lambda") + m.add_sos_constraints(lambdas, sos_type=2, sos_dim="bp") + + fn = tmp_path / "sos.lp" + m.to_file(fn, io_api="lp") + content = fn.read_text() + + assert "\nsos\n" in content + assert "S2 ::" in content + assert "3.5" in content + + +@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") +def test_to_gurobipy_emits_sos_constraints() -> None: + gurobipy = pytest.importorskip("gurobipy") + + m = Model() + segments = pd.Index([0.0, 0.5, 1.0], name="seg") + var = m.add_variables(coords=[segments], name="lambda") + m.add_sos_constraints(var, sos_type=1, sos_dim="seg") + + try: + model = m.to_gurobipy() + except gurobipy.GurobiError as exc: # pragma: no cover - depends on license setup + pytest.skip(f"Gurobi environment unavailable: {exc}") + + assert model.NumSOS == 1 From b0b1792a630a7fd6d8d2ac86a2df5c5d243e5670 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 2 Dec 2025 15:21:52 +0100 Subject: [PATCH 07/10] fix: type annotations and docs for SOS constraints - Add return type annotations to add_sos_constraints and add_sos - Fix iterate_slices call with list instead of tuple - Fix groupby for multi-dimensional SOS using stack/unstack - Add defensive check in remove_sos_constraints - Clarify direct API support (Gurobi only) in docs --- doc/sos-constraints.rst | 10 +++++++--- linopy/io.py | 23 +++++++++++++---------- linopy/model.py | 9 ++++++--- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/doc/sos-constraints.rst b/doc/sos-constraints.rst index aa9d1bd2..5dec21a4 100644 --- a/doc/sos-constraints.rst +++ b/doc/sos-constraints.rst @@ -235,7 +235,8 @@ Solver Compatibility SOS constraints are supported by most modern mixed-integer programming solvers through the LP file format: -**Supported solvers:** +**Supported solvers (via LP file):** + - HiGHS - Gurobi - CPLEX @@ -243,7 +244,11 @@ SOS constraints are supported by most modern mixed-integer programming solvers t - SCIP - Xpress -**Note:** Some solvers may have varying levels of SOS support. Check your solver's documentation for specific capabilities. +**Direct API support:** + +- Gurobi (via ``gurobipy``) + +**Note:** When using the direct API with other solvers (e.g., ``highspy``), SOS constraints are not currently supported. Use file-based export (LP format) instead. Common Patterns --------------- @@ -300,4 +305,3 @@ See Also - :doc:`creating-variables`: Creating variables with coordinates - :doc:`creating-constraints`: Adding regular constraints - :doc:`user-guide`: General linopy usage patterns -- Example notebook: ``examples/sos-constraints-example.ipynb`` diff --git a/linopy/io.py b/linopy/io.py index d9e0f2a8..95d8267a 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -336,7 +336,7 @@ def sos_to_file( explicit_coordinate_names: bool = False, ) -> None: """ - Write out integers of a model to a lp file. + Write out SOS constraints of a model to an LP file. """ names = m.variables.sos if not len(list(names)): @@ -359,7 +359,7 @@ def sos_to_file( sos_type = var.attrs["sos_type"] sos_dim = var.attrs["sos_dim"] - other_dims = tuple([dim for dim in var.labels.dims if dim != sos_dim]) + other_dims = [dim for dim in var.labels.dims if dim != sos_dim] for var_slice in var.iterate_slices(slice_size, other_dims): ds = var_slice.labels.to_dataset() ds["sos_labels"] = ds["labels"].isel({sos_dim: 0}) @@ -754,19 +754,22 @@ def to_gurobipy( if m.variables.sos: for var_name in m.variables.sos: var = m.variables.sos[var_name] - sos_type = var.attrs["sos_type"] - sos_dim = var.attrs["sos_dim"] + sos_type: int = var.attrs["sos_type"] # type: ignore[assignment] + sos_dim: str = var.attrs["sos_dim"] # type: ignore[assignment] - def add_sos(s): + def add_sos(s: xr.DataArray, sos_type: int, sos_dim: str) -> None: s = s.squeeze() - model.addSOS(sos_type, x[s].tolist(), s.coords[sos_dim].values) + indices = s.values.flatten().tolist() + weights = s.coords[sos_dim].values.tolist() + model.addSOS(sos_type, x[indices].tolist(), weights) - others = tuple(dim for dim in var.labels.dims if dim != sos_dim) + others = [dim for dim in var.labels.dims if dim != sos_dim] if not others: - add_sos(var.labels) + add_sos(var.labels, sos_type, sos_dim) else: - for _, s in var.labels.groupby(*others): - add_sos(s) + stacked = var.labels.stack(_sos_group=others) + for _, s in stacked.groupby("_sos_group"): + add_sos(s.unstack("_sos_group"), sos_type, sos_dim) model.update() return model diff --git a/linopy/model.py b/linopy/model.py index 0717ae2f..375b60ff 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -556,7 +556,7 @@ def add_sos_constraints( variable: Variable, sos_type: Literal[1, 2], sos_dim: str, - ): + ) -> None: """ Add an sos1 or sos2 constraint for one dimension of a variable @@ -830,8 +830,11 @@ def remove_sos_constraints(self, variable: Variable) -> None: ------- None. """ - sos_type = variable.attrs.get("sos_type") - sos_dim = variable.attrs.get("sos_dim") + if "sos_type" not in variable.attrs or "sos_dim" not in variable.attrs: + raise ValueError(f"Variable '{variable.name}' has no SOS constraints") + + sos_type = variable.attrs["sos_type"] + sos_dim = variable.attrs["sos_dim"] del variable.attrs["sos_type"], variable.attrs["sos_dim"] From 3b5a540b4a8f952232d1603e785d355caf7287cf Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 2 Dec 2025 15:38:49 +0100 Subject: [PATCH 08/10] add SOS solver validation and tests - Add highs to unsupported solvers (doesn't support SOS at all) - Add NotImplementedError for to_highspy and to_mosek with SOS - Add 5 new tests for SOS constraints and error handling - Update docs to reflect correct solver support - Add release notes entry --- doc/release_notes.rst | 1 + doc/sos-constraints.rst | 12 +++-- linopy/io.py | 8 ++++ linopy/model.py | 5 +++ test/test_sos_constraints.py | 86 ++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 5ca5ecc7..46b7fdd1 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -3,6 +3,7 @@ Release Notes .. Upcoming Version +* Add support for SOS1 and SOS2 (Special Ordered Sets) constraints via ``Model.add_sos_constraints()`` and ``Model.remove_sos_constraints()`` * 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/doc/sos-constraints.rst b/doc/sos-constraints.rst index 5dec21a4..37dd72d2 100644 --- a/doc/sos-constraints.rst +++ b/doc/sos-constraints.rst @@ -123,7 +123,7 @@ Example 1: Facility Location (SOS1) m.add_objective(-((net_benefit * build).sum())) # Solve - m.solve(solver_name="highs") + m.solve(solver_name="gurobi") if m.status == "ok": solution = build.solution.to_pandas() @@ -164,7 +164,7 @@ Example 2: Piecewise Linear Approximation (SOS2) m.add_objective(y) # Solve - m.solve(solver_name="highs") + m.solve(solver_name="gurobi") Working with Multi-dimensional Variables ----------------------------------------- @@ -237,7 +237,6 @@ SOS constraints are supported by most modern mixed-integer programming solvers t **Supported solvers (via LP file):** -- HiGHS - Gurobi - CPLEX - COIN-OR CBC @@ -248,7 +247,12 @@ SOS constraints are supported by most modern mixed-integer programming solvers t - Gurobi (via ``gurobipy``) -**Note:** When using the direct API with other solvers (e.g., ``highspy``), SOS constraints are not currently supported. Use file-based export (LP format) instead. +**Unsupported solvers:** + +- HiGHS (does not support SOS constraints) +- GLPK +- MOSEK +- MindOpt Common Patterns --------------- diff --git a/linopy/io.py b/linopy/io.py index 95d8267a..bc47f593 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -607,6 +607,8 @@ def to_mosek( ------- task : MOSEK Task object """ + if m.variables.sos: + raise NotImplementedError("SOS constraints are not supported by MOSEK.") import mosek @@ -792,6 +794,12 @@ def to_highspy(m: Model, explicit_coordinate_names: bool = False) -> Highs: ------- model : highspy.Highs """ + if m.variables.sos: + raise NotImplementedError( + "SOS constraints are not supported by the HiGHS direct API. " + "Use io_api='lp' instead." + ) + import highspy print_variable, print_constraint = get_printers_scalar( diff --git a/linopy/model.py b/linopy/model.py index 375b60ff..12462d5e 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1279,6 +1279,11 @@ def solve( f"Solver {solver_name} does not support quadratic problems." ) + # SOS constraints are not supported by all solvers + SOS_UNSUPPORTED_SOLVERS = {"glpk", "mosek", "mindopt", "highs"} + if self.variables.sos and solver_name in SOS_UNSUPPORTED_SOLVERS: + raise ValueError(f"Solver {solver_name} does not support SOS constraints.") + try: solver_class = getattr(solvers, f"{solvers.SolverName(solver_name).name}") # initialize the solver as object of solver subclass diff --git a/test/test_sos_constraints.py b/test/test_sos_constraints.py index b4e4dc3f..d7c62cdd 100644 --- a/test/test_sos_constraints.py +++ b/test/test_sos_constraints.py @@ -1,5 +1,6 @@ from __future__ import annotations +import numpy as np import pandas as pd import pytest @@ -72,3 +73,88 @@ def test_to_gurobipy_emits_sos_constraints() -> None: pytest.skip(f"Gurobi environment unavailable: {exc}") assert model.NumSOS == 1 + + +@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobi not installed") +def test_sos1_binary_maximize_lp_polars() -> None: + gurobipy = pytest.importorskip("gurobipy") + + m = Model() + locations = pd.Index([0, 1, 2], name="locations") + build = m.add_variables(coords=[locations], name="build", binary=True) + m.add_sos_constraints(build, sos_type=1, sos_dim="locations") + m.add_objective(build * [1, 2, 3], sense="max") + + try: + m.solve(solver_name="gurobi", io_api="lp-polars") + except gurobipy.GurobiError as exc: # pragma: no cover - depends on license setup + pytest.skip(f"Gurobi environment unavailable: {exc}") + + assert np.isclose(build.solution.values, [0, 0, 1]).all() + assert np.isclose(m.objective.value, 3) + + +@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobi not installed") +def test_sos2_binary_maximize_direct() -> None: + gurobipy = pytest.importorskip("gurobipy") + + m = Model() + locations = pd.Index([0, 1, 2], name="locations") + build = m.add_variables(coords=[locations], name="build", binary=True) + m.add_sos_constraints(build, sos_type=2, sos_dim="locations") + m.add_objective(build * [1, 2, 3], sense="max") + + try: + m.solve(solver_name="gurobi", io_api="direct") + except gurobipy.GurobiError as exc: # pragma: no cover - depends on license setup + pytest.skip(f"Gurobi environment unavailable: {exc}") + + assert np.isclose(build.solution.values, [0, 1, 1]).all() + assert np.isclose(m.objective.value, 5) + + +@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobi not installed") +def test_sos2_binary_maximize_different_coeffs() -> None: + gurobipy = pytest.importorskip("gurobipy") + + m = Model() + locations = pd.Index([0, 1, 2], name="locations") + build = m.add_variables(coords=[locations], name="build", binary=True) + m.add_sos_constraints(build, sos_type=2, sos_dim="locations") + m.add_objective(build * [2, 1, 3], sense="max") + + try: + m.solve(solver_name="gurobi", io_api="direct") + except gurobipy.GurobiError as exc: # pragma: no cover - depends on license setup + pytest.skip(f"Gurobi environment unavailable: {exc}") + + assert np.isclose(build.solution.values, [0, 1, 1]).all() + assert np.isclose(m.objective.value, 4) + + +def test_unsupported_solver_raises_error() -> None: + m = Model() + locations = pd.Index([0, 1, 2], name="locations") + build = m.add_variables(coords=[locations], name="build", binary=True) + m.add_sos_constraints(build, sos_type=1, sos_dim="locations") + m.add_objective(build * [1, 2, 3], sense="max") + + for solver in ["glpk", "mosek", "mindopt", "highs"]: + if solver in available_solvers: + with pytest.raises(ValueError, match="does not support SOS constraints"): + m.solve(solver_name=solver) + + +def test_to_highspy_raises_not_implemented() -> None: + pytest.importorskip("highspy") + + m = Model() + locations = pd.Index([0, 1, 2], name="locations") + build = m.add_variables(coords=[locations], name="build", binary=True) + m.add_sos_constraints(build, sos_type=1, sos_dim="locations") + + with pytest.raises( + NotImplementedError, + match="SOS constraints are not supported by the HiGHS direct API", + ): + m.to_highspy() From 3f0df2b6a54d738125c2e891acc666317d8c1643 Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 8 Dec 2025 17:54:38 +0100 Subject: [PATCH 09/10] update SOS constraints to use solver support matrix - Add SOS_CONSTRAINTS feature to SolverFeature enum - Update Gurobi and CPLEX solver entries to support SOS - Replace hardcoded SOS_UNSUPPORTED_SOLVERS with solver_supports call - Integrate with newly introduced solver capabilities system from PR 528 --- .../__pycache__/benchmark-linopy.cpython-310.pyc | Bin 742 -> 0 bytes .../__pycache__/benchmark-pyomo.cpython-310.pyc | Bin 777 -> 0 bytes .../benchmark-pypsa-linopf.cpython-310.pyc | Bin 764 -> 0 bytes .../__pycache__/common.cpython-39.pyc | Bin 380 -> 0 bytes .../__pycache__/plot-benchmarks.cpython-310.pyc | Bin 909 -> 0 bytes linopy/model.py | 5 +++-- linopy/solver_capabilities.py | 5 +++++ 7 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 benchmark/scripts/leftovers/benchmarks-pypsa-eur/__pycache__/benchmark-linopy.cpython-310.pyc delete mode 100644 benchmark/scripts/leftovers/benchmarks-pypsa-eur/__pycache__/benchmark-pyomo.cpython-310.pyc delete mode 100644 benchmark/scripts/leftovers/benchmarks-pypsa-eur/__pycache__/benchmark-pypsa-linopf.cpython-310.pyc delete mode 100644 benchmark/scripts/leftovers/benchmarks-pypsa-eur/__pycache__/common.cpython-39.pyc delete mode 100644 benchmark/scripts/leftovers/benchmarks-pypsa-eur/__pycache__/plot-benchmarks.cpython-310.pyc diff --git a/benchmark/scripts/leftovers/benchmarks-pypsa-eur/__pycache__/benchmark-linopy.cpython-310.pyc b/benchmark/scripts/leftovers/benchmarks-pypsa-eur/__pycache__/benchmark-linopy.cpython-310.pyc deleted file mode 100644 index 3e7f137d09980ce0465199259c14642cce3a1158..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 742 zcmYjP&2AGh5VrR}PNEjXp&XF*nnSv6q!uYcs8L1L18k#i)Wcp@n_Y)&y0%xgQ%D7; z!V#VUBrY6y3f{t3xbO;`uoF-iX}<9{yW^S9HtTi~(Dv=;qv>}Dzz;+IH(Qev?IPAt zf)tdnv}%}I>u9I8j&|)3H+6}Zdc;qC5~Kk%|2cJwpcFxXQR$Z6J8%cnHg4Ym{nbi9 z&%b?)PC3pcE<;Ac$r6WWcp4rag@?z(!NKt8FdQ5V2Kxw|GF1X_Zs-OAiC$ zo}*Bl#G`R?elbauGagOOm6u#Rzj}75x|^7dN0*})Ne?Q&;cQmbxG=T%H|*ud_gNR63ngWdH6l;_7|U0TUvqQJ3cbV>^IS46(pHmEMlzDW zR-T@^rn$0cqwI>#(w3lkBj!vBZkf7(HL@brE#^iFcQxqdkn)zH27X^fb4GBqIlkzI zV-Yp0sIDl}VTx%nCpo{4M8T^@ifu;p*Ms))l5dm!P2GO8Qtn#Vj&30W4aj-`cj2DJ z?`zWQC_l?eR%DrS>5?=n-qmSt9-Q(DMhK&80c-W!+0_oggz;7O_rf^$Oqtnot7x;j fXSNES5LPa0{M1~g*wx@zffv9%*nyUfAPW8fksY~z diff --git a/benchmark/scripts/leftovers/benchmarks-pypsa-eur/__pycache__/benchmark-pyomo.cpython-310.pyc b/benchmark/scripts/leftovers/benchmarks-pypsa-eur/__pycache__/benchmark-pyomo.cpython-310.pyc deleted file mode 100644 index 79f35be324a541c888fc9baef3d359517e9b1456..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 777 zcmYjPO>fgM7`C0yx-AbDqI6S zq#@Igegop6p?_ZYXPlD$_2=!M3nKgVs$Z3in=&OVo6%G(`Z5z`t>kS-ZZ#se$Wq)k zO^;K)U9Ys)I9f9|$c_Pw9>8_zA|K+vV$;g#=padQo+XL)*pk*Oao@CsU9A0#Q_5MR zzcJq^Z#?KVN+=hrYx15VgIZaT3}?1?7OW*Bsikky@k*p@xd82S}?X;;=$mZL$v8bp0XQDFo3| z;n@EGM-KcUOPt`sU*Lqj4QfZ4H=bvYXP$R?yN!XhAHScBe?kC$x#YfiHaWDe1{MmC zf&vz(f+?z_mwGmOl~02-pkW%)D2-^G#?YPfs|Lj>hJfHAD8kR+8m3Lsyasj`4M8XR z_!J*Wl50{VoF$_LNsh@l+1*Qa_YZnI2Yb6oZ>QJW#`rK_=oyy>$uu9AIm3VKFksN} z4IT^!{j=f8>1b&Dv;OGBgu~NUZ(g38_HE40`se-E!wxi&kbGKJWa4W78?uYQ`jK_@ z!h!~0p+?`}75D_^-rS!DI-EyN> zkqHS^f};#&;Jv$ddbhkg?7Ce8FfNbN514O1<}cb{uA$Wl2r*)Vn6XK$*di@rlQz(K zvEvpwhzzgue1;aHpy&~&40E)SRH3qgDi!Y2S|U!XCn1*#(VS+9N>0xQJllK$v@@@z zNZAHrjEw~{->%2*P&qIr{hfoN)nPq?Bii0?Q>* onPg?9_8ZmTznF=l5PVQJx^qzoQs?y2gaFk8u}z?5nLBR30j=S5r~m)} diff --git a/benchmark/scripts/leftovers/benchmarks-pypsa-eur/__pycache__/plot-benchmarks.cpython-310.pyc b/benchmark/scripts/leftovers/benchmarks-pypsa-eur/__pycache__/plot-benchmarks.cpython-310.pyc deleted file mode 100644 index cf90eabee23c7a207ed369464036987900c6638f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 909 zcmYjP%Wl&^6rCA6wri3$rKK$`1uweEqNJ&WiYkP735f*=iFRYCtV!-o;?yrZPHD1b z-G4~gvEVQGirK=Zzrcz+DFVitb6+#}amIP0;WJv_er|Wa3&wsJ}QLMJwpdRIs3Rp8E&BmE_fFrY@#(nGk zTAm*G&r6gQLXc;{(HMi5*bO=lg3iHVr*pX13HJB)_wW1u6FIJid3hN0WjB_Ye?=6r zH&>kS7Y|yN_S9)yl=&y6ogqrVQd#_m??sVbPGwajd6mT7`^Cg)+W8}`A~A-{Kq@T?(5^z+&C85t z$&|LBPcV%Oy`VCAiYaYfi?d=}Y5No>6qH4*?Lt;V?GddI_0*Xb(ARD+PsVAc=7Y>f z(vB<&%s@N6JnPAd2po4!H%DRdk-nE+%@ePpGyvS=827R_+=44E#E75Azh;*ALh}D^azJNt-I bool: SolverFeature.LP_FILE_NAMES, SolverFeature.SOLUTION_FILE_NOT_NEEDED, SolverFeature.IIS_COMPUTATION, + SolverFeature.SOS_CONSTRAINTS, SolverFeature.SOLVER_ATTRIBUTE_ACCESS, } ), @@ -96,6 +100,7 @@ def supports(self, feature: SolverFeature) -> bool: { SolverFeature.QUADRATIC_OBJECTIVE, SolverFeature.LP_FILE_NAMES, + SolverFeature.SOS_CONSTRAINTS, } ), ), From cf74caf4d27c60c5056d5f046156233a57439095 Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 8 Dec 2025 18:23:49 +0100 Subject: [PATCH 10/10] fix: resolve mypy errors in SOS constraints tests - Add type ignore for intentional invalid sos_type test case - Add proper Path type annotation for tmp_path parameter - Add null checks before np.isclose calls for objective values - Replace list literals with np.array for arithmetic operations --- test/test_sos_constraints.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/test_sos_constraints.py b/test/test_sos_constraints.py index d7c62cdd..5d94162e 100644 --- a/test/test_sos_constraints.py +++ b/test/test_sos_constraints.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + import numpy as np import pandas as pd import pytest @@ -27,7 +29,7 @@ def test_add_sos_constraints_validation() -> None: m = Model() strings = pd.Index(["a", "b"], name="strings") with pytest.raises(ValueError, match="sos_type"): - m.add_sos_constraints(m.add_variables(name="x"), sos_type=3, sos_dim="i") + m.add_sos_constraints(m.add_variables(name="x"), sos_type=3, sos_dim="i") # type: ignore[arg-type] variable = m.add_variables(coords=[strings], name="string_var") @@ -43,7 +45,7 @@ def test_add_sos_constraints_validation() -> None: m.add_sos_constraints(numeric, sos_type=1, sos_dim="dim") -def test_sos_constraints_written_to_lp(tmp_path) -> None: +def test_sos_constraints_written_to_lp(tmp_path: Path) -> None: m = Model() breakpoints = pd.Index([0.0, 1.5, 3.5], name="bp") lambdas = m.add_variables(coords=[breakpoints], name="lambda") @@ -83,7 +85,7 @@ def test_sos1_binary_maximize_lp_polars() -> None: locations = pd.Index([0, 1, 2], name="locations") build = m.add_variables(coords=[locations], name="build", binary=True) m.add_sos_constraints(build, sos_type=1, sos_dim="locations") - m.add_objective(build * [1, 2, 3], sense="max") + m.add_objective(build * np.array([1, 2, 3]), sense="max") try: m.solve(solver_name="gurobi", io_api="lp-polars") @@ -91,6 +93,7 @@ def test_sos1_binary_maximize_lp_polars() -> None: pytest.skip(f"Gurobi environment unavailable: {exc}") assert np.isclose(build.solution.values, [0, 0, 1]).all() + assert m.objective.value is not None assert np.isclose(m.objective.value, 3) @@ -102,7 +105,7 @@ def test_sos2_binary_maximize_direct() -> None: locations = pd.Index([0, 1, 2], name="locations") build = m.add_variables(coords=[locations], name="build", binary=True) m.add_sos_constraints(build, sos_type=2, sos_dim="locations") - m.add_objective(build * [1, 2, 3], sense="max") + m.add_objective(build * np.array([1, 2, 3]), sense="max") try: m.solve(solver_name="gurobi", io_api="direct") @@ -110,6 +113,7 @@ def test_sos2_binary_maximize_direct() -> None: pytest.skip(f"Gurobi environment unavailable: {exc}") assert np.isclose(build.solution.values, [0, 1, 1]).all() + assert m.objective.value is not None assert np.isclose(m.objective.value, 5) @@ -121,7 +125,7 @@ def test_sos2_binary_maximize_different_coeffs() -> None: locations = pd.Index([0, 1, 2], name="locations") build = m.add_variables(coords=[locations], name="build", binary=True) m.add_sos_constraints(build, sos_type=2, sos_dim="locations") - m.add_objective(build * [2, 1, 3], sense="max") + m.add_objective(build * np.array([2, 1, 3]), sense="max") try: m.solve(solver_name="gurobi", io_api="direct") @@ -129,6 +133,7 @@ def test_sos2_binary_maximize_different_coeffs() -> None: pytest.skip(f"Gurobi environment unavailable: {exc}") assert np.isclose(build.solution.values, [0, 1, 1]).all() + assert m.objective.value is not None assert np.isclose(m.objective.value, 4) @@ -137,7 +142,7 @@ def test_unsupported_solver_raises_error() -> None: locations = pd.Index([0, 1, 2], name="locations") build = m.add_variables(coords=[locations], name="build", binary=True) m.add_sos_constraints(build, sos_type=1, sos_dim="locations") - m.add_objective(build * [1, 2, 3], sense="max") + m.add_objective(build * np.array([1, 2, 3]), sense="max") for solver in ["glpk", "mosek", "mindopt", "highs"]: if solver in available_solvers: