Repository: PyPSA/linopy
Pull request #469 (2025-06-30 10:51:12)

feat: allow passing a parameter dict to gurobi's env creation
## Changes proposed in this Pull Request

Enable passing parameters to gurobi's environment creation by supplying a dictionary
as the `env` parameter.

This allows to provide license information to gurobi like:

```
env_params = {
    "WLSACCESSID": "203dec48-e3f8-46ac-0184-92d7d6ded944",
    "WLSSECRET": "a080cce8-4e01-4e36-955e-61592c5630db",
    "LICENSEID": 12127,
}
m.solve(solver_name="gurobi", env=env_params)
```

This enables implicitly to add license information to `pypsa-eur`/`pypsa-earth` `solver_options` like:

```
solver_options:
  gurobi-default:
    env:
      WLSACCESSID: "203dec48-e3f8-46ac-0184-92d7d6ded944",
      WLSSECRET: "a080cce8-4e01-4e36-955e-61592c5630db",
      LICENSEID: 12127,
```

@FabianHofmann @lkstrp Would this be an acceptable addition or is this too magic?

## Checklist

- [x] Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in `doc`.
- [x] Unit tests for new features were added (if applicable).
- [x] A note for the release notes `doc/release_notes.rst` of the upcoming release is included.
- [x] I consent to the release of this PR's code under the MIT license.


coroa: > Fine for me, since they all resolve in a gurobi env. But needs type hints and a better docstring, e.g. pass env directly or kwargs for creation

How do you type something that maybe is not there?
coroa: Ok, i added release notes and updated doc strings and types.

I did not immediately find a testable gurobi parameter, so i'd like to punt on adding a test for this feature.

Do you hate me then? Or do you have a better suggestion :)
FabianHofmann:  let's go and merge it when CI is passing
coroa: Ok, i was finally able to make mypy, codecov and tests happy. @lkstrp Anything against the new TypeVar?
coroa: Ok, then i'll merge in the evening if i don't hear further complaints.

Patch:
diff --git a/.gitignore b/.gitignore
index aaa535e..2088cae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 .coverage
 .eggs
 .DS_Store
+.mypy_cache
 linopy/__pycache__
 test/__pycache__
 linopy.egg-info
diff --git a/doc/release_notes.rst b/doc/release_notes.rst
index 3ede543..6158090 100644
--- a/doc/release_notes.rst
+++ b/doc/release_notes.rst
@@ -7,6 +7,7 @@ Release Notes
 
 
 * Improved variable/expression arithmetic methods so that they correctly handle types
+* Gurobi: Pass dictionary as env argument `env={...}` through to gurobi env creation
 
 **Breaking Changes**
 
diff --git a/linopy/solvers.py b/linopy/solvers.py
index caf113f..55e9cbd 100644
--- a/linopy/solvers.py
+++ b/linopy/solvers.py
@@ -17,7 +17,7 @@ from abc import ABC, abstractmethod
 from collections import namedtuple
 from collections.abc import Callable, Generator
 from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Generic, TypeVar
 
 import numpy as np
 import pandas as pd
@@ -32,8 +32,12 @@ from linopy.constants import (
 )
 
 if TYPE_CHECKING:
+    import gurobipy
+
     from linopy.model import Model
 
