diff --git a/docs/home/installation.md b/docs/home/installation.md index d61022074..2c01833ab 100644 --- a/docs/home/installation.md +++ b/docs/home/installation.md @@ -48,6 +48,20 @@ For academic use, Gurobi offers free licenses: ``` 3. Activate your license following Gurobi's instructions +### FICO Xpress (Optional) + +Xpress ships with a free Community license for small models (up to 5000 rows and columns combined). Academic and commercial licenses are available from [FICO](https://www.fico.com/en/fico-xpress-community-license): + +1. Install Xpress: + ```bash + pip install xpress + ``` +2. For larger models, point Xpress to your license file (e.g. via the `XPAUTH_PATH` environment variable) +3. Use it via `fx.solvers.XpressSolver()` + +!!! note "Version requirements" + `XpressSolver` needs Xpress 9.0 or newer (for the `TIMELIMIT` control). Use linopy 0.5.8 or newer: older versions cannot return a solution when Xpress stops at the time limit. + ## Verification Verify your installation by running: diff --git a/docs/user-guide/optimization/index.md b/docs/user-guide/optimization/index.md index 12571ad67..fdc50b0b6 100644 --- a/docs/user-guide/optimization/index.md +++ b/docs/user-guide/optimization/index.md @@ -253,6 +253,7 @@ Variables follow this naming pattern: | **HiGHS** | Open-source | Fast | Free | | **Gurobi** | Commercial | Fastest | Academic/Commercial | | **CPLEX** | Commercial | Fastest | Academic/Commercial | +| **Xpress** | Commercial | Fastest | Free Community/Academic/Commercial | | **GLPK** | Open-source | Slower | Free | **Recommendation:** Start with HiGHS (included by default). Use Gurobi/CPLEX for large models or when speed matters. diff --git a/flixopt/solvers.py b/flixopt/solvers.py index e5db61192..b1eed755d 100644 --- a/flixopt/solvers.py +++ b/flixopt/solvers.py @@ -42,6 +42,7 @@ def _options(self) -> dict[str, Any]: raise NotImplementedError +@dataclass class GurobiSolver(_Solver): """ Gurobi solver configuration. @@ -64,6 +65,7 @@ def _options(self) -> dict[str, Any]: } +@dataclass class HighsSolver(_Solver): """ HiGHS solver configuration. @@ -87,3 +89,31 @@ def _options(self) -> dict[str, Any]: 'threads': self.threads, 'log_to_console': self.log_to_console, } + + +@dataclass +class XpressSolver(_Solver): + """ + FICO Xpress solver configuration. + + Requires Xpress >= 9.0 (for `TIMELIMIT`) and linopy >= 0.5.8 (to retrieve solutions after hitting the time limit). + + Args: + mip_gap: Acceptable relative optimality gap in [0.0, 1.0]; mapped to Xpress `MIPRELSTOP`. + time_limit_seconds: Time limit in seconds; mapped to Xpress `TIMELIMIT`. + log_to_console: If False, no output to console; mapped to Xpress `OUTPUTLOG`. + extra_options: Additional Xpress controls merged into `options`. + threads (int | None): Number of threads to use; mapped to Xpress `THREADS`. If None, Xpress chooses. + """ + + threads: int | None = None + name: ClassVar[str] = 'xpress' + + @property + def _options(self) -> dict[str, Any]: + return { + 'MIPRELSTOP': self.mip_gap, + 'TIMELIMIT': self.time_limit_seconds, + 'THREADS': self.threads, + 'OUTPUTLOG': 1 if self.log_to_console else 0, + } diff --git a/tests/conftest.py b/tests/conftest.py index b3950cc35..8f574c3b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,7 +33,13 @@ def gurobi_solver(): return fx.solvers.GurobiSolver(mip_gap=0, time_limit_seconds=300) -@pytest.fixture(params=[highs_solver, gurobi_solver], ids=['highs', 'gurobi']) +@pytest.fixture() +def xpress_solver(): + pytest.importorskip('xpress', reason='Xpress not available in this environment') + return fx.solvers.XpressSolver(mip_gap=0, time_limit_seconds=300) + + +@pytest.fixture(params=[highs_solver, gurobi_solver, xpress_solver], ids=['highs', 'gurobi', 'xpress']) def solver_fixture(request): return request.getfixturevalue(request.param.__name__) diff --git a/tests/utilities/test_solvers.py b/tests/utilities/test_solvers.py new file mode 100644 index 000000000..a06259a05 --- /dev/null +++ b/tests/utilities/test_solvers.py @@ -0,0 +1,38 @@ +"""Tests for the solver configuration classes.""" + +import pytest + +import flixopt as fx + + +def test_highs_threads(): + solver = fx.solvers.HighsSolver(threads=2) + assert solver.threads == 2 + assert solver.options['threads'] == 2 + + +def test_xpress_threads(): + solver = fx.solvers.XpressSolver(threads=2) + assert solver.threads == 2 + assert solver.options['THREADS'] == 2 + + +@pytest.mark.parametrize('solver_cls', [fx.solvers.HighsSolver, fx.solvers.XpressSolver]) +def test_threads_omitted_by_default(solver_cls): + assert all(value is not None for value in solver_cls().options.values()) + assert 'threads' not in solver_cls().options + assert 'THREADS' not in solver_cls().options + + +@pytest.mark.parametrize('solver_cls', [fx.solvers.GurobiSolver, fx.solvers.HighsSolver, fx.solvers.XpressSolver]) +def test_positional_args(solver_cls): + solver = solver_cls(0.01, 60) + assert solver.mip_gap == 0.01 + assert solver.time_limit_seconds == 60 + + +def test_xpress_options(): + solver = fx.solvers.XpressSolver( + mip_gap=0.05, time_limit_seconds=30, log_to_console=False, extra_options={'PRESOLVE': 0} + ) + assert solver.options == {'MIPRELSTOP': 0.05, 'TIMELIMIT': 30, 'OUTPUTLOG': 0, 'PRESOLVE': 0}