From 96cb9d98bd12937a8a128a097cd40ab5a922bee0 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 11 Dec 2025 13:57:37 +0100 Subject: [PATCH 1/3] Add Knitro solver support - Add Knitro detection to available_solvers list - Implement Knitro solver class with MPS/LP file support - Add solver capabilities for Knitro (quadratic, LP names, no solution file) - Add tests for Knitro solver functionality - Map Knitro status codes to linopy Status system --- linopy/solver_capabilities.py | 11 ++ linopy/solvers.py | 220 ++++++++++++++++++++++++++++++++++ test/test_solvers.py | 38 ++++++ 3 files changed, 269 insertions(+) diff --git a/linopy/solver_capabilities.py b/linopy/solver_capabilities.py index 721cc34d..7a9910a4 100644 --- a/linopy/solver_capabilities.py +++ b/linopy/solver_capabilities.py @@ -111,6 +111,17 @@ def supports(self, feature: SolverFeature) -> bool: } ), ), + "knitro": SolverInfo( + name="knitro", + display_name="Artelys Knitro", + features=frozenset( + { + SolverFeature.QUADRATIC_OBJECTIVE, + SolverFeature.LP_FILE_NAMES, + SolverFeature.SOLUTION_FILE_NOT_NEEDED, + } + ), + ), "scip": SolverInfo( name="scip", display_name="SCIP", diff --git a/linopy/solvers.py b/linopy/solvers.py index f0f732fe..23345b6d 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -96,6 +96,11 @@ available_solvers.append("xpress") +with contextlib.suppress(ModuleNotFoundError, ImportError): + import knitro + + available_solvers.append("knitro") + # xpress.Namespaces was added in xpress 9.6 try: from xpress import Namespaces as xpress_Namespaces @@ -160,6 +165,7 @@ class SolverName(enum.Enum): Gurobi = "gurobi" SCIP = "scip" Xpress = "xpress" + Knitro = "knitro" Mosek = "mosek" COPT = "copt" MindOpt = "mindopt" @@ -1625,6 +1631,220 @@ def get_solver_solution() -> Solution: return Result(status, solution, m) +class Knitro(Solver[None]): + """ + Solver subclass for the Knitro solver. + + Knitro is a powerful nonlinear optimization solver that also handles + linear and quadratic problems efficiently. + + 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) + + 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. + + This function reads the linear problem file and passes it to the Knitro + solver. If the solution is successful it returns variable solutions and + constraint dual values. + + 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 + """ + # Knitro status codes: https://www.artelys.com/app/docs/knitro/3_referenceManual/knitroReturnCodes.html + CONDITION_MAP = { + 0: "optimal", + -100: "feasible_point", + -101: "infeasible", + -102: "feasible_point", + -200: "unbounded", + -201: "infeasible_or_unbounded", + -202: "iteration_limit", + -203: "time_limit", + -204: "function_evaluation_limit", + -300: "unbounded", + -400: "iteration_limit", + -401: "time_limit", + -410: "mip_node_limit", + -411: "mip_solution_limit", + } + + io_api = read_io_api_from_problem_file(problem_fn) + sense = read_sense_from_problem_file(problem_fn) + + try: + kc = knitro.KN_new() + except Exception as e: + msg = f"Failed to create Knitro solver instance: {e}" + raise RuntimeError(msg) + + try: + # Read the problem file + ret = knitro.KN_load_mps_file(kc, path_to_string(problem_fn)) + if ret != 0: + msg = f"Failed to load problem file: Knitro error code {ret}" + raise RuntimeError(msg) + + # Set log file if specified + if log_fn is not None: + knitro.KN_set_param_by_name(kc, "outlev", 6) # Enable detailed output + knitro.KN_set_param_by_name(kc, "outmode", path_to_string(log_fn)) + + # Set solver options + for k, v in self.solver_options.items(): + if isinstance(v, int | float): + knitro.KN_set_param_by_name(kc, k, v) + elif isinstance(v, str): + knitro.KN_set_char_param_by_name(kc, k, v) + + # Load warmstart if provided + if warmstart_fn is not None: + try: + # Knitro doesn't have direct basis loading, but we can set initial values + logger.info( + "Warmstart not directly supported by Knitro LP interface" + ) + except Exception as err: + logger.info("Warmstart could not be loaded. Error: %s", err) + + # Solve the problem + ret = knitro.KN_solve(kc) + + # Get termination condition + termination_condition = CONDITION_MAP.get(ret, "unknown") + status = Status.from_termination_condition(termination_condition) + status.legacy_status = ret + + def get_solver_solution() -> Solution: + # Get objective value + try: + obj_ptr = knitro.KN_get_obj_value(kc) + objective = obj_ptr[0] if obj_ptr[1] == 0 else np.nan + except Exception: + objective = np.nan + + # Get variable values + try: + n_vars = knitro.KN_get_number_vars(kc) + x_ptr = knitro.KN_get_var_primal_values(kc, n_vars) + if x_ptr[1] == 0: + # Get variable names + var_names = [] + for i in range(n_vars): + name_ptr = knitro.KN_get_var_name(kc, i) + if name_ptr[1] == 0: + var_names.append(name_ptr[0]) + else: + var_names.append(f"x{i}") + sol = pd.Series(x_ptr[0], index=var_names, dtype=float) + else: + sol = pd.Series(dtype=float) + except Exception as e: + logger.warning(f"Could not extract primal solution: {e}") + sol = pd.Series(dtype=float) + + # Get dual values (constraint multipliers) + try: + n_cons = knitro.KN_get_number_cons(kc) + if n_cons > 0: + dual_ptr = knitro.KN_get_con_dual_values(kc, n_cons) + if dual_ptr[1] == 0: + # Get constraint names + con_names = [] + for i in range(n_cons): + name_ptr = knitro.KN_get_con_name(kc, i) + if name_ptr[1] == 0: + con_names.append(name_ptr[0]) + else: + con_names.append(f"c{i}") + dual = pd.Series(dual_ptr[0], index=con_names, dtype=float) + else: + dual = pd.Series(dtype=float) + else: + dual = pd.Series(dtype=float) + except Exception as e: + logger.warning(f"Could not extract dual solution: {e}") + 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) + + # Save basis if requested + if basis_fn is not None: + try: + # Knitro doesn't have direct basis export for LP files + logger.info( + "Basis export not directly supported by Knitro LP interface" + ) + except Exception as err: + logger.info("No basis stored. Error: %s", err) + + # Save solution if requested + if solution_fn is not None: + try: + knitro.KN_write_mps_file(kc, path_to_string(solution_fn)) + except Exception as err: + logger.info("Could not write solution file. Error: %s", err) + + return Result(status, solution, kc) + + finally: + # Clean up Knitro context + knitro.KN_free(kc) + + mosek_bas_re = re.compile(r" (XL|XU)\s+([^ \t]+)\s+([^ \t]+)| (LL|UL|BS)\s+([^ \t]+)") diff --git a/test/test_solvers.py b/test/test_solvers.py index 129c1e0b..561eb69f 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -67,6 +67,44 @@ 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(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.objective == 30.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.""" + # Set some common Knitro 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( "gurobi" not in set(solvers.available_solvers), reason="Gurobi is not installed" ) From 2337f1604b2cc15a4cbb30195eb35932658d9daa Mon Sep 17 00:00:00 2001 From: Fabian Date: Sun, 14 Dec 2025 12:42:44 +0100 Subject: [PATCH 2/3] Fix Knitro solver integration --- linopy/solvers.py | 195 +++++++++++++++++++++++++++++++++------------- 1 file changed, 141 insertions(+), 54 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 23345b6d..be39f115 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -96,11 +96,6 @@ available_solvers.append("xpress") -with contextlib.suppress(ModuleNotFoundError, ImportError): - import knitro - - available_solvers.append("knitro") - # xpress.Namespaces was added in xpress 9.6 try: from xpress import Namespaces as xpress_Namespaces @@ -112,6 +107,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 @@ -1702,26 +1705,67 @@ def solve_problem_from_file( Result """ # Knitro status codes: https://www.artelys.com/app/docs/knitro/3_referenceManual/knitroReturnCodes.html - CONDITION_MAP = { - 0: "optimal", - -100: "feasible_point", - -101: "infeasible", - -102: "feasible_point", - -200: "unbounded", - -201: "infeasible_or_unbounded", - -202: "iteration_limit", - -203: "time_limit", - -204: "function_evaluation_limit", - -300: "unbounded", - -400: "iteration_limit", - -401: "time_limit", - -410: "mip_node_limit", - -411: "mip_solution_limit", + 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, } io_api = read_io_api_from_problem_file(problem_fn) sense = read_sense_from_problem_file(problem_fn) + def unpack_value_and_rc(obj: Any) -> tuple[Any, int]: + if isinstance(obj, tuple) and len(obj) == 2: + return obj[0], int(obj[1]) + return obj, 0 + + def set_param_by_name(kc: Any, name: str, value: Any) -> None: + if isinstance(value, bool): + value = int(value) + + if isinstance(value, int): + setter = getattr(knitro, "KN_set_int_param_by_name", None) or getattr( + knitro, "KN_set_param_by_name", None + ) + elif isinstance(value, float): + setter = getattr( + knitro, "KN_set_double_param_by_name", None + ) or getattr(knitro, "KN_set_param_by_name", None) + elif isinstance(value, str): + setter = getattr(knitro, "KN_set_char_param_by_name", None) + else: + logger.warning( + "Ignoring unsupported Knitro option %r=%r (type %s)", + name, + value, + type(value).__name__, + ) + return + + if setter is None: + logger.warning( + "Could not set Knitro option %r; required setter not available in knitro Python API.", + name, + ) + return + + try: + setter(kc, name, value) + except Exception as e: + logger.warning("Could not set Knitro option %r=%r: %s", name, value, e) + + kc = None try: kc = knitro.KN_new() except Exception as e: @@ -1730,22 +1774,36 @@ def solve_problem_from_file( try: # Read the problem file - ret = knitro.KN_load_mps_file(kc, path_to_string(problem_fn)) + problem_path = path_to_string(problem_fn) + if io_api is not None and io_api.startswith("lp"): + load_fn = getattr(knitro, "KN_load_lp_file", None) + if load_fn is None: + msg = "Knitro Python API does not support loading LP files (missing KN_load_lp_file)." + raise RuntimeError(msg) + else: + load_fn = getattr(knitro, "KN_load_mps_file", None) + if load_fn is None: + msg = "Knitro Python API does not support loading MPS files (missing KN_load_mps_file)." + raise RuntimeError(msg) + + ret = int(load_fn(kc, problem_path)) if ret != 0: msg = f"Failed to load problem file: Knitro error code {ret}" raise RuntimeError(msg) # Set log file if specified if log_fn is not None: - knitro.KN_set_param_by_name(kc, "outlev", 6) # Enable detailed output - knitro.KN_set_param_by_name(kc, "outmode", path_to_string(log_fn)) + log_fn.parent.mkdir(parents=True, exist_ok=True) + with open(path_to_string(log_fn), "w", encoding="utf-8") as f: + f.write("linopy: knitro log\n") + + set_param_by_name(kc, "outlev", 6) + set_param_by_name(kc, "outmode", 1) + set_param_by_name(kc, "outname", path_to_string(log_fn)) # Set solver options for k, v in self.solver_options.items(): - if isinstance(v, int | float): - knitro.KN_set_param_by_name(kc, k, v) - elif isinstance(v, str): - knitro.KN_set_char_param_by_name(kc, k, v) + set_param_by_name(kc, k, v) # Load warmstart if provided if warmstart_fn is not None: @@ -1758,35 +1816,47 @@ def solve_problem_from_file( logger.info("Warmstart could not be loaded. Error: %s", err) # Solve the problem - ret = knitro.KN_solve(kc) + ret = int(knitro.KN_solve(kc)) # Get termination condition - termination_condition = CONDITION_MAP.get(ret, "unknown") + 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 = ret + status.legacy_status = str(ret) def get_solver_solution() -> Solution: # Get objective value try: - obj_ptr = knitro.KN_get_obj_value(kc) - objective = obj_ptr[0] if obj_ptr[1] == 0 else np.nan + obj_val, obj_rc = unpack_value_and_rc(knitro.KN_get_obj_value(kc)) + objective = float(obj_val) if obj_rc == 0 else np.nan except Exception: objective = np.nan # Get variable values try: - n_vars = knitro.KN_get_number_vars(kc) - x_ptr = knitro.KN_get_var_primal_values(kc, n_vars) - if x_ptr[1] == 0: + n_vars_val, n_vars_rc = unpack_value_and_rc( + knitro.KN_get_number_vars(kc) + ) + n_vars = int(n_vars_val) if n_vars_rc == 0 else 0 + + x_val, x_rc = unpack_value_and_rc( + knitro.KN_get_var_primal_values(kc, n_vars) + ) + if x_rc == 0 and n_vars > 0: # Get variable names var_names = [] for i in range(n_vars): - name_ptr = knitro.KN_get_var_name(kc, i) - if name_ptr[1] == 0: - var_names.append(name_ptr[0]) - else: - var_names.append(f"x{i}") - sol = pd.Series(x_ptr[0], index=var_names, dtype=float) + name_val, name_rc = unpack_value_and_rc( + knitro.KN_get_var_name(kc, i) + ) + var_names.append(str(name_val) if name_rc == 0 else f"x{i}") + + sol = pd.Series(x_val, index=var_names, dtype=float) else: sol = pd.Series(dtype=float) except Exception as e: @@ -1795,19 +1865,27 @@ def get_solver_solution() -> Solution: # Get dual values (constraint multipliers) try: - n_cons = knitro.KN_get_number_cons(kc) + n_cons_val, n_cons_rc = unpack_value_and_rc( + knitro.KN_get_number_cons(kc) + ) + n_cons = int(n_cons_val) if n_cons_rc == 0 else 0 + if n_cons > 0: - dual_ptr = knitro.KN_get_con_dual_values(kc, n_cons) - if dual_ptr[1] == 0: + dual_val, dual_rc = unpack_value_and_rc( + knitro.KN_get_con_dual_values(kc, n_cons) + ) + if dual_rc == 0: # Get constraint names con_names = [] for i in range(n_cons): - name_ptr = knitro.KN_get_con_name(kc, i) - if name_ptr[1] == 0: - con_names.append(name_ptr[0]) - else: - con_names.append(f"c{i}") - dual = pd.Series(dual_ptr[0], index=con_names, dtype=float) + name_val, name_rc = unpack_value_and_rc( + knitro.KN_get_con_name(kc, i) + ) + con_names.append( + str(name_val) if name_rc == 0 else f"c{i}" + ) + + dual = pd.Series(dual_val, index=con_names, dtype=float) else: dual = pd.Series(dtype=float) else: @@ -1834,15 +1912,24 @@ def get_solver_solution() -> Solution: # Save solution if requested if solution_fn is not None: try: - knitro.KN_write_mps_file(kc, path_to_string(solution_fn)) + write_sol = getattr(knitro, "KN_write_sol_file", None) + if write_sol is None: + logger.info( + "Solution export not supported by Knitro interface; ignoring solution_fn=%s", + solution_fn, + ) + else: + solution_fn.parent.mkdir(parents=True, exist_ok=True) + write_sol(kc, path_to_string(solution_fn)) except Exception as err: logger.info("Could not write solution file. Error: %s", err) - return Result(status, solution, kc) + return Result(status, solution) finally: - # Clean up Knitro context - knitro.KN_free(kc) + if kc is not None: + 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]+)") From 241c6e9341c2b412829d6171633e3df138eac65b Mon Sep 17 00:00:00 2001 From: Fabian Date: Sun, 14 Dec 2025 12:51:45 +0100 Subject: [PATCH 3/3] Document Knitro and improve file loading --- doc/release_notes.rst | 1 + linopy/solvers.py | 43 ++++++++++++++++++++++++++++++++++--------- pyproject.toml | 1 + 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 5ca5ecc7..c1b5a0e2 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,6 +4,7 @@ Release Notes .. Upcoming Version * Fix compatibility for xpress versions below 9.6 (regression) +* Add support for the Artelys Knitro solver (via the Knitro Python API) * Performance: Up to 50x faster ``repr()`` for variables/constraints via O(log n) label lookup and direct numpy indexing * Performance: Up to 46x faster ``ncons`` property by replacing ``.flat.labels.unique()`` with direct counting diff --git a/linopy/solvers.py b/linopy/solvers.py index be39f115..c32ff90d 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1775,18 +1775,43 @@ def set_param_by_name(kc: Any, name: str, value: Any) -> None: try: # Read the problem file problem_path = path_to_string(problem_fn) - if io_api is not None and io_api.startswith("lp"): - load_fn = getattr(knitro, "KN_load_lp_file", None) - if load_fn is None: - msg = "Knitro Python API does not support loading LP files (missing KN_load_lp_file)." - raise RuntimeError(msg) + suffix = problem_fn.suffix.lower() + if suffix == ".lp": + candidate_loaders = [ + "KN_load_lp_file", + "KN_load_file", + "KN_load_mps_file", + ] + elif suffix == ".mps": + candidate_loaders = [ + "KN_load_mps_file", + "KN_load_file", + "KN_load_lp_file", + ] else: - load_fn = getattr(knitro, "KN_load_mps_file", None) + candidate_loaders = [ + "KN_load_file", + "KN_load_mps_file", + "KN_load_lp_file", + ] + + last_ret: int | None = None + for candidate in candidate_loaders: + load_fn = getattr(knitro, candidate, None) if load_fn is None: - msg = "Knitro Python API does not support loading MPS files (missing KN_load_mps_file)." - raise RuntimeError(msg) + continue + ret_val, _ret_rc = unpack_value_and_rc(load_fn(kc, problem_path)) + last_ret = int(ret_val) + if last_ret == 0: + break + else: + msg = ( + "Knitro Python API does not expose a suitable file loader for " + f"{suffix or 'unknown'} problems (tried: {', '.join(candidate_loaders)})." + ) + raise RuntimeError(msg) - ret = int(load_fn(kc, problem_path)) + ret = 0 if last_ret is None else last_ret if ret != 0: msg = f"Failed to load problem file: Knitro error code {ret}" raise RuntimeError(msg) diff --git a/pyproject.toml b/pyproject.toml index b5105230..18c03f66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ solvers = [ "coptpy!=7.2.1", "xpress; platform_system != 'Darwin' and python_version < '3.11'", "pyscipopt; platform_system != 'Darwin'", + "knitro" ] [tool.setuptools.packages.find]