From 503e9e834cde68d716af359342fc0381180505f9 Mon Sep 17 00:00:00 2001 From: lisa Date: Tue, 15 Sep 2026 17:27:07 +0200 Subject: [PATCH 1/6] feat: add highs to possible solvers for IIS --- linopy/model.py | 66 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/linopy/model.py b/linopy/model.py index 614adf38..fbb29b75 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -2348,9 +2348,9 @@ def compute_infeasibilities(self) -> list[int]: """ Compute a set of infeasible constraints. - 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. + This function requires that the model was solved with `gurobi`, `xpress` + or `highs` and the termination condition was infeasible. The solver must + have detected the infeasibility during the solve process. Returns ------- @@ -2383,6 +2383,18 @@ def compute_infeasibilities(self) -> list[int]: except ImportError: pass + # Check for HiGHS + if "highs" in available_solvers: + try: + import highspy + + if solver_model is not None and isinstance( + solver_model, highspy.Highs + ): + return self._compute_infeasibilities_highs(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 @@ -2398,12 +2410,12 @@ def compute_infeasibilities(self) -> list[int]: # 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." + "Only Gurobi, Xpress and HiGHS 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. " + "Computing infeasibilities is only supported for Gurobi, Xpress and HiGHS solvers. " f"Current solver model type: {type(solver_model).__name__}" ) @@ -2529,12 +2541,48 @@ def _extract_iis_constraints(self, solver_model: Any, iis_num: int) -> list[Any] return miisrow + def _compute_infeasibilities_highs(self, solver_model: Any) -> list[int]: + """Compute infeasibilities for the HiGHS solver.""" + if not hasattr(solver_model, "getIis"): + raise NotImplementedError( + "Computing infeasibilities requires a `highspy` version that " + "supports `Highs.getIis` (HiGHS IIS computation). " + "Please upgrade the `highspy` package." + ) + + import highspy + + solver_model.setOptionValue( + "iis_strategy", highspy.IisStrategy.kIisStrategyIrreducible + ) + status, iis = solver_model.getIis() + if status == highspy.HighsStatus.kError or not iis.valid_: + raise RuntimeError( + "HiGHS failed to compute an irreducible infeasible subsystem (IIS)." + ) + + row_index = np.asarray(iis.row_index_, dtype=np.intp) + if not len(row_index): + return [] + + solver = self.solver + assert solver is not None + if solver.io_api == "direct": + clabels = self.constraints.label_index.clabels + else: + from linopy.solvers import _names_to_labels + + clabels = _names_to_labels(solver_model.getLp().row_names_) + + labels = {int(clabels[pos]) for pos in row_index if clabels[pos] >= 0} + return sorted(labels) + def format_infeasibilities(self, display_max_terms: int | None = None) -> str: """ Return a string representation of infeasible constraints. - This function requires that the model was solved using `gurobi` or `xpress` - and the termination condition was infeasible. + This function requires that the model was solved using `gurobi`, `xpress` + or `highs` and the termination condition was infeasible. Parameters ---------- @@ -2573,8 +2621,8 @@ def compute_set_of_infeasible_constraints(self) -> Dataset: """ Compute a set of infeasible constraints. - This function requires that the model was solved with `gurobi` or `xpress` and the - termination condition was infeasible. + This function requires that the model was solved with `gurobi`, `xpress` or `highs` + and the termination condition was infeasible. Returns ------- From ff2fc0772f0b959d5ddbf329a2e8d283cabc50b7 Mon Sep 17 00:00:00 2001 From: lisa Date: Tue, 15 Sep 2026 17:27:43 +0200 Subject: [PATCH 2/6] feat: add solverfeature IIS for Highs --- linopy/solvers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/linopy/solvers.py b/linopy/solvers.py index 0393dc61..c1641649 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1575,6 +1575,7 @@ class Highs(Solver[None]): SolverFeature.SOLUTION_FILE_NOT_NEEDED, SolverFeature.SEMI_CONTINUOUS_VARIABLES, SolverFeature.MIP_DUAL_BOUND_REPORT, + SolverFeature.IIS_COMPUTATION, } ) supports_persistent_update: ClassVar[bool] = True From a8459645728ea7e54a97659f7022ba32b7f69efd Mon Sep 17 00:00:00 2001 From: lisa Date: Tue, 15 Sep 2026 17:28:00 +0200 Subject: [PATCH 3/6] feat: add highs to the tests --- test/test_infeasibility.py | 14 +++++++------- test/test_optimization.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/test_infeasibility.py b/test/test_infeasibility.py index df6b6273..2ba20d33 100644 --- a/test/test_infeasibility.py +++ b/test/test_infeasibility.py @@ -75,7 +75,7 @@ def multi_dimensional_infeasible_model(self) -> Model: return m - @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + @pytest.mark.parametrize("solver", ["gurobi", "xpress", "highs"]) def test_simple_infeasibility_detection( self, simple_infeasible_model: Model, solver: str ) -> None: @@ -98,7 +98,7 @@ def test_simple_infeasibility_detection( assert isinstance(formatted, str) assert formatted - @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + @pytest.mark.parametrize("solver", ["gurobi", "xpress", "highs"]) def test_complex_infeasibility_detection( self, complex_infeasible_model: Model, solver: str ) -> None: @@ -121,7 +121,7 @@ def test_complex_infeasibility_detection( # We expect at least 2 constraints to be involved assert len(labels) >= 2 - @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + @pytest.mark.parametrize("solver", ["gurobi", "xpress", "highs"]) def test_multi_dimensional_infeasibility( self, multi_dimensional_infeasible_model: Model, solver: str ) -> None: @@ -152,7 +152,7 @@ def test_unsolved_model_error(self) -> None: ): m.compute_infeasibilities() - @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + @pytest.mark.parametrize("solver", ["gurobi", "xpress", "highs"]) 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: @@ -173,7 +173,7 @@ def test_no_solver_model_error(self, solver: str) -> None: with pytest.raises(ValueError, match="No solver model available"): m.compute_infeasibilities() - @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + @pytest.mark.parametrize("solver", ["gurobi", "xpress", "highs"]) def test_feasible_model_iis(self, solver: str) -> None: """Test IIS computation on a feasible model.""" if solver not in available_solvers: @@ -220,7 +220,7 @@ def test_unsupported_solver_error(self) -> None: with pytest.raises(NotImplementedError): m.compute_infeasibilities() - @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + @pytest.mark.parametrize("solver", ["gurobi", "xpress", "highs"]) def test_deprecated_method( self, simple_infeasible_model: Model, solver: str ) -> None: @@ -246,7 +246,7 @@ def test_deprecated_method( # Check that it contains constraint labels assert len(subset) > 0 - @pytest.mark.parametrize("solver", ["gurobi", "xpress"]) + @pytest.mark.parametrize("solver", ["gurobi", "xpress", "highs"]) def test_masked_constraint_infeasibility( self, solver: str, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/test/test_optimization.py b/test/test_optimization.py index 76c240e1..79a80db8 100644 --- a/test/test_optimization.py +++ b/test/test_optimization.py @@ -698,7 +698,7 @@ def test_infeasible_model( assert status == "warning" assert "infeasible" in condition - if solver in ["gurobi", "xpress"]: + if solver in ["gurobi", "xpress", "highs"]: # ignore deprecated warning with pytest.warns(DeprecationWarning): model.compute_set_of_infeasible_constraints() From 33f970870da2c9784e7b79a836716af1cc4dd897 Mon Sep 17 00:00:00 2001 From: lisa Date: Tue, 15 Sep 2026 17:28:17 +0200 Subject: [PATCH 4/6] docs: add release note --- doc/release_notes.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index dae3a6dd..29578c0a 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -271,6 +271,7 @@ Version 0.7.0 * OETC: ``Model.solve()`` forwards solver options to the handler; ``OetcSettings.from_env()`` reads ``OETC_*``. * SCIP supports quadratic problems on Windows. +* ``Model.compute_infeasibilities`` now also supports HiGHS, via ``Highs.getIis``. **Performance** From 22e0db83ae9ea5d24121725710923b88d3a042d2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:33:00 +0000 Subject: [PATCH 5/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- linopy/model.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/linopy/model.py b/linopy/model.py index fbb29b75..93deb6b6 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -2388,9 +2388,7 @@ def compute_infeasibilities(self) -> list[int]: try: import highspy - if solver_model is not None and isinstance( - solver_model, highspy.Highs - ): + if solver_model is not None and isinstance(solver_model, highspy.Highs): return self._compute_infeasibilities_highs(solver_model) except ImportError: pass From 2c22b9ad71f0b6cf10eec4950033d19ed18203d0 Mon Sep 17 00:00:00 2001 From: lisa Date: Tue, 15 Sep 2026 17:46:08 +0200 Subject: [PATCH 6/6] fix: make IIS strategy possible as user input --- linopy/model.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/linopy/model.py b/linopy/model.py index 93deb6b6..50aa23c8 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -2550,9 +2550,14 @@ def _compute_infeasibilities_highs(self, solver_model: Any) -> list[int]: import highspy - solver_model.setOptionValue( - "iis_strategy", highspy.IisStrategy.kIisStrategyIrreducible - ) + solver = self.solver + assert solver is not None + if "iis_strategy" not in solver.solver_options: + solver_model.setOptionValue( + "iis_strategy", + int(highspy.IisStrategy.kIisStrategyFromLp) + | int(highspy.IisStrategy.kIisStrategyIrreducible), + ) status, iis = solver_model.getIis() if status == highspy.HighsStatus.kError or not iis.valid_: raise RuntimeError( @@ -2563,8 +2568,6 @@ def _compute_infeasibilities_highs(self, solver_model: Any) -> list[int]: if not len(row_index): return [] - solver = self.solver - assert solver is not None if solver.io_api == "direct": clabels = self.constraints.label_index.clabels else: