Skip to content

Commit d5136e7

Browse files
FabianHofmannfinozzifapre-commit-ci[bot]
authored
Add Knitro solver support (#532)
* 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 * Fix Knitro solver integration * Document Knitro and improve file loading * code: add check to solve mypy issue * code: remove unnecessary candidate loaders * code: remove unnecessary candidate loaders * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * code: use just KN_read_problem for lp * add read_options * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * code: update KN_read_problem calling * code: new changes from Daniele Lerede * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * code: add reported runtime * code: remove unnecessary code * doc: update README.md and realease_notes * code: add new unit tests for Knitro * code: add new unit tests for Knitro * code: add test for lp for knitro * code: add test for lp for knitro * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * code: add-back again skip * code: remove uncomment to skipif * add namedtuple * include pre-commit checks * fix type checking * simplify Knitro solver class Remove excessive error handling, getattr usage, and unpack_value_and_rc. Use direct Knitro API calls, extract _set_option and _extract_values helpers. Add missing INTEGER_VARIABLES and READ_MODEL_FROM_FILE capabilities. Fix test variable names and remove dead warmstart/basis no-ops. * code: update pyproject.toml and solver attributes * code: update KN attribute dependence * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Fabrizio Finozzi <fabrizio.finozzi.business@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 6655b54 commit d5136e7

6 files changed

Lines changed: 315 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ Fri 0 4
150150
* [MOSEK](https://www.mosek.com/)
151151
* [COPT](https://www.shanshu.ai/copt)
152152
* [cuPDLPx](https://github.com/MIT-Lu-Lab/cuPDLPx)
153+
* [Knitro](https://www.artelys.com/solvers/knitro/)
153154

154155
Note that these do have to be installed by the user separately.
155156

doc/release_notes.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Release Notes
44
Upcoming Version
55
----------------
66

7+
* Add support for the `knitro` solver via the knitro python API
8+
79
Version 0.6.3
810
--------------
911

linopy/solver_capabilities.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,19 @@ def supports(self, feature: SolverFeature) -> bool:
161161
}
162162
),
163163
),
164+
"knitro": SolverInfo(
165+
name="knitro",
166+
display_name="Artelys Knitro",
167+
features=frozenset(
168+
{
169+
SolverFeature.INTEGER_VARIABLES,
170+
SolverFeature.QUADRATIC_OBJECTIVE,
171+
SolverFeature.LP_FILE_NAMES,
172+
SolverFeature.READ_MODEL_FROM_FILE,
173+
SolverFeature.SOLUTION_FILE_NOT_NEEDED,
174+
}
175+
),
176+
),
164177
"scip": SolverInfo(
165178
name="scip",
166179
display_name="SCIP",

linopy/solvers.py

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,14 @@ class xpress_Namespaces: # type: ignore[no-redef]
176176
SET = 3
177177

178178

179+
with contextlib.suppress(ModuleNotFoundError, ImportError):
180+
import knitro
181+
182+
with contextlib.suppress(Exception):
183+
kc = knitro.KN_new()
184+
knitro.KN_free(kc)
185+
available_solvers.append("knitro")
186+
179187
with contextlib.suppress(ModuleNotFoundError):
180188
import mosek
181189

@@ -239,6 +247,7 @@ class SolverName(enum.Enum):
239247
Gurobi = "gurobi"
240248
SCIP = "scip"
241249
Xpress = "xpress"
250+
Knitro = "knitro"
242251
Mosek = "mosek"
243252
COPT = "copt"
244253
MindOpt = "mindopt"
@@ -1252,7 +1261,7 @@ def get_solver_solution() -> Solution:
12521261
return Solution(sol, dual, objective)
12531262

12541263
solution = self.safe_get_solution(status=status, func=get_solver_solution)
1255-
solution = solution = maybe_adjust_objective_sign(solution, io_api, sense)
1264+
solution = maybe_adjust_objective_sign(solution, io_api, sense)
12561265

12571266
return Result(status, solution, m)
12581267

@@ -1736,6 +1745,200 @@ def get_solver_solution() -> Solution:
17361745
return Result(status, solution, m)
17371746

17381747

1748+
KnitroResult = namedtuple("KnitroResult", "reported_runtime")
1749+
1750+
1751+
class Knitro(Solver[None]):
1752+
"""
1753+
Solver subclass for the Knitro solver.
1754+
1755+
For more information on solver options, see
1756+
https://www.artelys.com/app/docs/knitro/3_referenceManual/knitroPythonReference.html
1757+
1758+
Attributes
1759+
----------
1760+
**solver_options
1761+
options for the given solver
1762+
"""
1763+
1764+
def __init__(
1765+
self,
1766+
**solver_options: Any,
1767+
) -> None:
1768+
super().__init__(**solver_options)
1769+
1770+
def solve_problem_from_model(
1771+
self,
1772+
model: Model,
1773+
solution_fn: Path | None = None,
1774+
log_fn: Path | None = None,
1775+
warmstart_fn: Path | None = None,
1776+
basis_fn: Path | None = None,
1777+
env: None = None,
1778+
explicit_coordinate_names: bool = False,
1779+
) -> Result:
1780+
msg = "Direct API not implemented for Knitro"
1781+
raise NotImplementedError(msg)
1782+
1783+
@staticmethod
1784+
def _set_option(kc: Any, name: str, value: Any) -> None:
1785+
param_id = knitro.KN_get_param_id(kc, name)
1786+
1787+
if isinstance(value, bool):
1788+
value = int(value)
1789+
1790+
if isinstance(value, int):
1791+
knitro.KN_set_int_param(kc, param_id, value)
1792+
elif isinstance(value, float):
1793+
knitro.KN_set_double_param(kc, param_id, value)
1794+
elif isinstance(value, str):
1795+
knitro.KN_set_char_param(kc, param_id, value)
1796+
else:
1797+
msg = f"Unsupported Knitro option type for {name!r}: {type(value).__name__}"
1798+
raise TypeError(msg)
1799+
1800+
@staticmethod
1801+
def _extract_values(
1802+
kc: Any,
1803+
get_count_fn: Callable[..., Any],
1804+
get_values_fn: Callable[..., Any],
1805+
get_names_fn: Callable[..., Any],
1806+
) -> pd.Series:
1807+
n = int(get_count_fn(kc))
1808+
if n == 0:
1809+
return pd.Series(dtype=float)
1810+
1811+
values = get_values_fn(kc, n - 1)
1812+
names = list(get_names_fn(kc))
1813+
return pd.Series(values, index=names, dtype=float)
1814+
1815+
def solve_problem_from_file(
1816+
self,
1817+
problem_fn: Path,
1818+
solution_fn: Path | None = None,
1819+
log_fn: Path | None = None,
1820+
warmstart_fn: Path | None = None,
1821+
basis_fn: Path | None = None,
1822+
env: None = None,
1823+
) -> Result:
1824+
"""
1825+
Solve a linear problem from a problem file using the Knitro solver.
1826+
1827+
Parameters
1828+
----------
1829+
problem_fn : Path
1830+
Path to the problem file.
1831+
solution_fn : Path, optional
1832+
Path to the solution file.
1833+
log_fn : Path, optional
1834+
Path to the log file.
1835+
warmstart_fn : Path, optional
1836+
Path to the warmstart file.
1837+
basis_fn : Path, optional
1838+
Path to the basis file.
1839+
env : None, optional
1840+
Environment for the solver.
1841+
1842+
Returns
1843+
-------
1844+
Result
1845+
"""
1846+
CONDITION_MAP: dict[int, TerminationCondition] = {
1847+
0: TerminationCondition.optimal,
1848+
-100: TerminationCondition.suboptimal,
1849+
-101: TerminationCondition.infeasible,
1850+
-102: TerminationCondition.suboptimal,
1851+
-200: TerminationCondition.unbounded,
1852+
-201: TerminationCondition.infeasible_or_unbounded,
1853+
-202: TerminationCondition.iteration_limit,
1854+
-203: TerminationCondition.time_limit,
1855+
-204: TerminationCondition.terminated_by_limit,
1856+
-300: TerminationCondition.unbounded,
1857+
-400: TerminationCondition.iteration_limit,
1858+
-401: TerminationCondition.time_limit,
1859+
-410: TerminationCondition.terminated_by_limit,
1860+
-411: TerminationCondition.terminated_by_limit,
1861+
}
1862+
1863+
READ_OPTIONS: dict[str, str] = {".lp": "l", ".mps": "m"}
1864+
1865+
io_api = read_io_api_from_problem_file(problem_fn)
1866+
sense = read_sense_from_problem_file(problem_fn)
1867+
1868+
suffix = problem_fn.suffix.lower()
1869+
if suffix not in READ_OPTIONS:
1870+
msg = f"Unsupported problem file format: {suffix}"
1871+
raise ValueError(msg)
1872+
1873+
kc = knitro.KN_new()
1874+
try:
1875+
knitro.KN_read_problem(
1876+
kc,
1877+
path_to_string(problem_fn),
1878+
read_options=READ_OPTIONS[suffix],
1879+
)
1880+
1881+
if log_fn is not None:
1882+
logger.warning("Log file output not implemented for Knitro")
1883+
1884+
for k, v in self.solver_options.items():
1885+
self._set_option(kc, k, v)
1886+
1887+
ret = int(knitro.KN_solve(kc))
1888+
1889+
reported_runtime: float | None = None
1890+
with contextlib.suppress(Exception):
1891+
reported_runtime = float(knitro.KN_get_solve_time_real(kc))
1892+
1893+
if ret in CONDITION_MAP:
1894+
termination_condition = CONDITION_MAP[ret]
1895+
elif ret > 0:
1896+
termination_condition = TerminationCondition.internal_solver_error
1897+
else:
1898+
termination_condition = TerminationCondition.unknown
1899+
1900+
status = Status.from_termination_condition(termination_condition)
1901+
status.legacy_status = str(ret)
1902+
1903+
def get_solver_solution() -> Solution:
1904+
objective = float(knitro.KN_get_obj_value(kc))
1905+
1906+
sol = self._extract_values(
1907+
kc,
1908+
knitro.KN_get_number_vars,
1909+
knitro.KN_get_var_primal_values,
1910+
knitro.KN_get_var_names,
1911+
)
1912+
1913+
try:
1914+
dual = self._extract_values(
1915+
kc,
1916+
knitro.KN_get_number_cons,
1917+
knitro.KN_get_con_dual_values,
1918+
knitro.KN_get_con_names,
1919+
)
1920+
except Exception:
1921+
logger.warning("Dual values couldn't be parsed")
1922+
dual = pd.Series(dtype=float)
1923+
1924+
return Solution(sol, dual, objective)
1925+
1926+
solution = self.safe_get_solution(status=status, func=get_solver_solution)
1927+
solution = maybe_adjust_objective_sign(solution, io_api, sense)
1928+
1929+
if solution_fn is not None:
1930+
solution_fn.parent.mkdir(exist_ok=True)
1931+
knitro.KN_write_mps_file(kc, path_to_string(solution_fn))
1932+
1933+
return Result(
1934+
status, solution, KnitroResult(reported_runtime=reported_runtime)
1935+
)
1936+
1937+
finally:
1938+
with contextlib.suppress(Exception):
1939+
knitro.KN_free(kc)
1940+
1941+
17391942
mosek_bas_re = re.compile(r" (XL|XU)\s+([^ \t]+)\s+([^ \t]+)| (LL|UL|BS)\s+([^ \t]+)")
17401943

17411944

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ solvers = [
8484
"coptpy!=7.2.1",
8585
"xpress; platform_system != 'Darwin' and python_version < '3.11'",
8686
"pyscipopt; platform_system != 'Darwin'",
87+
"knitro>=15.1.0",
8788
# "cupdlpx>=0.1.2", pip package currently unstable
8889
]
8990

test/test_solvers.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@
4545
ENDATA
4646
"""
4747

48+
free_lp_problem = """
49+
Maximize
50+
z: 3 x + 4 y
51+
Subject To
52+
c1: 2 x + y <= 10
53+
c2: x + 2 y <= 12
54+
Bounds
55+
0 <= x
56+
0 <= y
57+
End
58+
"""
59+
4860

4961
@pytest.mark.parametrize("solver", set(solvers.available_solvers))
5062
def test_free_mps_solution_parsing(solver: str, tmp_path: Path) -> None:
@@ -71,6 +83,88 @@ def test_free_mps_solution_parsing(solver: str, tmp_path: Path) -> None:
7183
assert result.solution.objective == 30.0
7284

7385

86+
@pytest.mark.skipif(
87+
"knitro" not in set(solvers.available_solvers), reason="Knitro is not installed"
88+
)
89+
def test_knitro_solver_mps(tmp_path: Path) -> None:
90+
"""Test Knitro solver with a simple MPS problem."""
91+
knitro = solvers.Knitro()
92+
93+
mps_file = tmp_path / "problem.mps"
94+
mps_file.write_text(free_mps_problem)
95+
sol_file = tmp_path / "solution.sol"
96+
97+
result = knitro.solve_problem(problem_fn=mps_file, solution_fn=sol_file)
98+
99+
assert result.status.is_ok
100+
assert result.solution is not None
101+
assert result.solution.objective == 30.0
102+
103+
104+
@pytest.mark.skipif(
105+
"knitro" not in set(solvers.available_solvers), reason="Knitro is not installed"
106+
)
107+
def test_knitro_solver_for_lp(tmp_path: Path) -> None:
108+
"""Test Knitro solver with a simple LP problem."""
109+
knitro = solvers.Knitro()
110+
111+
lp_file = tmp_path / "problem.lp"
112+
lp_file.write_text(free_lp_problem)
113+
sol_file = tmp_path / "solution.sol"
114+
115+
result = knitro.solve_problem(problem_fn=lp_file, solution_fn=sol_file)
116+
117+
assert result.status.is_ok
118+
assert result.solution is not None
119+
assert result.solution.objective == pytest.approx(26.666, abs=1e-3)
120+
121+
122+
@pytest.mark.skipif(
123+
"knitro" not in set(solvers.available_solvers), reason="Knitro is not installed"
124+
)
125+
def test_knitro_solver_with_options(tmp_path: Path) -> None:
126+
"""Test Knitro solver with custom options."""
127+
knitro = solvers.Knitro(maxit=100, feastol=1e-6)
128+
129+
mps_file = tmp_path / "problem.mps"
130+
mps_file.write_text(free_mps_problem)
131+
sol_file = tmp_path / "solution.sol"
132+
log_file = tmp_path / "knitro.log"
133+
134+
result = knitro.solve_problem(
135+
problem_fn=mps_file, solution_fn=sol_file, log_fn=log_file
136+
)
137+
assert result.status.is_ok
138+
139+
140+
@pytest.mark.skipif(
141+
"knitro" not in set(solvers.available_solvers), reason="Knitro is not installed"
142+
)
143+
def test_knitro_solver_with_model_raises_error(model: Model) -> None: # noqa: F811
144+
"""Test Knitro solver raises NotImplementedError for model-based solving."""
145+
knitro = solvers.Knitro()
146+
with pytest.raises(
147+
NotImplementedError, match="Direct API not implemented for Knitro"
148+
):
149+
knitro.solve_problem(model=model)
150+
151+
152+
@pytest.mark.skipif(
153+
"knitro" not in set(solvers.available_solvers), reason="Knitro is not installed"
154+
)
155+
def test_knitro_solver_no_log(tmp_path: Path) -> None:
156+
"""Test Knitro solver without log file."""
157+
knitro = solvers.Knitro(outlev=0)
158+
159+
mps_file = tmp_path / "problem.mps"
160+
mps_file.write_text(free_mps_problem)
161+
sol_file = tmp_path / "solution.sol"
162+
163+
result = knitro.solve_problem(problem_fn=mps_file, solution_fn=sol_file)
164+
165+
assert result.status.is_ok
166+
167+
74168
@pytest.mark.skipif(
75169
"gurobi" not in set(solvers.available_solvers), reason="Gurobi is not installed"
76170
)

0 commit comments

Comments
 (0)