Skip to content

Commit f9e3a5e

Browse files
feat: extend IIS calculation to support Xpress solver (#473)
* 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
1 parent cd55e1a commit f9e3a5e

File tree

3 files changed

+369
-11
lines changed

3 files changed

+369
-11
lines changed

linopy/model.py

Lines changed: 124 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,24 +1283,65 @@ 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. The solver must have detected
1288+
the infeasibility during the solve process.
12881289
12891290
Returns
12901291
-------
12911292
labels : list[int]
12921293
Labels of the infeasible constraints.
12931294
"""
1294-
if "gurobi" not in available_solvers:
1295-
raise ImportError("Gurobi is required for this method.")
1295+
solver_model = getattr(self, "solver_model", None)
12961296

1297-
import gurobipy
1297+
# Check for Gurobi
1298+
if "gurobi" in available_solvers:
1299+
try:
1300+
import gurobipy
12981301

1299-
solver_model = getattr(self, "solver_model")
1302+
if solver_model is not None and isinstance(
1303+
solver_model, gurobipy.Model
1304+
):
1305+
return self._compute_infeasibilities_gurobi(solver_model)
1306+
except ImportError:
1307+
pass
13001308

1301-
if not isinstance(solver_model, gurobipy.Model):
1302-
raise NotImplementedError("Solver model must be a Gurobi Model.")
1309+
# Check for Xpress
1310+
if "xpress" in available_solvers:
1311+
try:
1312+
import xpress
1313+
1314+
if solver_model is not None and isinstance(
1315+
solver_model, xpress.problem
1316+
):
1317+
return self._compute_infeasibilities_xpress(solver_model)
1318+
except ImportError:
1319+
pass
1320+
1321+
# If we get here, either the solver doesn't support IIS or no solver model is available
1322+
if solver_model is None:
1323+
# Check if this is a supported solver without a stored model
1324+
solver_name = getattr(self, "solver_name", "unknown")
1325+
if solver_name in ["gurobi", "xpress"]:
1326+
raise ValueError(
1327+
"No solver model available. The model must be solved first with "
1328+
"'gurobi' or 'xpress' solver and the result must be infeasible."
1329+
)
1330+
else:
1331+
# This is an unsupported solver
1332+
raise NotImplementedError(
1333+
f"Computing infeasibilities is not supported for '{solver_name}' solver. "
1334+
"Only Gurobi and Xpress solvers support IIS computation."
1335+
)
1336+
else:
1337+
# We have a solver model but it's not a supported type
1338+
raise NotImplementedError(
1339+
"Computing infeasibilities is only supported for Gurobi and Xpress solvers. "
1340+
f"Current solver model type: {type(solver_model).__name__}"
1341+
)
13031342

1343+
def _compute_infeasibilities_gurobi(self, solver_model: Any) -> list[int]:
1344+
"""Compute infeasibilities for Gurobi solver."""
13041345
solver_model.computeIIS()
13051346
f = NamedTemporaryFile(suffix=".ilp", prefix="linopy-iis-", delete=False)
13061347
solver_model.write(f.name)
@@ -1315,13 +1356,86 @@ def compute_infeasibilities(self) -> list[int]:
13151356
match = pattern.match(line_decoded)
13161357
if match:
13171358
labels.append(int(match.group(1)))
1359+
f.close()
13181360
return labels
13191361

1362+
def _compute_infeasibilities_xpress(self, solver_model: Any) -> list[int]:
1363+
"""Compute infeasibilities for Xpress solver."""
1364+
# Compute all IIS
1365+
solver_model.iisall()
1366+
1367+
# Get the number of IIS found
1368+
num_iis = solver_model.attributes.numiis
1369+
if num_iis == 0:
1370+
return []
1371+
1372+
labels = set()
1373+
1374+
# Create constraint mapping for efficient lookups
1375+
constraint_to_index = {
1376+
constraint: idx
1377+
for idx, constraint in enumerate(solver_model.getConstraint())
1378+
}
1379+
1380+
# Retrieve each IIS
1381+
for iis_num in range(1, num_iis + 1):
1382+
iis_constraints = self._extract_iis_constraints(solver_model, iis_num)
1383+
1384+
# Convert constraint objects to indices
1385+
for constraint_obj in iis_constraints:
1386+
if constraint_obj in constraint_to_index:
1387+
labels.add(constraint_to_index[constraint_obj])
1388+
# Note: Silently skip constraints not found in mapping
1389+
# This can happen if the model structure changed after solving
1390+
1391+
return sorted(list(labels))
1392+
1393+
def _extract_iis_constraints(self, solver_model: Any, iis_num: int) -> list[Any]:
1394+
"""
1395+
Extract constraint objects from a specific IIS.
1396+
1397+
Parameters
1398+
----------
1399+
solver_model : xpress.problem
1400+
The Xpress solver model
1401+
iis_num : int
1402+
IIS number (1-indexed)
1403+
1404+
Returns
1405+
-------
1406+
list[Any]
1407+
List of xpress.constraint objects in the IIS
1408+
"""
1409+
# Prepare lists to receive IIS data
1410+
miisrow: list[Any] = [] # xpress.constraint objects in the IIS
1411+
miiscol: list[Any] = [] # xpress.variable objects in the IIS
1412+
constrainttype: list[str] = [] # Constraint types ('L', 'G', 'E')
1413+
colbndtype: list[str] = [] # Column bound types
1414+
duals: list[float] = [] # Dual values
1415+
rdcs: list[float] = [] # Reduced costs
1416+
isolationrows: list[str] = [] # Row isolation info
1417+
isolationcols: list[str] = [] # Column isolation info
1418+
1419+
# Get IIS data from Xpress
1420+
solver_model.getiisdata(
1421+
iis_num,
1422+
miisrow,
1423+
miiscol,
1424+
constrainttype,
1425+
colbndtype,
1426+
duals,
1427+
rdcs,
1428+
isolationrows,
1429+
isolationcols,
1430+
)
1431+
1432+
return miisrow
1433+
13201434
def print_infeasibilities(self, display_max_terms: int | None = None) -> None:
13211435
"""
13221436
Print a list of infeasible constraints.
13231437
1324-
This function requires that the model was solved using `gurobi`
1438+
This function requires that the model was solved using `gurobi` or `xpress`
13251439
and the termination condition was infeasible.
13261440
13271441
Parameters
@@ -1346,7 +1460,7 @@ def compute_set_of_infeasible_constraints(self) -> Dataset:
13461460
"""
13471461
Compute a set of infeasible constraints.
13481462
1349-
This function requires that the model was solved with `gurobi` and the
1463+
This function requires that the model was solved with `gurobi` or `xpress` and the
13501464
termination condition was infeasible.
13511465
13521466
Returns

0 commit comments

Comments
 (0)