diff --git a/README.md b/README.md index 3b0a7167..9738a347 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ Fri 0 4 * [MOSEK](https://www.mosek.com/) * [COPT](https://www.shanshu.ai/copt) * [cuPDLPx](https://github.com/MIT-Lu-Lab/cuPDLPx) +* [Knitro](https://www.artelys.com/solvers/knitro/) Note that these do have to be installed by the user separately. diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 9359e55e..5cf09447 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,6 +4,8 @@ Release Notes Upcoming Version ---------------- +* Add support for the `knitro` solver via the knitro python API + Version 0.6.3 -------------- diff --git a/linopy/solver_capabilities.py b/linopy/solver_capabilities.py index 0ffea923..f0507317 100644 --- a/linopy/solver_capabilities.py +++ b/linopy/solver_capabilities.py @@ -161,6 +161,19 @@ def supports(self, feature: SolverFeature) -> bool: } ), ), + "knitro": SolverInfo( + name="knitro", + display_name="Artelys Knitro", + features=frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.QUADRATIC_OBJECTIVE, + SolverFeature.LP_FILE_NAMES, + SolverFeature.READ_MODEL_FROM_FILE, + SolverFeature.SOLUTION_FILE_NOT_NEEDED, + } + ), + ), "scip": SolverInfo( name="scip", display_name="SCIP", diff --git a/linopy/solvers.py b/linopy/solvers.py index 48ffafca..9b69bf96 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -176,6 +176,14 @@ class xpress_Namespaces: # type: ignore[no-redef] SET = 3 +with contextlib.suppress(ModuleNotFoundError, ImportError): + import knitro + + with contextlib.suppress(Exception): + kc = knitro.KN_new() + knitro.KN_free(kc) + available_solvers.append("knitro") + with contextlib.suppress(ModuleNotFoundError): import mosek @@ -239,6 +247,7 @@ class SolverName(enum.Enum): Gurobi = "gurobi" SCIP = "scip" Xpress = "xpress" + Knitro = "knitro" Mosek = "mosek" COPT = "copt" MindOpt = "mindopt" @@ -1252,7 +1261,7 @@ def get_solver_solution() -> Solution: return Solution(sol, dual, objective) solution = self.safe_get_solution(status=status, func=get_solver_solution) - solution = solution = maybe_adjust_objective_sign(solution, io_api, sense) + solution = maybe_adjust_objective_sign(solution, io_api, sense) return Result(status, solution, m) @@ -1736,6 +1745,200 @@ def get_solver_solution() -> Solution: return Result(status, solution, m) +KnitroResult = namedtuple("KnitroResult", "reported_runtime") + + +class Knitro(Solver[None]): + """ + Solver subclass for the Knitro solver. + + For more information on solver options, see + https://www.artelys.com/app/docs/knitro/3_referenceManual/knitroPythonReference.html + + Attributes + ---------- + **solver_options + options for the given solver + """ + + def __init__( + self, + **solver_options: Any, + ) -> None: + super().__init__(**solver_options) + + def solve_problem_from_model( + self, + model: Model, + solution_fn: Path | None = None, + log_fn: Path | None = None, + warmstart_fn: Path | None = None, + basis_fn: Path | None = None, + env: None = None, + explicit_coordinate_names: bool = False, + ) -> Result: + msg = "Direct API not implemented for Knitro" + raise NotImplementedError(msg) + + @staticmethod + def _set_option(kc: Any, name: str, value: Any) -> None: + if isinstance(value, bool): + value = int(value) + + if isinstance(value, int): + knitro.KN_set_int_param_by_name(kc, name, value) + elif isinstance(value, float): + knitro.KN_set_double_param_by_name(kc, name, value) + elif isinstance(value, str): + knitro.KN_set_char_param_by_name(kc, name, value) + else: + msg = f"Unsupported Knitro option type for {name!r}: {type(value).__name__}" + raise TypeError(msg) + + @staticmethod + def _extract_values( + kc: Any, + get_count_fn: Callable[..., Any], + get_values_fn: Callable[..., Any], + get_names_fn: Callable[..., Any], + ) -> pd.Series: + n = int(get_count_fn(kc)) + if n == 0: + return pd.Series(dtype=float) + + values = get_values_fn(kc, n - 1) + names = list(get_names_fn(kc)) + return pd.Series(values, index=names, dtype=float) + + def solve_problem_from_file( + self, + problem_fn: Path, + solution_fn: Path | None = None, + log_fn: Path | None = None, + warmstart_fn: Path | None = None, + basis_fn: Path | None = None, + env: None = None, + ) -> Result: + """ + Solve a linear problem from a problem file using the Knitro solver. + + Parameters + ---------- + problem_fn : Path + Path to the problem file. + solution_fn : Path, optional + Path to the solution file. + log_fn : Path, optional + Path to the log file. + warmstart_fn : Path, optional + Path to the warmstart file. + basis_fn : Path, optional + Path to the basis file. + env : None, optional + Environment for the solver. + + Returns + ------- + Result + """ + CONDITION_MAP: dict[int, TerminationCondition] = { + 0: TerminationCondition.optimal, + -100: TerminationCondition.suboptimal, + -101: TerminationCondition.infeasible, + -102: TerminationCondition.suboptimal, + -200: TerminationCondition.unbounded, + -201: TerminationCondition.infeasible_or_unbounded, + -202: TerminationCondition.iteration_limit, + -203: TerminationCondition.time_limit, + -204: TerminationCondition.terminated_by_limit, + -300: TerminationCondition.unbounded, + -400: TerminationCondition.iteration_limit, + -401: TerminationCondition.time_limit, + -410: TerminationCondition.terminated_by_limit, + -411: TerminationCondition.terminated_by_limit, + } + + READ_OPTIONS: dict[str, str] = {".lp": "l", ".mps": "m"} + + io_api = read_io_api_from_problem_file(problem_fn) + sense = read_sense_from_problem_file(problem_fn) + + suffix = problem_fn.suffix.lower() + if suffix not in READ_OPTIONS: + msg = f"Unsupported problem file format: {suffix}" + raise ValueError(msg) + + kc = knitro.KN_new() + try: + knitro.KN_read_problem( + kc, + path_to_string(problem_fn), + read_options=READ_OPTIONS[suffix], + ) + + if log_fn is not None: + log_fn.parent.mkdir(parents=True, exist_ok=True) + knitro.KN_set_char_param_by_name(kc, "outdir", str(log_fn.parent)) + knitro.KN_set_char_param_by_name(kc, "outname", log_fn.stem) + + for k, v in self.solver_options.items(): + self._set_option(kc, k, v) + + ret = int(knitro.KN_solve(kc)) + + reported_runtime: float | None = None + with contextlib.suppress(Exception): + reported_runtime = float(knitro.KN_get_solve_time_real(kc)) + + if ret in CONDITION_MAP: + termination_condition = CONDITION_MAP[ret] + elif ret > 0: + termination_condition = TerminationCondition.internal_solver_error + else: + termination_condition = TerminationCondition.unknown + + status = Status.from_termination_condition(termination_condition) + status.legacy_status = str(ret) + + def get_solver_solution() -> Solution: + objective = float(knitro.KN_get_obj_value(kc)) + + sol = self._extract_values( + kc, + knitro.KN_get_number_vars, + knitro.KN_get_var_primal_values, + knitro.KN_get_var_names, + ) + + try: + dual = self._extract_values( + kc, + knitro.KN_get_number_cons, + knitro.KN_get_con_dual_values, + knitro.KN_get_con_names, + ) + except Exception: + logger.warning("Dual values couldn't be parsed") + dual = pd.Series(dtype=float) + + return Solution(sol, dual, objective) + + solution = self.safe_get_solution(status=status, func=get_solver_solution) + solution = maybe_adjust_objective_sign(solution, io_api, sense) + + if solution_fn is not None: + solution_fn.parent.mkdir(exist_ok=True) + knitro.KN_write_sol_file(kc, path_to_string(solution_fn)) + + return Result( + status, solution, KnitroResult(reported_runtime=reported_runtime) + ) + + finally: + with contextlib.suppress(Exception): + knitro.KN_free(kc) + + mosek_bas_re = re.compile(r" (XL|XU)\s+([^ \t]+)\s+([^ \t]+)| (LL|UL|BS)\s+([^ \t]+)") diff --git a/pyproject.toml b/pyproject.toml index 50d71538..1ff663ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,7 @@ solvers = [ "coptpy!=7.2.1", "xpress; platform_system != 'Darwin' and python_version < '3.11'", "pyscipopt; platform_system != 'Darwin'", + "knitro", # "cupdlpx>=0.1.2", pip package currently unstable ] diff --git a/test/test_solvers.py b/test/test_solvers.py index 7f8b01f2..462e0439 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -45,6 +45,18 @@ ENDATA """ +free_lp_problem = """ +Maximize + z: 3 x + 4 y +Subject To + c1: 2 x + y <= 10 + c2: x + 2 y <= 12 +Bounds + 0 <= x + 0 <= y +End +""" + @pytest.mark.parametrize("solver", set(solvers.available_solvers)) def test_free_mps_solution_parsing(solver: str, tmp_path: Path) -> None: @@ -71,6 +83,90 @@ def test_free_mps_solution_parsing(solver: str, tmp_path: Path) -> None: assert result.solution.objective == 30.0 +@pytest.mark.skipif( + "knitro" not in set(solvers.available_solvers), reason="Knitro is not installed" +) +def test_knitro_solver_mps(tmp_path: Path) -> None: + """Test Knitro solver with a simple MPS problem.""" + knitro = solvers.Knitro() + + mps_file = tmp_path / "problem.mps" + mps_file.write_text(free_mps_problem) + sol_file = tmp_path / "solution.sol" + + result = knitro.solve_problem(problem_fn=mps_file, solution_fn=sol_file) + + assert result.status.is_ok + assert result.solution is not None + assert result.solution.objective == 30.0 + + +@pytest.mark.skipif( + "knitro" not in set(solvers.available_solvers), reason="Knitro is not installed" +) +def test_knitro_solver_for_lp(tmp_path: Path) -> None: + """Test Knitro solver with a simple LP problem.""" + knitro = solvers.Knitro() + + lp_file = tmp_path / "problem.lp" + lp_file.write_text(free_lp_problem) + sol_file = tmp_path / "solution.sol" + + result = knitro.solve_problem(problem_fn=lp_file, solution_fn=sol_file) + + assert result.status.is_ok + assert result.solution is not None + assert result.solution.objective == 28.0 + + +@pytest.mark.skipif( + "knitro" not in set(solvers.available_solvers), reason="Knitro is not installed" +) +def test_knitro_solver_with_options(tmp_path: Path) -> None: + """Test Knitro solver with custom options.""" + knitro = solvers.Knitro(maxit=100, feastol=1e-6) + + mps_file = tmp_path / "problem.mps" + mps_file.write_text(free_mps_problem) + sol_file = tmp_path / "solution.sol" + log_file = tmp_path / "knitro.log" + + result = knitro.solve_problem( + problem_fn=mps_file, solution_fn=sol_file, log_fn=log_file + ) + + assert result.status.is_ok + assert log_file.exists() + + +@pytest.mark.skipif( + "knitro" not in set(solvers.available_solvers), reason="Knitro is not installed" +) +def test_knitro_solver_with_model_raises_error(model: Model) -> None: # noqa: F811 + """Test Knitro solver raises NotImplementedError for model-based solving.""" + knitro = solvers.Knitro() + with pytest.raises( + NotImplementedError, match="Direct API not implemented for Knitro" + ): + knitro.solve_problem(model=model) + + +@pytest.mark.skipif( + "knitro" not in set(solvers.available_solvers), reason="Knitro is not installed" +) +def test_knitro_solver_no_log(tmp_path: Path) -> None: + """Test Knitro solver without log file.""" + knitro = solvers.Knitro(outlev=0) + + mps_file = tmp_path / "problem.mps" + mps_file.write_text(free_mps_problem) + sol_file = tmp_path / "solution.sol" + + result = knitro.solve_problem(problem_fn=mps_file, solution_fn=sol_file) + + assert result.status.is_ok + + @pytest.mark.skipif( "gurobi" not in set(solvers.available_solvers), reason="Gurobi is not installed" )