diff --git a/linopy/model.py b/linopy/model.py index 01b3027b..c8024843 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1283,24 +1283,65 @@ def compute_infeasibilities(self) -> list[int]: """ Compute a set of infeasible constraints. - This function requires that the model was solved with `gurobi` and the - termination condition was infeasible. + This function requires that the model was solved with `gurobi` or `xpress` + and the termination condition was infeasible. The solver must have detected + the infeasibility during the solve process. Returns ------- labels : list[int] Labels of the infeasible constraints. """ - if "gurobi" not in available_solvers: - raise ImportError("Gurobi is required for this method.") + solver_model = getattr(self, "solver_model", None) - import gurobipy + # Check for Gurobi + if "gurobi" in available_solvers: + try: + import gurobipy - solver_model = getattr(self, "solver_model") + if solver_model is not None and isinstance( + solver_model, gurobipy.Model + ): + return self._compute_infeasibilities_gurobi(solver_model) + except ImportError: + pass - if not isinstance(solver_model, gurobipy.Model): - raise NotImplementedError("Solver model must be a Gurobi Model.") + # Check for Xpress + if "xpress" in available_solvers: + try: + import xpress + + if solver_model is not None and isinstance( + solver_model, xpress.problem + ): + return self._compute_infeasibilities_xpress(solver_model) + except ImportError: + pass + + # If we get here, either the solver doesn't support IIS or no solver model is available + if solver_model is None: + # Check if this is a supported solver without a stored model + solver_name = getattr(self, "solver_name", "unknown") + if solver_name in ["gurobi", "xpress"]: + raise ValueError( + "No solver model available. The model must be solved first with " + "'gurobi' or 'xpress' solver and the result must be infeasible." + ) + else: + # This is an unsupported solver + raise NotImplementedError( + f"Computing infeasibilities is not supported for '{solver_name}' solver. " + "Only Gurobi and Xpress solvers support IIS computation." + ) + else: + # We have a solver model but it's not a supported type + raise NotImplementedError( + "Computing infeasibilities is only supported for Gurobi and Xpress solvers. " + f"Current solver model type: {type(solver_model).__name__}" + ) + def _compute_infeasibilities_gurobi(self, solver_model: Any) -> list[int]: + """Compute infeasibilities for Gurobi solver.""" solver_model.computeIIS() f = NamedTemporaryFile(suffix=".ilp", prefix="linopy-iis-", delete=False) solver_model.write(f.name) @@ -1315,13 +1356,86 @@ def compute_infeasibilities(self) -> list[int]: match = pattern.match(line_decoded) if match: labels.append(int(match.group(1))) + f.close() return labels + def _compute_infeasibilities_xpress(self, solver_model: Any) -> list[int]: + """Compute infeasibilities for Xpress solver.""" + # Compute all IIS + solver_model.iisall() + + # Get the number of IIS found + num_iis = solver_model.attributes.numiis + if num_iis == 0: + return [] + + labels = set() + + # Create constraint mapping for efficient lookups + constraint_to_index = { + constraint: idx + for idx, constraint in enumerate(solver_model.getConstraint()) + } + + # Retrieve each IIS + for iis_num in range(1, num_iis + 1): + iis_constraints = self._extract_iis_constraints(solver_model, iis_num) + + # Convert constraint objects to indices + for constraint_obj in iis_constraints: + if constraint_obj in constraint_to_index: + labels.add(constraint_to_index[constraint_obj]) + # Note: Silently skip constraints not found in mapping + # This can happen if the model structure changed after solving + + return sorted(list(labels)) + + def _extract_iis_constraints(self, solver_model: Any, iis_num: int) -> list[Any]: + """ + Extract constraint objects from a specific IIS. + + Parameters + ---------- + solver_model : xpress.problem + The Xpress solver model + iis_num : int + IIS number (1-indexed) + + Returns + ------- + list[Any] + List of xpress.constraint objects in the IIS + """ + # Prepare lists to receive IIS data + miisrow: list[Any] = [] # xpress.constraint objects in the IIS + miiscol: list[Any] = [] # xpress.variable objects in the IIS + constrainttype: list[str] = [] # Constraint types ('L', 'G', 'E') + colbndtype: list[str] = [] # Column bound types + duals: list[float] = [] # Dual values + rdcs: list[float] = [] # Reduced costs + isolationrows: list[str] = [] # Row isolation info + isolationcols: list[str] = [] # Column isolation info + + # Get IIS data from Xpress + solver_model.getiisdata( + iis_num, + miisrow, + miiscol, + constrainttype, + colbndtype, + duals, + rdcs, + isolationrows, + isolationcols, + ) + + return miisrow + def print_infeasibilities(self, display_max_terms: int | None = None) -> None: """ Print a list of infeasible constraints. - This function requires that the model was solved using `gurobi` + This function requires that the model was solved using `gurobi` or `xpress` and the termination condition was infeasible. Parameters @@ -1346,7 +1460,7 @@ def compute_set_of_infeasible_constraints(self) -> Dataset: """ Compute a set of infeasible constraints. - This function requires that the model was solved with `gurobi` and the + This function requires that the model was solved with `gurobi` or `xpress` and the termination condition was infeasible. Returns diff --git a/test/test_infeasibility.py b/test/test_infeasibility.py new file mode 100644 index 00000000..01994789 --- /dev/null +++ b/test/test_infeasibility.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Test infeasibility detection for different solvers. +""" + +import pandas as pd +import pytest + +from linopy import Model, available_solvers + + +class TestInfeasibility: + """Test class for infeasibility detection functionality.""" + + @pytest.fixture + def simple_infeasible_model(self) -> Model: + """Create a simple infeasible model.""" + m = Model() + + time = pd.RangeIndex(10, name="time") + x = m.add_variables(lower=0, coords=[time], name="x") + y = m.add_variables(lower=0, coords=[time], name="y") + + # Create infeasible constraints + m.add_constraints(x <= 5, name="con_x_upper") + m.add_constraints(y <= 5, name="con_y_upper") + m.add_constraints(x + y >= 12, name="con_sum_lower") + + # Add objective to avoid multi-objective issue with xpress + m.add_objective(x.sum() + y.sum()) + + return m + + @pytest.fixture + def complex_infeasible_model(self) -> Model: + """Create a more complex infeasible model.""" + m = Model() + + # Create variables + x = m.add_variables(lower=0, upper=10, name="x") + y = m.add_variables(lower=0, upper=10, name="y") + z = m.add_variables(lower=0, upper=10, name="z") + + # Add conflicting constraints + m.add_constraints(x + y >= 15, name="con1") + m.add_constraints(x <= 5, name="con2") + m.add_constraints(y <= 5, name="con3") + m.add_constraints(z >= x + y, name="con4") + m.add_constraints(z <= 8, name="con5") + + # Add objective + m.add_objective(x + y + z) + + return m + + @pytest.fixture + def multi_dimensional_infeasible_model(self) -> Model: + """Create a multi-dimensional infeasible model.""" + m = Model() + + # Create multi-dimensional variables + i = pd.RangeIndex(5, name="i") + j = pd.RangeIndex(3, name="j") + + x = m.add_variables(lower=0, upper=1, coords=[i, j], name="x") + + # Add constraints that make it infeasible + m.add_constraints(x.sum("j") >= 2.5, name="row_sum") # Each row sum >= 2.5 + m.add_constraints(x.sum("i") <= 1, name="col_sum") # Each column sum <= 1 + + # Add objective + m.add_objective(x.sum()) + + return m + + @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + def test_simple_infeasibility_detection( + self, simple_infeasible_model: Model, solver: str + ) -> None: + """Test basic infeasibility detection.""" + if solver not in available_solvers: + pytest.skip(f"{solver} not available") + + m = simple_infeasible_model + status, condition = m.solve(solver_name=solver) + + assert status == "warning" + assert "infeasible" in condition + + # Test compute_infeasibilities + labels = m.compute_infeasibilities() + assert isinstance(labels, list) + assert len(labels) > 0 # Should find at least one infeasible constraint + + # Test print_infeasibilities (just check it doesn't raise an error) + m.print_infeasibilities() + + @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + def test_complex_infeasibility_detection( + self, complex_infeasible_model: Model, solver: str + ) -> None: + """Test infeasibility detection on more complex model.""" + if solver not in available_solvers: + pytest.skip(f"{solver} not available") + + m = complex_infeasible_model + status, condition = m.solve(solver_name=solver) + + assert status == "warning" + assert "infeasible" in condition + + labels = m.compute_infeasibilities() + assert isinstance(labels, list) + assert len(labels) > 0 + + # The infeasible set should include constraints that conflict + # Different solvers might find different minimal IIS + # We expect at least 2 constraints to be involved + assert len(labels) >= 2 + + @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + def test_multi_dimensional_infeasibility( + self, multi_dimensional_infeasible_model: Model, solver: str + ) -> None: + """Test infeasibility detection on multi-dimensional model.""" + if solver not in available_solvers: + pytest.skip(f"{solver} not available") + + m = multi_dimensional_infeasible_model + status, condition = m.solve(solver_name=solver) + + assert status == "warning" + assert "infeasible" in condition + + labels = m.compute_infeasibilities() + assert isinstance(labels, list) + assert len(labels) > 0 + + def test_unsolved_model_error(self) -> None: + """Test error when model hasn't been solved yet.""" + m = Model() + x = m.add_variables(name="x") + m.add_constraints(x >= 0) + m.add_objective(1 * x) # Convert to LinearExpression + + # Don't solve the model - should raise NotImplementedError for unsolved models + with pytest.raises( + NotImplementedError, match="Computing infeasibilities is not supported" + ): + m.compute_infeasibilities() + + @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + def test_no_solver_model_error(self, solver: str) -> None: + """Test error when solver model is not available after solving.""" + if solver not in available_solvers: + pytest.skip(f"{solver} not available") + + m = Model() + x = m.add_variables(name="x") + m.add_constraints(x >= 0) + m.add_objective(1 * x) # Convert to LinearExpression + + # Solve the model first + m.solve(solver_name=solver) + + # Manually remove the solver_model to simulate cleanup + m.solver_model = None + m.solver_name = solver # But keep the solver name + + # Should raise ValueError since we know it was solved with supported solver + with pytest.raises(ValueError, match="No solver model available"): + m.compute_infeasibilities() + + @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + def test_feasible_model_iis(self, solver: str) -> None: + """Test IIS computation on a feasible model.""" + if solver not in available_solvers: + pytest.skip(f"{solver} not available") + + m = Model() + x = m.add_variables(lower=0, name="x") + y = m.add_variables(lower=0, name="y") + + m.add_constraints(x + y >= 1) + m.add_constraints(x <= 10) + m.add_constraints(y <= 10) + + m.add_objective(x + y) + + status, condition = m.solve(solver_name=solver) + assert status == "ok" + assert condition == "optimal" + + # Calling compute_infeasibilities on a feasible model + # Different solvers might handle this differently + # Gurobi might raise an error, Xpress might return empty list + try: + labels = m.compute_infeasibilities() + # If it doesn't raise an error, it should return empty list + assert labels == [] + except Exception: + # Some solvers might raise an error when computing IIS on feasible model + pass + + def test_unsupported_solver_error(self) -> None: + """Test error for unsupported solvers.""" + m = Model() + x = m.add_variables(name="x") + m.add_constraints(x >= 0) + m.add_constraints(x <= -1) # Make it infeasible + + # Use a solver that doesn't support IIS + if "cbc" in available_solvers: + status, condition = m.solve(solver_name="cbc") + assert "infeasible" in condition + + with pytest.raises(NotImplementedError): + m.compute_infeasibilities() + + @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + def test_deprecated_method( + self, simple_infeasible_model: Model, solver: str + ) -> None: + """Test that deprecated method still works.""" + if solver not in available_solvers: + pytest.skip(f"{solver} not available") + + m = simple_infeasible_model + status, condition = m.solve(solver_name=solver) + + assert status == "warning" + assert "infeasible" in condition + + # Test deprecated method + with pytest.warns(DeprecationWarning): + subset = m.compute_set_of_infeasible_constraints() + + # Check that it returns a Dataset + from xarray import Dataset + + assert isinstance(subset, Dataset) + + # Check that it contains constraint labels + assert len(subset) > 0 diff --git a/test/test_optimization.py b/test/test_optimization.py index 6af3f89d..da1050fc 100644 --- a/test/test_optimization.py +++ b/test/test_optimization.py @@ -589,7 +589,7 @@ def test_infeasible_model( assert status == "warning" assert "infeasible" in condition - if solver == "gurobi": + if solver in ["gurobi", "xpress"]: # ignore deprecated warning with pytest.warns(DeprecationWarning): model.compute_set_of_infeasible_constraints()