+EnvType = TypeVar("EnvType")
+
 QUADRATIC_SOLVERS = [
     "gurobi",
     "xpress",
@@ -195,7 +199,7 @@ def maybe_adjust_objective_sign(
     return solution
 
 
-class Solver(ABC):
+class Solver(ABC, Generic[EnvType]):
     """
     Abstract base class for solving a given linear problem.
 
@@ -242,7 +246,7 @@ class Solver(ABC):
         log_fn: Path | None = None,
         warmstart_fn: Path | None = None,
         basis_fn: Path | None = None,
-        env: None = None,
+        env: EnvType | None = None,
         explicit_coordinate_names: bool = False,
     ) -> Result:
         """
@@ -262,7 +266,7 @@ class Solver(ABC):
         log_fn: Path | None = None,
         warmstart_fn: Path | None = None,
         basis_fn: Path | None = None,
-        env: None = None,
+        env: EnvType | None = None,
     ) -> Result:
         """
         Abstract method to solve a linear problem from a problem file.
@@ -281,7 +285,7 @@ class Solver(ABC):
         log_fn: Path | None = None,
         warmstart_fn: Path | None = None,
         basis_fn: Path | None = None,
-        env: None = None,
+        env: EnvType | None = None,
         explicit_coordinate_names: bool = False,
     ) -> Result:
         """
@@ -322,7 +326,7 @@ class Solver(ABC):
         return SolverName[self.__class__.__name__]
 
 
-class CBC(Solver):
+class CBC(Solver[None]):
     """
     Solver subclass for the CBC solver.
 
@@ -503,7 +507,7 @@ class CBC(Solver):
         return Result(status, solution, CbcModel(mip_gap, runtime))
 
 
-class GLPK(Solver):
+class GLPK(Solver[None]):
     """
     Solver subclass for the GLPK solver.
 
@@ -673,7 +677,7 @@ class GLPK(Solver):
         return Result(status, solution)
 
 
-class Highs(Solver):
+class Highs(Solver[None]):
     """
     Solver subclass for the Highs solver. Highs must be installed
     for usage. Find the documentation at https://www.maths.ed.ac.uk/hall/HiGHS/.
@@ -919,7 +923,7 @@ class Highs(Solver):
         return Result(status, solution, h)
 
 
-class Gurobi(Solver):
+class Gurobi(Solver[gurobipy.Env | dict[str, Any] | None]):
     """
     Solver subclass for the gurobi solver.
 
@@ -942,7 +946,7 @@ class Gurobi(Solver):
         log_fn: Path | None = None,
         warmstart_fn: Path | None = None,
         basis_fn: Path | None = None,
-        env: None = None,
+        env: gurobipy.Env | dict[str, Any] | None = None,
         explicit_coordinate_names: bool = False,
     ) -> Result:
         """
@@ -962,8 +966,8 @@ class Gurobi(Solver):
             Path to the warmstart file.
         basis_fn : Path, optional
             Path to the basis file.
-        env : None, optional
-            Gurobi environment for the solver
+        env : gurobipy.Env or dict, optional
+            Gurobi environment for the solver, pass env directly or kwargs for creation.
         explicit_coordinate_names : bool, optional
             Transfer variable and constraint names to the solver (default: False)
 
@@ -974,6 +978,8 @@ class Gurobi(Solver):
         with contextlib.ExitStack() as stack:
             if env is None:
                 env_ = stack.enter_context(gurobipy.Env())
+            elif isinstance(env, dict):
+                env_ = stack.enter_context(gurobipy.Env(params=env))
             else:
                 env_ = env
 
@@ -998,7 +1004,7 @@ class Gurobi(Solver):
         log_fn: Path | None = None,
         warmstart_fn: Path | None = None,
         basis_fn: Path | None = None,
-        env: None = None,
+        env: gurobipy.Env | dict[str, Any] | None = None,
     ) -> Result:
         """
         Solve a linear problem from a problem file using the Gurobi solver.
@@ -1017,8 +1023,8 @@ class Gurobi(Solver):
             Path to the warmstart file.
         basis_fn : Path, optional
             Path to the basis file.
-        env : None, optional
-            Gurobi environment for the solver
+        env : gurobipy.Env or dict, optional
+            Gurobi environment for the solver, pass env directly or kwargs for creation.
 
         Returns
         -------
@@ -1031,6 +1037,8 @@ class Gurobi(Solver):
         with contextlib.ExitStack() as stack:
             if env is None:
                 env_ = stack.enter_context(gurobipy.Env())
+            elif isinstance(env, dict):
+                env_ = stack.enter_context(gurobipy.Env(params=env))
             else:
                 env_ = env
 
@@ -1150,7 +1158,7 @@ class Gurobi(Solver):
         return Result(status, solution, m)
 
 
-class Cplex(Solver):
+class Cplex(Solver[None]):
     """
     Solver subclass for the Cplex solver.
 
@@ -1306,7 +1314,7 @@ class Cplex(Solver):
         return Result(status, solution, m)
 
 
-class SCIP(Solver):
+class SCIP(Solver[None]):
     """
     Solver subclass for the SCIP solver.
 
