Skip to content

Commit 751760d

Browse files
FabianHofmannclaude
andcommitted
feat: extend IIS calculation to support Xpress solver
- Add support for Xpress solver IIS computation alongside existing Gurobi support - Implement _compute_infeasibilities_xpress() method using xpress.iisall() API - Update compute_infeasibilities() to auto-detect solver type and route appropriately - Extend test coverage to include both Gurobi and Xpress in infeasibility tests - Add comprehensive test suite in test_infeasibility.py with various scenarios - Update documentation to reflect dual solver support in docstrings - Maintain backward compatibility with existing Gurobi-only code 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent cd55e1a commit 751760d

File tree

3 files changed

+306
-11
lines changed

3 files changed

+306
-11
lines changed

linopy/model.py

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,24 +1283,48 @@ def compute_infeasibilities(self) -> list[int]:
12831283
"""
12841284
Compute a set of infeasible constraints.
12851285
1286-
This function requires that the model was solved with `gurobi` and the
1287-
termination condition was infeasible.
1286+
This function requires that the model was solved with `gurobi` or `xpress`
1287+
and the termination condition was infeasible.
12881288
12891289
Returns
12901290
-------
12911291
labels : list[int]
12921292
Labels of the infeasible constraints.
12931293
"""
1294-
if "gurobi" not in available_solvers:
1295-
raise ImportError("Gurobi is required for this method.")
1294+
solver_model = getattr(self, "solver_model", None)
1295+
if solver_model is None:
1296+
raise ValueError(
1297+
"No solver model available. The model must be solved first with "
1298+
"'gurobi' or 'xpress' solver and the result must be infeasible."
1299+
)
1300+
1301+
# Check for Gurobi
1302+
if "gurobi" in available_solvers:
1303+
try:
1304+
import gurobipy
1305+
1306+
if isinstance(solver_model, gurobipy.Model):
1307+
return self._compute_infeasibilities_gurobi(solver_model)
1308+
except ImportError:
1309+
pass
12961310

1297-
import gurobipy
1311+
# Check for Xpress
1312+
if "xpress" in available_solvers:
1313+
try:
1314+
import xpress
12981315

1299-
solver_model = getattr(self, "solver_model")
1316+
if isinstance(solver_model, xpress.problem):
1317+
return self._compute_infeasibilities_xpress(solver_model)
1318+
except ImportError:
1319+
pass
13001320

1301-
if not isinstance(solver_model, gurobipy.Model):
1302-
raise NotImplementedError("Solver model must be a Gurobi Model.")
1321+
raise NotImplementedError(
1322+
"Computing infeasibilities is only supported for Gurobi and Xpress solvers. "
1323+
f"Current solver model type: {type(solver_model).__name__}"
1324+
)
13031325

1326+
def _compute_infeasibilities_gurobi(self, solver_model: Any) -> list[int]:
1327+
"""Compute infeasibilities for Gurobi solver."""
13041328
solver_model.computeIIS()
13051329
f = NamedTemporaryFile(suffix=".ilp", prefix="linopy-iis-", delete=False)
13061330
solver_model.write(f.name)
@@ -1315,13 +1339,66 @@ def compute_infeasibilities(self) -> list[int]:
13151339
match = pattern.match(line_decoded)
13161340
if match:
13171341
labels.append(int(match.group(1)))
1342+
f.close()
13181343
return labels
13191344

1345+
def _compute_infeasibilities_xpress(self, solver_model: Any) -> list[int]:
1346+
"""Compute infeasibilities for Xpress solver."""
1347+
# Compute all IIS
1348+
solver_model.iisall()
1349+
1350+
# Get the number of IIS found
1351+
num_iis = solver_model.attributes.numiis
1352+
if num_iis == 0:
1353+
return []
1354+
1355+
labels = set()
1356+
1357+
# Get all constraints from the model for index mapping
1358+
all_constraints = list(solver_model.getConstraint())
1359+
1360+
# Retrieve each IIS
1361+
for iis_num in range(1, num_iis + 1):
1362+
# Prepare lists to receive IIS data
1363+
miisrow: list[Any] = [] # Constraint objects in the IIS
1364+
miiscol: list[Any] = [] # Variable objects in the IIS
1365+
constrainttype: list[str] = [] # Constraint types
1366+
colbndtype: list[str] = [] # Column bound types
1367+
duals: list[float] = [] # Dual values
1368+
rdcs: list[float] = [] # Reduced costs
1369+
isolationrows: list[str] = [] # Row isolation info
1370+
isolationcols: list[str] = [] # Column isolation info
1371+
1372+
# Get IIS data
1373+
solver_model.getiisdata(
1374+
iis_num,
1375+
miisrow,
1376+
miiscol,
1377+
constrainttype,
1378+
colbndtype,
1379+
duals,
1380+
rdcs,
1381+
isolationrows,
1382+
isolationcols,
1383+
)
1384+
1385+
# Convert constraint objects to indices
1386+
# miisrow contains xpress.constraint objects
1387+
for constraint_obj in miisrow:
1388+
try:
1389+
idx = all_constraints.index(constraint_obj)
1390+
labels.add(idx)
1391+
except ValueError:
1392+
# If constraint not found, skip it
1393+
pass
1394+
1395+
return sorted(list(labels))
1396+
13201397
def print_infeasibilities(self, display_max_terms: int | None = None) -> None:
13211398
"""
13221399
Print a list of infeasible constraints.
13231400
1324-
This function requires that the model was solved using `gurobi`
1401+
This function requires that the model was solved using `gurobi` or `xpress`
13251402
and the termination condition was infeasible.
13261403
13271404
Parameters
@@ -1346,7 +1423,7 @@ def compute_set_of_infeasible_constraints(self) -> Dataset:
13461423
"""
13471424
Compute a set of infeasible constraints.
13481425
1349-
This function requires that the model was solved with `gurobi` and the
1426+
This function requires that the model was solved with `gurobi` or `xpress` and the
13501427
termination condition was infeasible.
13511428
13521429
Returns

