Skip to content

Commit b420708

Browse files
authored
Merge branch 'master' into feature/add-const-expression
2 parents bc67615 + 3807adb commit b420708

File tree

6 files changed

+267
-39
lines changed

6 files changed

+267
-39
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ repos:
2424
rev: v0.4.1
2525
hooks:
2626
- id: blackdoc
27+
exclude: ^dev-scripts/
2728
additional_dependencies: ['black==24.8.0']
2829
- repo: https://github.com/codespell-project/codespell
2930
rev: v2.4.1

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