Skip to content

Commit 4ef32c1

Browse files
committed
Add solver_capabilities.py and update codebase
1 parent 72c6f72 commit 4ef32c1

File tree

4 files changed

+241
-29
lines changed

4 files changed

+241
-29
lines changed

linopy/model.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,10 @@
5959
from linopy.matrices import MatrixAccessor
6060
from linopy.objective import Objective
6161
from linopy.remote import OetcHandler, RemoteHandler
62+
from linopy.solver_capabilities import SolverFeature, solver_supports
6263
from linopy.solvers import (
6364
IO_APIS,
64-
NO_SOLUTION_FILE_SOLVERS,
6565
available_solvers,
66-
quadratic_solvers,
6766
)
6867
from linopy.types import (
6968
ConstantLike,
@@ -1196,7 +1195,10 @@ def solve(
11961195
if problem_fn is None:
11971196
problem_fn = self.get_problem_file(io_api=io_api)
11981197
if solution_fn is None:
1199-
if solver_name in NO_SOLUTION_FILE_SOLVERS and not keep_files:
1198+
if (
1199+
solver_supports(solver_name, SolverFeature.SOLUTION_FILE_NOT_NEEDED)
1200+
and not keep_files
1201+
):
12001202
# these (solver, keep_files=False) combos do not need a solution file
12011203
solution_fn = None
12021204
else:
@@ -1208,7 +1210,9 @@ def solve(
12081210
if sanitize_infinities:
12091211
self.constraints.sanitize_infinities()
12101212

1211-
if self.is_quadratic and solver_name not in quadratic_solvers:
1213+
if self.is_quadratic and not solver_supports(
1214+
solver_name, SolverFeature.QUADRATIC_OBJECTIVE
1215+
):
12121216
raise ValueError(
12131217
f"Solver {solver_name} does not support quadratic problems."
12141218
)
@@ -1231,7 +1235,10 @@ def solve(
12311235
explicit_coordinate_names=explicit_coordinate_names,
12321236
)
12331237
else:
1234-
if solver_name in ["glpk", "cbc"] and explicit_coordinate_names:
1238+
if (
1239+
not solver_supports(solver_name, SolverFeature.LP_FILE_NAMES)
1240+
and explicit_coordinate_names
1241+
):
12351242
logger.warning(
12361243
f"{solver_name} does not support writing names to lp files, disabling it."
12371244
)
@@ -1339,10 +1346,10 @@ def compute_infeasibilities(self) -> list[int]:
13391346
if solver_model is None:
13401347
# Check if this is a supported solver without a stored model
13411348
solver_name = getattr(self, "solver_name", "unknown")
1342-
if solver_name in ["gurobi", "xpress"]:
1349+
if solver_supports(solver_name, SolverFeature.IIS_COMPUTATION):
13431350
raise ValueError(
13441351
"No solver model available. The model must be solved first with "
1345-
"'gurobi' or 'xpress' solver and the result must be infeasible."
1352+
"a solver that supports IIS computation and the result must be infeasible."
13461353
)
13471354
else:
13481355
# This is an unsupported solver

linopy/solver_capabilities.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""
2+
Linopy module for solver capability tracking.
3+
4+
This module provides a centralized registry of solver capabilities,
5+
replacing scattered hardcoded checks throughout the codebase.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from dataclasses import dataclass
11+
from enum import Enum, auto
12+
from typing import TYPE_CHECKING
13+
14+
if TYPE_CHECKING:
15+
from collections.abc import Sequence
16+
17+
18+
class SolverFeature(Enum):
19+
"""Enumeration of all solver capabilities tracked by linopy."""
20+
21+
# Objective function support
22+
QUADRATIC_OBJECTIVE = auto()
23+
24+
# I/O capabilities
25+
LP_FILE_NAMES = auto() # Support for named variables/constraints in LP files
26+
SOLUTION_FILE_NOT_NEEDED = auto() # Solver doesn't need a solution file
27+
28+
# Advanced features
29+
IIS_COMPUTATION = auto() # Irreducible Infeasible Set computation
30+
31+
# Solver-specific
32+
SOLVER_ATTRIBUTE_ACCESS = auto() # Direct access to solver variable attributes
33+
34+
35+
@dataclass(frozen=True)
36+
class SolverInfo:
37+
"""Information about a solver's capabilities."""
38+
39+
name: str
40+
features: frozenset[SolverFeature]
41+
display_name: str = ""
42+
43+
def __post_init__(self) -> None:
44+
if not self.display_name:
45+
object.__setattr__(self, "display_name", self.name.upper())
46+
47+
def supports(self, feature: SolverFeature) -> bool:
48+
"""Check if this solver supports a given feature."""
49+
return feature in self.features
50+
51+
52+
# Define all solver capabilities
53+
SOLVER_REGISTRY: dict[str, SolverInfo] = {
54+
"gurobi": SolverInfo(
55+
name="gurobi",
56+
display_name="Gurobi",
57+
features=frozenset(
58+
{
59+
SolverFeature.QUADRATIC_OBJECTIVE,
60+
SolverFeature.LP_FILE_NAMES,
61+
SolverFeature.SOLUTION_FILE_NOT_NEEDED,
62+
SolverFeature.IIS_COMPUTATION,
63+
SolverFeature.SOLVER_ATTRIBUTE_ACCESS,
64+
}
65+
),
66+
),
67+
"highs": SolverInfo(
68+
name="highs",
69+
display_name="HiGHS",
70+
features=frozenset(
71+
{
72+
SolverFeature.QUADRATIC_OBJECTIVE,
73+
SolverFeature.LP_FILE_NAMES,
74+
SolverFeature.SOLUTION_FILE_NOT_NEEDED,
75+
}
76+
),
77+
),
78+
"glpk": SolverInfo(
79+
name="glpk",
80+
display_name="GLPK",
81+
features=frozenset(), # No LP_FILE_NAMES support
82+
),
83+
"cbc": SolverInfo(
84+
name="cbc",
85+
display_name="CBC",
86+
features=frozenset(), # No LP_FILE_NAMES support
87+
),
88+
"cplex": SolverInfo(
89+
name="cplex",
90+
display_name="CPLEX",
91+
features=frozenset(
92+
{
93+
SolverFeature.QUADRATIC_OBJECTIVE,
94+
SolverFeature.LP_FILE_NAMES,
95+
}
96+
),
97+
),
98+
"xpress": SolverInfo(
99+
name="xpress",
100+
display_name="FICO Xpress",
101+
features=frozenset(
102+
{
103+
SolverFeature.QUADRATIC_OBJECTIVE,
104+
SolverFeature.LP_FILE_NAMES,
105+
SolverFeature.SOLUTION_FILE_NOT_NEEDED,
106+
SolverFeature.IIS_COMPUTATION,
107+
}
108+
),
109+
),
110+
"scip": SolverInfo(
111+
name="scip",
112+
display_name="SCIP",
113+
features=frozenset(
114+
{
115+
SolverFeature.QUADRATIC_OBJECTIVE,
116+
SolverFeature.LP_FILE_NAMES,
117+
SolverFeature.SOLUTION_FILE_NOT_NEEDED,
118+
}
119+
),
120+
),
121+
"mosek": SolverInfo(
122+
name="mosek",
123+
display_name="MOSEK",
124+
features=frozenset(
125+
{
126+
SolverFeature.QUADRATIC_OBJECTIVE,
127+
SolverFeature.LP_FILE_NAMES,
128+
SolverFeature.SOLUTION_FILE_NOT_NEEDED,
129+
}
130+
),
131+
),
132+
"copt": SolverInfo(
133+
name="copt",
134+
display_name="COPT",
135+
features=frozenset(
136+
{
137+
SolverFeature.QUADRATIC_OBJECTIVE,
138+
SolverFeature.LP_FILE_NAMES,
139+
SolverFeature.SOLUTION_FILE_NOT_NEEDED,
140+
}
141+
),
142+
),
143+
"mindopt": SolverInfo(
144+
name="mindopt",
145+
display_name="MindOpt",
146+
features=frozenset(
147+
{
148+
SolverFeature.QUADRATIC_OBJECTIVE,
149+
SolverFeature.LP_FILE_NAMES,
150+
SolverFeature.SOLUTION_FILE_NOT_NEEDED,
151+
}
152+
),
153+
),
154+
}
155+
156+
157+
def solver_supports(solver_name: str, feature: SolverFeature) -> bool:
158+
"""
159+
Check if a solver supports a given feature.
160+
161+
Parameters
162+
----------
163+
solver_name : str
164+
Name of the solver (e.g., "gurobi", "highs")
165+
feature : SolverFeature
166+
The feature to check for
167+
168+
Returns
169+
-------
170+
bool
171+
True if the solver supports the feature, False otherwise.
172+
Returns False for unknown solvers.
173+
"""
174+
if solver_name not in SOLVER_REGISTRY:
175+
return False
176+
return SOLVER_REGISTRY[solver_name].supports(feature)
177+
178+
179+
def get_solvers_with_feature(feature: SolverFeature) -> list[str]:
180+
"""
181+
Get all solvers that support a given feature.
182+
183+
Parameters
184+
----------
185+
feature : SolverFeature
186+
The feature to filter by
187+
188+
Returns
189+
-------
190+
list[str]
191+
List of solver names supporting the feature
192+
"""
193+
return [name for name, info in SOLVER_REGISTRY.items() if info.supports(feature)]
194+
195+
196+
def get_available_solvers_with_feature(
197+
feature: SolverFeature, available_solvers: Sequence[str]
198+
) -> list[str]:
199+
"""
200+
Get installed solvers that support a given feature.
201+
202+
Parameters
203+
----------
204+
feature : SolverFeature
205+
The feature to filter by
206+
available_solvers : Sequence[str]
207+
List of currently available/installed solvers
208+
209+
Returns
210+
-------
211+
list[str]
212+
List of installed solver names supporting the feature
213+
"""
214+
return [s for s in get_solvers_with_feature(feature) if s in available_solvers]

linopy/solvers.py

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
Status,
3232
TerminationCondition,
3333
)
34+
from linopy.solver_capabilities import (
35+
SolverFeature,
36+
get_solvers_with_feature,
37+
)
3438

3539
if TYPE_CHECKING:
3640
import gurobipy
@@ -39,27 +43,11 @@
3943

4044
EnvType = TypeVar("EnvType")
4145

42-
QUADRATIC_SOLVERS = [
43-
"gurobi",
44-
"xpress",
45-
"cplex",
46-
"highs",
47-
"scip",
48-
"mosek",
49-
"copt",
50-
"mindopt",
51-
]
52-
53-
# Solvers that don't need a solution file when keep_files=False
54-
NO_SOLUTION_FILE_SOLVERS = [
55-
"xpress",
56-
"gurobi",
57-
"highs",
58-
"mosek",
59-
"scip",
60-
"copt",
61-
"mindopt",
62-
]
46+
# Generated from solver_capabilities registry for backward compatibility
47+
QUADRATIC_SOLVERS = get_solvers_with_feature(SolverFeature.QUADRATIC_OBJECTIVE)
48+
NO_SOLUTION_FILE_SOLVERS = get_solvers_with_feature(
49+
SolverFeature.SOLUTION_FILE_NOT_NEEDED
50+
)
6351

6452
FILE_IO_APIS = ["lp", "lp-polars", "mps"]
6553
IO_APIS = FILE_IO_APIS + ["direct"]

linopy/variables.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
)
5454
from linopy.config import options
5555
from linopy.constants import HELPER_DIMS, TERM_DIM
56+
from linopy.solver_capabilities import SolverFeature, solver_supports
5657
from linopy.types import (
5758
ConstantLike,
5859
DimsLike,
@@ -849,7 +850,9 @@ def get_solver_attribute(self, attr: str) -> DataArray:
849850
xr.DataArray
850851
"""
851852
solver_model = self.model.solver_model
852-
if self.model.solver_name != "gurobi":
853+
if not solver_supports(
854+
self.model.solver_name, SolverFeature.SOLVER_ATTRIBUTE_ACCESS
855+
):
853856
raise NotImplementedError(
854857
"Solver attribute getter only supports the Gurobi solver for now."
855858
)

0 commit comments

Comments
 (0)