test/test_infeasibility.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Test infeasibility detection for different solvers.
4+
"""
5+
6+
import pandas as pd
7+
import pytest
8+
9+
from linopy import Model, available_solvers
10+
11+
12+
class TestInfeasibility:
13+
"""Test class for infeasibility detection functionality."""
14+
15+
@pytest.fixture
16+
def simple_infeasible_model(self):
17+
"""Create a simple infeasible model."""
18+
m = Model()
19+
20+
time = pd.RangeIndex(10, name="time")
21+
x = m.add_variables(lower=0, coords=[time], name="x")
22+
y = m.add_variables(lower=0, coords=[time], name="y")
23+
24+
# Create infeasible constraints
25+
m.add_constraints(x <= 5, name="con_x_upper")
26+
m.add_constraints(y <= 5, name="con_y_upper")
27+
m.add_constraints(x + y >= 12, name="con_sum_lower")
28+
29+
# Add objective to avoid multi-objective issue with xpress
30+
m.add_objective(x.sum() + y.sum())
31+
32+
return m
33+
34+
@pytest.fixture
35+
def complex_infeasible_model(self):
36+
"""Create a more complex infeasible model."""
37+
m = Model()
38+
39+
# Create variables
40+
x = m.add_variables(lower=0, upper=10, name="x")
41+
y = m.add_variables(lower=0, upper=10, name="y")
42+
z = m.add_variables(lower=0, upper=10, name="z")
43+
44+
# Add conflicting constraints
45+
m.add_constraints(x + y >= 15, name="con1")
46+
m.add_constraints(x <= 5, name="con2")
47+
m.add_constraints(y <= 5, name="con3")
48+
m.add_constraints(z >= x + y, name="con4")
49+
m.add_constraints(z <= 8, name="con5")
50+
51+
# Add objective
52+
m.add_objective(x + y + z)
53+
54+
return m
55+
56+
@pytest.fixture
57+
def multi_dimensional_infeasible_model(self):
58+
"""Create a multi-dimensional infeasible model."""
59+
m = Model()
60+
61+
# Create multi-dimensional variables
62+
i = pd.RangeIndex(5, name="i")
63+
j = pd.RangeIndex(3, name="j")
64+
65+
x = m.add_variables(lower=0, upper=1, coords=[i, j], name="x")
66+
67+
# Add constraints that make it infeasible
68+
m.add_constraints(x.sum("j") >= 2.5, name="row_sum") # Each row sum >= 2.5
69+
m.add_constraints(x.sum("i") <= 1, name="col_sum") # Each column sum <= 1
70+
71+
# Add objective
72+
m.add_objective(x.sum())
73+
74+
return m
75+
76+
@pytest.mark.parametrize("solver", ["gurobi", "xpress"])
77+
def test_simple_infeasibility_detection(self, simple_infeasible_model, solver):
78+
"""Test basic infeasibility detection."""
79+
if solver not in available_solvers:
80+
pytest.skip(f"{solver} not available")
81+
82+
m = simple_infeasible_model
83+
status, condition = m.solve(solver_name=solver)
84+
85+
assert status == "warning"
86+
assert "infeasible" in condition
87+
88+
# Test compute_infeasibilities
89+
labels = m.compute_infeasibilities()
90+
assert isinstance(labels, list)
91+
assert len(labels) > 0 # Should find at least one infeasible constraint
92+
93+
# Test print_infeasibilities (just check it doesn't raise an error)
94+
m.print_infeasibilities()
95+
96+
@pytest.mark.parametrize("solver", ["gurobi", "xpress"])
97+
def test_complex_infeasibility_detection(self, complex_infeasible_model, solver):
98+
"""Test infeasibility detection on more complex model."""
99+
if solver not in available_solvers:
100+
pytest.skip(f"{solver} not available")
101+
102+
m = complex_infeasible_model
103+
status, condition = m.solve(solver_name=solver)
104+
105+
assert status == "warning"
106+
assert "infeasible" in condition
107+
108+
labels = m.compute_infeasibilities()
109+
assert isinstance(labels, list)
110+
assert len(labels) > 0
111+
112+
# The infeasible set should include constraints that conflict
113+
# Different solvers might find different minimal IIS
114+
# We expect at least 2 constraints to be involved
115+
assert len(labels) >= 2
116+
117+
@pytest.mark.parametrize("solver", ["gurobi", "xpress"])
118+
def test_multi_dimensional_infeasibility(
119+
self, multi_dimensional_infeasible_model, solver
120+
):
121+
"""Test infeasibility detection on multi-dimensional model."""
122+
if solver not in available_solvers:
123+
pytest.skip(f"{solver} not available")
124+
125+
m = multi_dimensional_infeasible_model
126+
status, condition = m.solve(solver_name=solver)
127+
128+
assert status == "warning"
129+
assert "infeasible" in condition
130+
131+
labels = m.compute_infeasibilities()
132+
assert isinstance(labels, list)
133+
assert len(labels) > 0
134+
135+
@pytest.mark.parametrize("solver", ["gurobi", "xpress"])
136+
def test_no_solver_model_error(self, solver):
137+
"""Test error when no solver model is available."""
138+
if solver not in available_solvers:
139+
pytest.skip(f"{solver} not available")
140+
141+
m = Model()
142+
x = m.add_variables(name="x")
143+
m.add_constraints(x >= 0)
144+
m.add_objective(1 * x) # Convert to LinearExpression
145+
146+
# Don't solve the model - should raise error
147+
with pytest.raises(ValueError, match="No solver model available"):
148+
m.compute_infeasibilities()
149+
150+
@pytest.mark.parametrize("solver", ["gurobi", "xpress"])
151+
def test_feasible_model_iis(self, solver):
152+
"""Test IIS computation on a feasible model."""
153+
if solver not in available_solvers:
154+
pytest.skip(f"{solver} not available")
155+
156+
m = Model()
157+
x = m.add_variables(lower=0, name="x")
158+
y = m.add_variables(lower=0, name="y")
159+
160+
m.add_constraints(x + y >= 1)
161+
m.add_constraints(x <= 10)
162+
m.add_constraints(y <= 10)
163+
164+
m.add_objective(x + y)
165+
166+
status, condition = m.solve(solver_name=solver)
167+
assert status == "ok"
168+
assert condition == "optimal"
169+
170+
# Calling compute_infeasibilities on a feasible model
171+
# Different solvers might handle this differently
172+
# Gurobi might raise an error, Xpress might return empty list
173+
try:
174+
labels = m.compute_infeasibilities()
175+
# If it doesn't raise an error, it should return empty list
176+
assert labels == []
177+
except Exception:
178+
# Some solvers might raise an error when computing IIS on feasible model
179+
pass
180+
181+
def test_unsupported_solver_error(self):
182+
"""Test error for unsupported solvers."""
183+
m = Model()
184+
x = m.add_variables(name="x")
185+
m.add_constraints(x >= 0)
186+
m.add_constraints(x <= -1) # Make it infeasible
187+
188+
# Use a solver that doesn't support IIS
189+
if "cbc" in available_solvers:
190+
status, condition = m.solve(solver_name="cbc")
191+
assert "infeasible" in condition
192+
193+
with pytest.raises(NotImplementedError):
194+
m.compute_infeasibilities()
195+
196+
@pytest.mark.parametrize("solver", ["gurobi", "xpress"])
197+
def test_deprecated_method(self, simple_infeasible_model, solver):
198+
"""Test that deprecated method still works."""
199+
if solver not in available_solvers:
200+
pytest.skip(f"{solver} not available")
201+
202+
m = simple_infeasible_model
203+
status, condition = m.solve(solver_name=solver)
204+
205+
assert status == "warning"
206+
assert "infeasible" in condition
207+
208+
# Test deprecated method
209+
with pytest.warns(DeprecationWarning):
210+
subset = m.compute_set_of_infeasible_constraints()
211+
212+
# Check that it returns a Dataset
213+
from xarray import Dataset
214+
215+
assert isinstance(subset, Dataset)
216+
217+
# Check that it contains constraint labels
218+
assert len(subset) > 0

test/test_optimization.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,7 @@ def test_infeasible_model(
589589
assert status == "warning"
590590
assert "infeasible" in condition
591591

592-
if solver == "gurobi":
592+
if solver in ["gurobi", "xpress"]:
593593
# ignore deprecated warning
594594
with pytest.warns(DeprecationWarning):
595595
model.compute_set_of_infeasible_constraints()

0 commit comments

Comments
 (0)