@@ -1459,7 +1467,7 @@ class SCIP(Solver):
         return Result(status, solution, m)
 
 
-class Xpress(Solver):
+class Xpress(Solver[None]):
     """
     Solver subclass for the xpress solver.
 
@@ -1596,7 +1604,7 @@ class Xpress(Solver):
 mosek_bas_re = re.compile(r" (XL|XU)\s+([^ \t]+)\s+([^ \t]+)| (LL|UL|BS)\s+([^ \t]+)")
 
 
-class Mosek(Solver):
+class Mosek(Solver[None]):
     """
     Solver subclass for the Mosek solver.
 
@@ -1926,7 +1934,7 @@ class Mosek(Solver):
         return Result(status, solution)
 
 
-class COPT(Solver):
+class COPT(Solver[None]):
     """
     Solver subclass for the COPT solver.
 
@@ -2067,7 +2075,7 @@ class COPT(Solver):
         return Result(status, solution, m)
 
 
-class MindOpt(Solver):
+class MindOpt(Solver[None]):
     """
     Solver subclass for the MindOpt solver.
 
@@ -2210,7 +2218,7 @@ class MindOpt(Solver):
         return Result(status, solution, m)
 
 
-class PIPS(Solver):
+class PIPS(Solver[None]):
     """
     Solver subclass for the PIPS solver.
     """


Test patch:
diff --git a/test/test_solvers.py b/test/test_solvers.py
index b29e672..129c1e0 100644
--- a/test/test_solvers.py
+++ b/test/test_solvers.py
@@ -8,8 +8,9 @@ Created on Tue Jan 28 09:03:35 2025.
 from pathlib import Path
 
 import pytest
+from test_io import model  # noqa: F401
 
-from linopy import solvers
+from linopy import Model, solvers
 
 free_mps_problem = """NAME               sample_mip
 ROWS
@@ -64,3 +65,58 @@ def test_free_mps_solution_parsing(solver: str, tmp_path: Path) -> None:
 
     assert result.status.is_ok
     assert result.solution.objective == 30.0
+
+
+@pytest.mark.skipif(
+    "gurobi" not in set(solvers.available_solvers), reason="Gurobi is not installed"
+)
+def test_gurobi_environment_with_dict(model: Model, tmp_path: Path) -> None:  # noqa: F811
+    gurobi = solvers.Gurobi()
+
+    mps_file = tmp_path / "problem.mps"
+    mps_file.write_text(free_mps_problem)
+    sol_file = tmp_path / "solution.sol"
+
+    log1_file = tmp_path / "gurobi1.log"
+    result = gurobi.solve_problem(
+        problem_fn=mps_file, solution_fn=sol_file, env={"LogFile": str(log1_file)}
+    )
+
+    assert result.status.is_ok
+    assert log1_file.exists()
+
+    log2_file = tmp_path / "gurobi2.log"
+    gurobi.solve_problem(
+        model=model, solution_fn=sol_file, env={"LogFile": str(log2_file)}
+    )
+    assert result.status.is_ok
+    assert log2_file.exists()
+
+
+@pytest.mark.skipif(
+    "gurobi" not in set(solvers.available_solvers), reason="Gurobi is not installed"
+)
+def test_gurobi_environment_with_gurobi_env(model: Model, tmp_path: Path) -> None:  # noqa: F811
+    import gurobipy as gp
+
+    gurobi = solvers.Gurobi()
+
+    mps_file = tmp_path / "problem.mps"
+    mps_file.write_text(free_mps_problem)
+    sol_file = tmp_path / "solution.sol"
+
+    log1_file = tmp_path / "gurobi1.log"
+
+    with gp.Env(params={"LogFile": str(log1_file)}) as env:
+        result = gurobi.solve_problem(
+            problem_fn=mps_file, solution_fn=sol_file, env=env
+        )
+
+    assert result.status.is_ok
+    assert log1_file.exists()
+
+    log2_file = tmp_path / "gurobi2.log"
+    with gp.Env(params={"LogFile": str(log2_file)}) as env:
+        gurobi.solve_problem(model=model, solution_fn=sol_file, env=env)
+    assert result.status.is_ok
+    assert log2_file.exists()